Files
Rajat Kapoor 051a266090 feat(typefully): add planned drafts support (#32)
## What

Adds planned-drafts support to the Typefully skill, matching the API v2
write support shipping in typefully-backend#1785.

A **planned draft** sits on the queue/calendar at its date but is inert
— it never auto-publishes until confirmed into a real schedule.

## Changes

- **`--plan <iso|next-free-slot>`** on `drafts:create` and
`drafts:update` → sends `plan_at`. Mutually exclusive with `--schedule`
(friendly CLI error before hitting the API). On update, literal `null`
clears the plan and returns the draft to plain draft status (same
convention as `--cover-media-id null`).
- **New `drafts:plan <social_set_id> <draft_id> --time <...>`** command
mirroring `drafts:schedule`, including the `--use-default` single-arg
safety gate.
- **`drafts:list --status planned`** documented (the filter was already
passed through; help text and docs now include it).
- **SKILL.md**: planned lifecycle in the Schedule & publish section
(confirm via `drafts:schedule`, publish via `drafts:publish`, lapsed
planned dates are *not* failures — replan or confirm), a Common-actions
row, flag/command table entries, and planned-aware `queue:get`
description.
- **CHANGELOG.md**: user-facing entry.

## Tests

6 new tests in `tests/drafts.test.js` covering: `drafts:plan` payload +
missing `--time`, create with `--plan` (and no `publish_at` leak), the
`--schedule`+`--plan` mutual-exclusion error, update with `--plan`, and
`--plan null` sending `plan_at: null`. Full suite: **130 passed**.

## Notes

- The backend PR (typefully-backend#1785) should merge/deploy first;
until then `plan_at` is rejected by the API.
- Not included (possible follow-up): an unschedule command (`publish_at:
null`) — the CLI never had one and it's orthogonal to planned drafts.

https://claude.ai/code/session_01267PabjVxa2EQueJ9oZoT9
2026-08-05 15:15:57 +05:30

1839 lines
64 KiB
JavaScript

const {
describe,
it,
assert,
fs,
path,
runCli,
parseJsonOrNull,
authAssertFactory,
withCliHarness,
} = require('./typefully-cli.test-helpers');
describe('drafts', () => {
it('drafts:update with only --tags does not touch content (no platforms payload)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/100813/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { tags: ['owner/me'] });
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', 'd1', '--tags', 'owner/me', '--social-set-id', '100813'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: apiKey,
},
}
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
assert.equal(server.requests.length, 1);
}));
it('update-draft alias: tag-only update works and does not crash', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/100813/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { tags: ['owner/me'] });
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['update-draft', 'd1', '--tags', 'owner/me', '--social-set-id', '100813'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: apiKey,
},
}
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
assert.equal(server.requests.length, 1);
}));
it('create-draft alias: positional text forwards correctly to drafts:create', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/100812/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: {
enabled: true,
posts: [{ text: 'Test content' }],
},
},
tags: ['owner/me'],
});
},
json: { id: 'new-draft' },
});
const result = await runCli(
['create-draft', 'Test content', '--social-set-id', '100812', '--platform', 'x', '--tags', 'owner/me'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: apiKey,
},
}
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'new-draft' });
server.assertNoPendingExpectations();
assert.equal(server.requests.length, 1);
}));
it('draft target safety: drafts:get single arg with default social set requires --use-default', withCliHarness(async ({
sandbox, server, baseUrl
}) => {
// Create local config with a default social set.
const cfgDir = path.join(sandbox.cwd, '.typefully');
await fs.mkdir(cfgDir, { recursive: true });
await fs.writeFile(path.join(cfgDir, 'config.json'), JSON.stringify({ defaultSocialSetId: '111' }, null, 2));
const result = await runCli(
['drafts:get', 'd99'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: 'typ_test_key',
},
}
);
assert.equal(result.code, 1);
const out = parseJsonOrNull(result.stdout);
assert.ok(out?.error?.includes('Ambiguous arguments for drafts:get'));
assert.equal(server.requests.length, 0);
}));
it('drafts:get with --use-default uses configured default social set', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const cfgDir = path.join(sandbox.cwd, '.typefully');
await fs.mkdir(cfgDir, { recursive: true });
await fs.writeFile(path.join(cfgDir, 'config.json'), JSON.stringify({ defaultSocialSetId: '111' }, null, 2));
server.expect('GET', '/v2/social-sets/111/drafts/d99', {
assert: authAssertFactory(apiKey),
json: { id: 'd99' },
});
const result = await runCli(
['drafts:get', 'd99', '--use-default'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: apiKey,
},
}
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd99' });
server.assertNoPendingExpectations();
}));
it('drafts:create chooses first connected platform when --platform omitted', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/55', {
assert: authAssertFactory(apiKey),
json: { id: '55', platforms: { x: {}, linkedin: {} } },
});
server.expect('POST', '/v2/social-sets/55/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(Object.keys(req.bodyJson.platforms).join(','), 'x');
assert.equal(req.bodyJson.platforms.x.posts[0].text, 'Hello');
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '55', '--text', 'Hello'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: apiKey,
},
}
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:create --all targets all connected platforms', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/55', {
assert: authAssertFactory(apiKey),
json: { id: '55', platforms: { linkedin: {}, x: {}, threads: {} } },
});
server.expect('POST', '/v2/social-sets/55/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(Object.keys(req.bodyJson.platforms), ['x', 'linkedin', 'threads']);
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '55', '--all', '--text', 'Hello'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: apiKey,
},
}
);
assert.equal(result.code, 0);
server.assertNoPendingExpectations();
}));
it('drafts:create --all excludes x_article from connected post platforms', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/55', {
assert: authAssertFactory(apiKey),
json: { id: '55', platforms: { x_article: {}, linkedin: {}, x: {} } },
});
server.expect('POST', '/v2/social-sets/55/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(Object.keys(req.bodyJson.platforms), ['x', 'linkedin']);
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '55', '--all', '--text', 'Hello'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: apiKey,
},
}
);
assert.equal(result.code, 0);
server.assertNoPendingExpectations();
}));
it('drafts:create targets substack with a single post', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
substack: {
enabled: true,
posts: [{ text: 'A quick note' }],
},
},
});
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '9', '--platform', 'substack', '--text', 'A quick note'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:create --all includes substack when connected', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/55', {
assert: authAssertFactory(apiKey),
json: { id: '55', platforms: { substack: {}, x: {} } },
});
server.expect('POST', '/v2/social-sets/55/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(Object.keys(req.bodyJson.platforms), ['x', 'substack']);
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '55', '--all', '--text', 'Hello'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
server.assertNoPendingExpectations();
}));
it('drafts:create errors when thread content targets substack', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'x,substack', '--text', 'Post one\n---\nPost two'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: 'substack (Substack Notes) supports a single post per draft — threads are not supported. Use a single post, or target other platforms with --platform.',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:update --append errors when the draft targets substack', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
substack: { enabled: true, posts: [{ text: 'Existing note' }] },
},
},
});
const result = await runCli(
['drafts:update', '9', 'd1', '--append', '--text', 'One more'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: 'substack (Substack Notes) supports a single post per draft — threads are not supported. Use a single post, or target other platforms with --platform.',
});
server.assertNoPendingExpectations();
assert.equal(server.requests.length, 1);
}));
it('drafts:create supports standalone X Article markdown and cover media', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const markdown = '# Article Title\n\nFirst section\n---\nStill the same article body';
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x_article: {
content_markdown: markdown,
cover_media_id: 'media-123',
},
},
draft_title: 'Article draft',
tags: ['longform'],
});
},
json: { id: 'article-1' },
});
const result = await runCli(
[
'drafts:create',
'9',
'--platform',
'x_article',
'--content-markdown',
markdown,
'--cover-media-id',
'media-123',
'--title',
'Article draft',
'--tags',
'longform',
],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'article-1' });
server.assertNoPendingExpectations();
}));
it('create-draft alias forwards X Article content and cover flags', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const markdown = '# Alias Article\n\nBody';
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x_article: {
content_markdown: markdown,
cover_media_id: 'cover-1',
},
},
});
},
json: { id: 'article-alias' },
});
const result = await runCli(
[
'create-draft',
'--social-set-id',
'9',
'--platform',
'x_article',
'--content-markdown',
markdown,
'--cover-media-id',
'cover-1',
],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'article-alias' });
server.assertNoPendingExpectations();
}));
it('drafts:schedule sends publish_at payload', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { publish_at: 'next-free-slot' });
},
json: { id: 'd1', scheduled: true },
});
const result = await runCli(
['drafts:schedule', '9', 'd1', '--time', 'next-free-slot'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: apiKey,
},
}
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', scheduled: true });
server.assertNoPendingExpectations();
}));
it('drafts:plan sends plan_at payload', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { plan_at: 'next-free-slot' });
},
json: { id: 'd1', status: 'planned' },
});
const result = await runCli(
['drafts:plan', '9', 'd1', '--time', 'next-free-slot'],
{
cwd: sandbox.cwd,
env: {
HOME: sandbox.home,
TYPEFULLY_API_BASE: baseUrl,
TYPEFULLY_API_KEY: apiKey,
},
}
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'planned' });
server.assertNoPendingExpectations();
}));
it('drafts:plan requires --time', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const result = await runCli(
['drafts:plan', '9', 'd1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--time is required (use "next-free-slot" or a future ISO datetime)',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:create sends plan_at payload with --plan', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.bodyJson.plan_at, 'next-free-slot');
assert.ok(!('publish_at' in req.bodyJson));
},
json: { id: 'd1', status: 'planned' },
});
const result = await runCli(
['drafts:create', '9', '--platform', 'x', '--text', 'Pencil this in', '--plan', 'next-free-slot'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'planned' });
server.assertNoPendingExpectations();
}));
it('drafts:create rejects --schedule combined with --plan', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'x', '--text', 'Hello', '--schedule', 'now', '--plan', 'next-free-slot'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--schedule and --plan are mutually exclusive - provide only one',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:update sends plan_at payload with --plan', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { plan_at: '2027-01-20T14:00:00Z' });
},
json: { id: 'd1', status: 'planned' },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--plan', '2027-01-20T14:00:00Z'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'planned' });
server.assertNoPendingExpectations();
}));
it('drafts:update --plan null clears the plan (sends plan_at: null)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { plan_at: null });
},
json: { id: 'd1', status: 'draft' },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--plan', 'null'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'draft' });
server.assertNoPendingExpectations();
}));
it('drafts:list builds query params (status/tag/sort/limit)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
const q = new URL('http://x' + req.path + req.search).searchParams;
assert.equal(q.get('limit'), '3');
assert.equal(q.get('status'), 'scheduled');
assert.equal(q.get('tag'), 'owner/me');
assert.equal(q.get('order_by'), '-created_at');
},
json: { results: [{ id: 'd1' }] },
});
const result = await runCli(
['drafts:list', '9', '--limit', '3', '--status', 'scheduled', '--tag', 'owner/me', '--sort', '-created_at'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { results: [{ id: 'd1' }] });
server.assertNoPendingExpectations();
}));
it('drafts:publish sends publish_at=now', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { publish_at: 'now' });
},
json: { id: 'd1', published: true },
});
const result = await runCli(
['drafts:publish', '9', 'd1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', published: true });
server.assertNoPendingExpectations();
}));
it('drafts:delete uses DELETE and returns success payload', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('DELETE', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {},
});
const result = await runCli(
['drafts:delete', '9', 'd1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { success: true, message: 'Draft deleted' });
server.assertNoPendingExpectations();
}));
it('drafts:update with no update fields errors (message mentions --tags)', withCliHarness(async ({
sandbox, server, baseUrl
}) => {
const result = await runCli(
['drafts:update', '9', 'd1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: 'At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --plan, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, --hide-link-preview, or --force-overwrite-comments is required',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:update can clear tags with --tags \"\" (sends empty array)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { tags: [] });
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--tags', ''],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:create splits threads on --- and attaches media only to first post', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
const posts = req.bodyJson.platforms.x.posts;
assert.equal(posts.length, 2);
assert.deepEqual(posts[0], { text: 'First', media_ids: ['m1', 'm2'] });
assert.deepEqual(posts[1], { text: 'Second' });
},
json: { id: 'd1' },
});
const threadText = 'First\n---\nSecond';
const result = await runCli(
['drafts:create', '9', '--platform', 'x', '--text', threadText, '--media', 'm1,m2'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:create thread splitting supports CRLF (\\r\\n)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
const posts = req.bodyJson.platforms.x.posts;
assert.equal(posts.length, 2);
assert.deepEqual(posts[0], { text: 'First', media_ids: ['m1', 'm2'] });
assert.deepEqual(posts[1], { text: 'Second' });
},
json: { id: 'd1' },
});
const threadText = 'First\r\n---\r\nSecond';
const result = await runCli(
['drafts:create', '9', '--platform', 'x', '--text', threadText, '--media', 'm1,m2'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:update --append fetches existing draft and appends a new post', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
x: {
enabled: true,
posts: [{ text: 'Old' }],
},
},
},
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: {
enabled: true,
posts: [{ text: 'Old' }, { text: 'New', media_ids: ['m1'] }],
},
},
});
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--append', '--text', 'New', '--media', 'm1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:update replaces posts and attaches media only to first post', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
x: { enabled: true, posts: [{ text: 'Old' }] },
},
},
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
const posts = req.bodyJson.platforms.x.posts;
assert.deepEqual(posts, [
{ text: 'First', media_ids: ['m1'] },
{ text: 'Second' },
]);
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--text', 'First\n---\nSecond', '--media', 'm1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:create supports title/schedule/share/notes/tags/reply-to/community', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: {
enabled: true,
posts: [{ text: 'Hello' }],
settings: {
reply_to_url: 'https://x.com/user/status/123',
community_id: '999',
},
},
},
draft_title: 'Title',
publish_at: 'now',
tags: ['a', 'b'],
share: true,
scratchpad_text: 'Some notes',
});
},
json: { id: 'd1' },
});
const result = await runCli(
[
'drafts:create',
'9',
'--platform',
'x',
'--text',
'Hello',
'--title',
'Title',
'--schedule',
'now',
'--tags',
'a,b',
'--share',
'--scratchpad',
'Some notes',
'--reply-to',
'https://x.com/user/status/123',
'--community',
'999',
],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:create applies quote_post_url only to X posts', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const quoteUrl = 'https://x.com/typefullyapp/status/1234567890123456789';
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: {
enabled: true,
posts: [{ text: 'Hello', quote_post_url: quoteUrl }],
},
linkedin: {
enabled: true,
posts: [{ text: 'Hello' }],
},
},
});
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '9', '--platform', 'x,linkedin', '--text', 'Hello', '--quote-post-url', quoteUrl],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:create accepts --quote-url alias', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const quoteUrl = 'https://x.com/typefullyapp/status/1234567890123456789';
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.bodyJson.platforms.x.posts[0].quote_post_url, quoteUrl);
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '9', '--platform', 'x', '--text', 'Hello', '--quote-url', quoteUrl],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:create applies X content disclosures only to X posts', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: {
enabled: true,
posts: [{ text: 'Sponsored AI update', paid_partnership: true, made_with_ai: true }],
},
linkedin: {
enabled: true,
posts: [{ text: 'Sponsored AI update' }],
},
},
});
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '9', '--platform', 'x,linkedin', '--text', 'Sponsored AI update', '--paid-partnership', '--made-with-ai'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:create errors when quote URL is used without X platform', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'linkedin', '--text', 'Hello', '--quote-post-url', 'https://x.com/user/status/123'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--quote-post-url is only supported for X posts. Include x in --platform or remove the quote flag.',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:create errors when X content disclosures are used without X platform', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'linkedin', '--text', 'Hello', '--made-with-ai'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--paid-partnership/--made-with-ai is only supported for X posts. Include x in --platform or remove the X-only flag.',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:create applies --hide-link-preview only to LinkedIn, Threads, and Substack posts', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
linkedin: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
},
threads: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
},
bluesky: {
enabled: true,
posts: [{ text: 'Read this https://example.com' }],
},
substack: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
},
},
});
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '9', '--platform', 'linkedin,threads,bluesky,substack', '--text', 'Read this https://example.com', '--hide-link-preview'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:create errors when --hide-link-preview is used without LinkedIn, Threads, or Substack', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'bluesky', '--text', 'Read this https://example.com', '--hide-link-preview'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--hide-link-preview is only supported for LinkedIn, Threads, and Substack posts. Include linkedin, threads, or substack in --platform or remove the flag.',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:create surfaces API VALIDATION_ERROR messages for quote URLs', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const validationMessage = 'Invalid quote_post_url. Expected an X/Twitter post URL like https://x.com/<username>/status/<id>.';
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: authAssertFactory(apiKey),
status: 400,
json: {
code: 'VALIDATION_ERROR',
message: validationMessage,
},
});
const result = await runCli(
['drafts:create', '9', '--platform', 'x', '--text', 'Hello', '--quote-post-url', 'https://example.com/not-x'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 1);
const out = parseJsonOrNull(result.stdout);
assert.equal(out?.error, `Validation error: ${validationMessage}`);
assert.deepEqual(out?.response, {
code: 'VALIDATION_ERROR',
message: validationMessage,
});
server.assertNoPendingExpectations();
}));
it('drafts:create reads from file via -f', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const filePath = path.join(sandbox.cwd, 'post.txt');
await fs.writeFile(filePath, 'From file');
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.bodyJson.platforms.x.posts[0].text, 'From file');
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '9', '--platform', 'x', '-f', filePath],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
server.assertNoPendingExpectations();
}));
it('drafts:create errors when both --all and --platform are provided', withCliHarness(async ({
sandbox
}) => {
const result = await runCli(
['drafts:create', '9', '--all', '--platform', 'x', '--text', 'Hello'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), { error: 'Cannot use both --all and --platform flags' });
}));
it('drafts:create errors when neither --text nor --file are provided', withCliHarness(async ({
sandbox
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'x'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), { error: '--text or --file is required' });
}));
it('drafts:create requires X Article flags to use --platform x_article', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:create', '9', '--content-markdown', '# Title\n\nBody'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: 'X Article flags require --platform x_article',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:create requires content markdown for X Article drafts', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'x_article', '--cover-media-id', 'cover-1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--content-markdown is required for X Article drafts',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:create rejects x_article combined with other platforms', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'x_article,x', '--content-markdown', '# Title\n\nBody'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: 'x_article is standalone and cannot be combined with other platforms',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:create rejects post-only flags for X Article drafts', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:create', '9', '--platform', 'x_article', '--content-markdown', '# Title\n\nBody', '--media', 'm1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--media cannot be used with --platform x_article',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:update rejects post-only flags for X Article updates before reading files', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:update', '9', 'article-1', '--platform', 'x_article', '--file', './missing.md'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--file cannot be used with --platform x_article',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:update supports title/schedule/share/notes without fetching existing draft', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
draft_title: 'Title',
publish_at: 'now',
share: true,
scratchpad_text: 'Notes',
tags: ['t1'],
});
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--title', 'Title', '--schedule', 'now', '--share', '--notes', 'Notes', '--tags', 't1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.equal(server.requests.length, 1);
server.assertNoPendingExpectations();
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
}));
it('drafts:update supports X Article content and cover without fetching existing draft', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const markdown = '# Updated Article\n\nBody with --- preserved';
server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x_article: {
content_markdown: markdown,
cover_media_id: 'cover-2',
},
},
});
},
json: { id: 'article-1', ok: true },
});
const result = await runCli(
[
'drafts:update',
'9',
'article-1',
'--platform',
'x_article',
'--content-markdown',
markdown,
'--cover-media-id',
'cover-2',
],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.equal(server.requests.length, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'article-1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:update supports X Article cover-only update and removal with null', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x_article: {
cover_media_id: 'cover-3',
},
},
});
},
json: { id: 'article-1', ok: true },
});
server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x_article: {
cover_media_id: null,
},
},
});
},
json: { id: 'article-1', ok: true },
});
const setCover = await runCli(
['drafts:update', '9', 'article-1', '--platform', 'x_article', '--cover-media-id', 'cover-3'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(setCover.code, 0);
const removeCover = await runCli(
['drafts:update', '9', 'article-1', '--platform', 'x_article', '--cover-media-id', 'null'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(removeCover.code, 0);
assert.equal(server.requests.length, 2);
server.assertNoPendingExpectations();
}));
it('update-draft alias forwards X Article update flags', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const markdown = '# Alias Update\n\nBody';
server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x_article: {
content_markdown: markdown,
},
},
});
},
json: { id: 'article-1', ok: true },
});
const result = await runCli(
[
'update-draft',
'article-1',
'--social-set-id',
'9',
'--platform',
'x_article',
'--content-markdown',
markdown,
],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'article-1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:update reads from file via -f and can target multiple platforms', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const filePath = path.join(sandbox.cwd, 'thread.txt');
await fs.writeFile(filePath, 'First\n---\nSecond');
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: { id: 'd1', platforms: { x: { enabled: true, posts: [{ text: 'Old' }] } } },
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: {
enabled: true,
posts: [{ text: 'First', media_ids: ['m1'] }, { text: 'Second' }],
},
linkedin: {
enabled: true,
posts: [{ text: 'First', media_ids: ['m1'] }, { text: 'Second' }],
},
},
});
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--platform', 'x,linkedin', '-f', filePath, '--media', 'm1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
server.assertNoPendingExpectations();
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
}));
it('drafts:update supports -a shorthand for --append', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
x: { enabled: true, posts: [{ text: 'Old' }] },
},
},
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: { enabled: true, posts: [{ text: 'Old' }, { text: 'New' }] },
},
});
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '-a', '--text', 'New'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
server.assertNoPendingExpectations();
}));
it('default social set safety: drafts:update/delete/schedule/publish require --use-default with a single arg', withCliHarness(async ({
sandbox
}) => {
const cfgDir = path.join(sandbox.cwd, '.typefully');
await fs.mkdir(cfgDir, { recursive: true });
await fs.writeFile(path.join(cfgDir, 'config.json'), JSON.stringify({ defaultSocialSetId: '9' }, null, 2));
for (const args of [
['drafts:update', 'd1', '--title', 'T'],
['drafts:delete', 'd1'],
['drafts:schedule', 'd1', '--time', 'next-free-slot'],
['drafts:publish', 'd1'],
]) {
const result = await runCli(args, {
cwd: sandbox.cwd,
env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' },
});
assert.equal(result.code, 1);
const out = parseJsonOrNull(result.stdout);
assert.ok(out?.error?.includes('Ambiguous arguments'));
}
}));
it('drafts:schedule/delete/publish work with --use-default and configured default social set', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const cfgDir = path.join(sandbox.cwd, '.typefully');
await fs.mkdir(cfgDir, { recursive: true });
await fs.writeFile(path.join(cfgDir, 'config.json'), JSON.stringify({ defaultSocialSetId: '9' }, null, 2));
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { publish_at: 'next-free-slot' });
},
json: { id: 'd1', scheduled: true },
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, { publish_at: 'now' });
},
json: { id: 'd1', published: true },
});
server.expect('DELETE', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {},
});
const sched = await runCli(['drafts:schedule', 'd1', '--time', 'next-free-slot', '--use-default'], {
cwd: sandbox.cwd,
env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey },
});
assert.equal(sched.code, 0);
const pub = await runCli(['drafts:publish', 'd1', '--use-default'], {
cwd: sandbox.cwd,
env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey },
});
assert.equal(pub.code, 0);
const del = await runCli(['drafts:delete', 'd1', '--use-default'], {
cwd: sandbox.cwd,
env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey },
});
assert.equal(del.code, 0);
server.assertNoPendingExpectations();
}));
it('drafts:list accepts --social_set_id (snake_case)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.search, '?limit=1');
},
json: { results: [] },
});
const result = await runCli(['drafts:list', '--social_set_id', '9', '--limit', '1'], {
cwd: sandbox.cwd,
env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey },
});
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { results: [] });
server.assertNoPendingExpectations();
}));
it('drafts:create reads from file via --file (long form)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const filePath = path.join(sandbox.cwd, 'post.txt');
await fs.writeFile(filePath, 'Hello from file', 'utf8');
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.bodyJson.platforms.x.posts[0].text, 'Hello from file');
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '9', '--platform', 'x', '--file', filePath],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:update reads from file via --file (long form)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const filePath = path.join(sandbox.cwd, 'post.txt');
await fs.writeFile(filePath, 'Updated from file', 'utf8');
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: { id: 'd1', platforms: { x: { enabled: true, posts: [{ text: 'Old' }] } } },
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.bodyJson.platforms.x.posts[0].text, 'Updated from file');
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--platform', 'x', '--file', filePath],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:update applies quote_post_url only to X posts', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const quoteUrl = 'https://x.com/typefullyapp/status/1234567890123456789';
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
x: { enabled: true, posts: [{ text: 'Old X' }] },
linkedin: { enabled: true, posts: [{ text: 'Old LI' }] },
},
},
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: {
enabled: true,
posts: [{ text: 'Hello', quote_post_url: quoteUrl }],
},
linkedin: {
enabled: true,
posts: [{ text: 'Hello' }],
},
},
});
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--platform', 'x,linkedin', '--text', 'Hello', '--quote-post-url', quoteUrl],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:update can set quote URL without changing text', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
const quoteUrl = 'https://x.com/typefullyapp/status/1234567890123456789';
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
x: { enabled: true, posts: [{ text: 'Existing text' }] },
},
},
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: {
enabled: true,
posts: [{ text: 'Existing text', quote_post_url: quoteUrl }],
},
},
});
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--quote-url', quoteUrl],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:update can hide the link preview without changing text', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
linkedin: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: false }],
},
threads: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: false }],
},
bluesky: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: false }],
},
},
},
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
linkedin: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
},
threads: {
enabled: true,
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
},
},
});
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--hide-link-preview'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:update errors on --hide-link-preview when the draft has no LinkedIn, Threads, or Substack posts', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
bluesky: { enabled: true, posts: [{ text: 'Read this https://example.com' }] },
},
},
});
const result = await runCli(
['drafts:update', '9', 'd1', '--hide-link-preview'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--hide-link-preview is only supported for LinkedIn, Threads, and Substack posts. Include linkedin, threads, or substack in --platform or remove the flag.',
});
assert.equal(server.requests.length, 1);
server.assertNoPendingExpectations();
}));
it('drafts:update can set X content disclosures without changing text', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: {
id: 'd1',
platforms: {
x: {
enabled: true,
posts: [{ text: 'Existing 1' }, { text: 'Existing 2' }],
},
},
},
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: {
enabled: true,
posts: [
{ text: 'Existing 1', made_with_ai: true },
{ text: 'Existing 2', made_with_ai: true },
],
},
},
});
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--made-with-ai'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:update errors when quote URL is used without X platform', withCliHarness(async ({
sandbox, server
}) => {
const result = await runCli(
['drafts:update', '9', 'd1', '--platform', 'linkedin', '--text', 'Hello', '--quote-post-url', 'https://x.com/user/status/123'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--quote-post-url is only supported for X posts. Include x in --platform or remove the quote flag.',
});
assert.equal(server.requests.length, 0);
}));
it('drafts:schedule errors when --time is missing', withCliHarness(async ({
sandbox, server, baseUrl
}) => {
const result = await runCli(
['drafts:schedule', '9', 'd1'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: 'typ_test_key' } }
);
assert.equal(result.code, 1);
assert.deepEqual(parseJsonOrNull(result.stdout), {
error: '--time is required (use "next-free-slot" or ISO datetime)',
});
assert.equal(server.requests.length, 0);
}));
it('global flag: drafts:list accepts --social-set-id (kebab-case)', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('GET', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.search, '?limit=10');
},
json: { results: [] },
});
const result = await runCli(
['drafts:list', '--social-set-id', '9'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { results: [] });
server.assertNoPendingExpectations();
}));
it('global flag: drafts:create accepts --social-set-id (kebab-case) with no positional social set', withCliHarness(async ({
sandbox, server, baseUrl, apiKey
}) => {
server.expect('POST', '/v2/social-sets/9/drafts', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.bodyJson.platforms.x.posts[0].text, 'Hello');
},
json: { id: 'd1' },
});
const result = await runCli(
['drafts:create', '--social-set-id', '9', '--platform', 'x', '--text', 'Hello'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
server.assertNoPendingExpectations();
}));
it('drafts:get with --exclude-comment-markers adds exclude_comment_markers query param', withCliHarness(async ({
sandbox, server, baseUrl, apiKey,
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.search, '?exclude_comment_markers=true');
},
json: { id: 'd1', platforms: { x: { posts: [{ text: 'plain text' }] } } },
});
const result = await runCli(
['drafts:get', '9', 'd1', '--exclude-comment-markers'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), {
id: 'd1',
platforms: { x: { posts: [{ text: 'plain text' }] } },
});
server.assertNoPendingExpectations();
}));
it('drafts:update with only --force-overwrite-comments sends PATCH without fetching existing draft', withCliHarness(async ({
sandbox, server, baseUrl, apiKey,
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.search, '');
assert.deepEqual(req.bodyJson, { force_overwrite_comments: true });
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--force-overwrite-comments'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
assert.equal(server.requests.length, 1);
server.assertNoPendingExpectations();
}));
it('drafts:update combines --text with --force-overwrite-comments in body', withCliHarness(async ({
sandbox, server, baseUrl, apiKey,
}) => {
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
assert: authAssertFactory(apiKey),
json: { id: 'd1', platforms: { x: { enabled: true, posts: [{ text: 'Old' }] } } },
});
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.deepEqual(req.bodyJson, {
platforms: {
x: { enabled: true, posts: [{ text: 'Rewritten body' }] },
},
force_overwrite_comments: true,
});
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--text', 'Rewritten body', '--force-overwrite-comments'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));
it('drafts:update with --exclude-comment-markers adds query string to PATCH URL', withCliHarness(async ({
sandbox, server, baseUrl, apiKey,
}) => {
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
assert: (req) => {
authAssertFactory(apiKey)(req);
assert.equal(req.search, '?exclude_comment_markers=true');
assert.deepEqual(req.bodyJson, { force_overwrite_comments: true });
},
json: { id: 'd1', ok: true },
});
const result = await runCli(
['drafts:update', '9', 'd1', '--force-overwrite-comments', '--exclude-comment-markers'],
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
);
assert.equal(result.code, 0);
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
server.assertNoPendingExpectations();
}));
});