From baa164f005233c6341636fe4fad655ffd2c8648e Mon Sep 17 00:00:00 2001 From: Fabrizio Rinaldi Date: Tue, 26 May 2026 10:59:19 +0100 Subject: [PATCH] feat(typefully): support X subscribers-only drafts --- skills/typefully/CHANGELOG.md | 1 + skills/typefully/SKILL.md | 54 ++++- skills/typefully/scripts/typefully.js | 181 +++++++++++++- tests/drafts.test.js | 329 +++++++++++++++++++++++++- 4 files changed, 555 insertions(+), 10 deletions(-) diff --git a/skills/typefully/CHANGELOG.md b/skills/typefully/CHANGELOG.md index cdde431..1d44665 100644 --- a/skills/typefully/CHANGELOG.md +++ b/skills/typefully/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on Keep a Changelog. - `analytics:followers:get [social_set_id]` to fetch X follower analytics, with optional `--start-date` / `--end-date` date filters and snake_case aliases. - `analytics:posts:list` now supports `--include-replies` (alias: `--include_replies`) to opt in to X reply posts. - `--paid-partnership` / `--paid_partnership` and `--made-with-ai` / `--made_with_ai` for X draft create/update disclosure flags. +- `--subscribers-only` and `--subscribers-only-posts ` for X draft create/update, allowing all-post or exact per-post Subscribers-only audience selection for eligible X Creator Subscriptions accounts. - Typefully skill docs now explain how to check `publishing_quota` with `social-sets:get`. ### Changed diff --git a/skills/typefully/SKILL.md b/skills/typefully/SKILL.md index a0bd016..b3eada1 100644 --- a/skills/typefully/SKILL.md +++ b/skills/typefully/SKILL.md @@ -4,7 +4,7 @@ description: > Create, schedule, and manage social media posts via Typefully. ALWAYS use this skill when asked to draft, schedule, post, or check tweets, posts, threads, or social media content for Twitter/X, LinkedIn, Threads, Bluesky, or Mastodon. -last-updated: 2026-05-05 +last-updated: 2026-05-26 allowed-tools: Bash(./scripts/typefully.js:*) --- @@ -108,6 +108,7 @@ When determining which social set to use: | User says... | Action | |--------------|--------| | "Draft a tweet about X" | `drafts:create --text "..."` (uses default social set) | +| "Draft a subscribers-only X post" | `drafts:create --platform x --text "..." --subscribers-only` (eligible X Creator Subscriptions accounts only) | | "Post this to LinkedIn" | `drafts:create --platform linkedin --text "..."` | | "Mention a company on LinkedIn" | `linkedin:organizations:resolve --organization-url ""` then use returned `mention_text` in `drafts:create` | | "Post to X and LinkedIn" (same content) | `drafts:create --platform x,linkedin --text "..."` | @@ -210,6 +211,33 @@ Here's what we shipped and why it matters..." --use-default So make sure to NEVER create multiple drafts unless the user explicitly wants separate drafts for each platform. +## X Subscribers-Only Posts + +Subscribers-only posts are an X Creator Subscriptions feature. Use these flags only when the connected X account is eligible, approved for Creator Subscriptions, and has an active offering. Typefully can store and send the audience flag, but X may reject publishing for accounts that do not meet the [Creator Monetization Standards](https://help.x.com/en/rules-and-policies/content-monetization-standards) or the [Creator Subscriptions requirements](https://help.x.com/en/using-x/subscriptions-creator). + +Use `--subscribers-only` to mark every X post in the submitted draft/update as Subscribers-only: + +```bash +./scripts/typefully.js drafts:create --platform x --text "For subscribers" --subscribers-only +``` + +Use `--subscribers-only-posts ` for exact zero-based per-post control: + +```bash +# Only the second post is Subscribers-only +./scripts/typefully.js drafts:create --platform x --text $'Public intro\n---\nSubscriber detail' --subscribers-only-posts 1 + +# Clear Subscribers-only flags from an existing X draft +./scripts/typefully.js drafts:update 456 --subscribers-only-posts none --use-default +``` + +**Important constraints:** + +- Subscribers-only flags are X-only and are applied only to X posts in a multi-platform draft. +- Subscribers-only X posts cannot be combined with X Communities. Remove the community audience first. +- Use zero-based indexes for `--subscribers-only-posts`, matching `comments:create --post-index`. +- Mixed public/subscribers-only threads are supported by the Typefully API, but X's behavior for public replies after gated posts still needs real-account verification. When in doubt, keep all posts after the first gated post Subscribers-only. + ## LinkedIn Mentions LinkedIn mentions are supported via text syntax inside post content: @@ -332,12 +360,16 @@ When updating a draft that has comments, preserve anchors from `drafts:get` in t | `drafts:create ... --quote-post-url ` | Quote an existing X post URL | | `drafts:create ... --paid-partnership` | Label X post(s) as paid partnership | | `drafts:create ... --made-with-ai` | Label X post(s) as made with AI | +| `drafts:create ... --subscribers-only` | Mark all X post(s) as Subscribers-only; eligible X Creator Subscriptions accounts only | +| `drafts:create ... --subscribers-only-posts ` | Mark exact zero-based X post indexes as Subscribers-only | | `drafts:create ... --share` | Generate a public share URL for the draft | | `drafts:create ... --scratchpad "..."` | Add internal notes/scratchpad to the draft | | `drafts:update [social_set_id] --text "..."` | Update an existing draft (single-arg requires `--use-default` if a default is configured) | | `drafts:update ... --quote-post-url ` | Update X post(s) in a draft to quote an existing post URL | | `drafts:update ... --paid-partnership` | Label existing or updated X post(s) as paid partnership | | `drafts:update ... --made-with-ai` | Label existing or updated X post(s) as made with AI | +| `drafts:update ... --subscribers-only` | Mark all X post(s) as Subscribers-only; eligible X Creator Subscriptions accounts only | +| `drafts:update ... --subscribers-only-posts ` | Set or clear exact zero-based Subscribers-only X post indexes; use `none` to clear | | `drafts:update [social_set_id] --tags "tag1,tag2"` | Update tags on an existing draft (content unchanged) | | `drafts:update ... --share` | Generate a public share URL for the draft | | `drafts:update ... --scratchpad "..."` | Update internal notes/scratchpad | @@ -521,6 +553,20 @@ Use `queue:get` when the user asks what is already scheduled (or free) for a giv ./scripts/typefully.js drafts:create --platform x --text "Sponsored AI-assisted update" --paid-partnership --made-with-ai ``` +### Create an X Subscribers-only post +```bash +./scripts/typefully.js drafts:create --platform x --text "For subscribers" --subscribers-only +``` + +Only use this for eligible, approved X Creator Subscriptions accounts with an active offering. + +### Create a mixed X thread with one Subscribers-only post +```bash +./scripts/typefully.js drafts:create --platform x --text $'Public intro\n---\nSubscriber detail' --subscribers-only-posts 1 +``` + +`--subscribers-only-posts` uses zero-based post indexes. It accepts `all`, `none`, or a comma-separated list like `0,2`. + ### Update a draft to quote an X post ```bash ./scripts/typefully.js drafts:update 456 --platform x --quote-post-url "https://x.com/user/status/1234567890123456789" --use-default @@ -531,6 +577,11 @@ Use `queue:get` when the user asks what is already scheduled (or free) for a giv ./scripts/typefully.js drafts:update 456 --made-with-ai --use-default ``` +### Clear Subscribers-only flags from an X draft +```bash +./scripts/typefully.js drafts:update 456 --subscribers-only-posts none --use-default +``` + ### Create draft with share URL ```bash ./scripts/typefully.js drafts:create --text "Check this out" --share @@ -654,6 +705,7 @@ When in doubt, create drafts for user review rather than publishing directly. - **X analytics**: Use `analytics:posts:list --start-date ... --end-date ...` to fetch post metrics for a social set; replies are excluded by default, and `--include-replies` opts back in - **X followers analytics**: Use `analytics:followers:get --start-date ... --end-date ...` to fetch daily follower counts, or omit dates for the API default range - **X content disclosures**: Use `--paid-partnership` and/or `--made-with-ai` on `drafts:create` or `drafts:update`; these flags are X-only and are applied only to X posts +- **X Subscribers-only posts**: Use `--subscribers-only` or `--subscribers-only-posts` only for eligible, approved X Creator Subscriptions accounts; these flags are X-only and cannot be combined with X Communities - **Publishing quota**: Use `social-sets:get` and inspect `publishing_quota` to see remaining publish capacity and reset time - **Read from file**: Use `--file ./post.txt` instead of `--text` to read content from a file - **Sorting drafts**: Use `--sort` with values like `created_at`, `-created_at`, `scheduled_date`, etc. diff --git a/skills/typefully/scripts/typefully.js b/skills/typefully/scripts/typefully.js index de46315..a563be9 100755 --- a/skills/typefully/scripts/typefully.js +++ b/skills/typefully/scripts/typefully.js @@ -414,15 +414,136 @@ function addXContentDisclosures(posts, disclosures) { }); } -function validateXOnlyPostOptions(platformList, { quotePostUrl, disclosures }) { - if ((quotePostUrl || disclosures.hasAny) && !platformList.includes('x')) { +function getXSubscribersOnlySelectionFromParsed(parsed) { + const hasAll = Boolean(parsed['subscribers-only'] || parsed.subscribers_only); + const hasPostsPrimary = Object.prototype.hasOwnProperty.call(parsed, 'subscribers-only-posts'); + const hasPostsAlias = Object.prototype.hasOwnProperty.call(parsed, 'subscribers_only_posts'); + + let postsValue = null; + if (hasPostsPrimary) { + postsValue = coerceFlagValueToString(parsed['subscribers-only-posts'], '--subscribers-only-posts'); + } + if (hasPostsAlias) { + const aliasValue = coerceFlagValueToString(parsed.subscribers_only_posts, '--subscribers_only_posts'); + if (postsValue && postsValue !== aliasValue) { + error('Conflicting subscribers-only post selector values', { + '--subscribers-only-posts': postsValue, + '--subscribers_only_posts': aliasValue, + }); + } + postsValue = postsValue || aliasValue; + } + + let selection = null; + if (postsValue != null) { + selection = parseSubscribersOnlyPostsSelector(postsValue); + } + + if (hasAll && selection && selection.mode !== 'all') { + error('Conflicting subscribers-only options', { + hint: 'Use either --subscribers-only for all X posts or --subscribers-only-posts for exact per-post selection.', + }); + } + + if (hasAll) { + return { hasAny: true, enablesAny: true, mode: 'all', explicitPostsSelector: false }; + } + + if (selection) { + return { ...selection, explicitPostsSelector: true }; + } + + return { hasAny: false, enablesAny: false, mode: null, explicitPostsSelector: false }; +} + +function parseSubscribersOnlyPostsSelector(value) { + const normalized = value.trim().toLowerCase(); + if (normalized === 'all') { + return { hasAny: true, enablesAny: true, mode: 'all' }; + } + if (normalized === 'none') { + return { hasAny: true, enablesAny: false, mode: 'none' }; + } + + const parts = value.split(',').map(part => part.trim()).filter(Boolean); + if (parts.length === 0 || parts.some(part => !/^\d+$/.test(part))) { + error('--subscribers-only-posts must be "all", "none", or a comma-separated list of zero-based post indexes'); + } + + const indexes = [...new Set(parts.map(part => Number.parseInt(part, 10)))]; + return { hasAny: true, enablesAny: indexes.length > 0, mode: 'indexes', indexes }; +} + +function subscribersOnlySelectionToFlagValue(selection) { + if (selection.mode === 'all') return 'all'; + if (selection.mode === 'none') return 'none'; + return selection.indexes.join(','); +} + +function addXSubscribersOnly(posts, selection) { + if (!selection.hasAny) return posts; + if (selection.mode === 'all') { + return posts.map(post => ({ ...post, subscribers_only: true })); + } + if (selection.mode === 'none') { + return posts.map(post => ({ ...post, subscribers_only: false })); + } + + for (const index of selection.indexes) { + if (index >= posts.length) { + error(`--subscribers-only-posts index ${index} is out of range for ${posts.length} post(s)`); + } + } + + return posts.map((post, index) => ({ + ...post, + subscribers_only: selection.indexes.includes(index), + })); +} + +function applyXPostOptions(posts, { quotePostUrl, disclosures, subscribersOnly }) { + return addXSubscribersOnly( + addXContentDisclosures(addQuotePostUrl(posts, quotePostUrl), disclosures), + subscribersOnly, + ); +} + +function validateXOnlyPostOptions(platformList, { quotePostUrl, disclosures, subscribersOnly }) { + if ((quotePostUrl || disclosures.hasAny || subscribersOnly.hasAny) && !platformList.includes('x')) { if (quotePostUrl) { error('--quote-post-url is only supported for X posts. Include x in --platform or remove the quote flag.'); } + if (subscribersOnly.hasAny) { + error('--subscribers-only is only supported for X posts. Include x in --platform or remove the subscribers-only flag.'); + } error('--paid-partnership/--made-with-ai is only supported for X posts. Include x in --platform or remove the X-only flag.'); } } +function validateXSubscribersOnlyCommunityConflict(communityId, subscribersOnly) { + if (!communityId || !subscribersOnly.enablesAny) return; + error('X subscribers-only posts cannot also be posted to an X community. Remove --community or the subscribers-only flag.'); +} + +function validateExistingXCommunityForSubscribersOnly(existing, subscribersOnly) { + if (!subscribersOnly.enablesAny) return; + const communityId = existing.platforms?.x?.settings?.community_id; + if (!communityId) return; + error('Cannot apply subscribers-only posts because this draft is configured for an X community. Remove the community audience first.'); +} + +function forwardSubscribersOnlyFlags(argv, parsed) { + const subscribersOnly = getXSubscribersOnlySelectionFromParsed(parsed); + if (!subscribersOnly.hasAny) return; + + if (subscribersOnly.mode === 'all' && !subscribersOnly.explicitPostsSelector) { + argv.push('--subscribers-only'); + return; + } + + argv.push('--subscribers-only-posts', subscribersOnlySelectionToFlagValue(subscribersOnly)); +} + function parseCsvArg(value, flagName) { // parseArgs sets missing values to true (e.g. `--tags --other-flag`) if (value === true) { @@ -1097,10 +1218,13 @@ async function cmdDraftsCreate(args) { paid_partnership: 'boolean', 'made-with-ai': 'boolean', made_with_ai: 'boolean', + 'subscribers-only': 'boolean', + subscribers_only: 'boolean', }); const socialSetId = resolveSocialSetIdFromParsed(parsed, parsed._positional[0]); const quotePostUrl = getQuotePostUrlFromParsed(parsed); const xContentDisclosures = getXContentDisclosuresFromParsed(parsed); + const xSubscribersOnly = getXSubscribersOnlySelectionFromParsed(parsed); // Get text content let text = parsed.text; @@ -1142,7 +1266,9 @@ async function cmdDraftsCreate(args) { validateXOnlyPostOptions(platformList, { quotePostUrl, disclosures: xContentDisclosures, + subscribersOnly: xSubscribersOnly, }); + validateXSubscribersOnlyCommunityConflict(parsed.community, xSubscribersOnly); // Split text into posts (thread support) const posts = splitThreadText(text); @@ -1164,7 +1290,11 @@ async function cmdDraftsCreate(args) { const platformsObj = {}; for (const platform of platformList) { const postsArray = platform === 'x' - ? addXContentDisclosures(addQuotePostUrl(basePostsArray, quotePostUrl), xContentDisclosures) + ? applyXPostOptions(basePostsArray, { + quotePostUrl, + disclosures: xContentDisclosures, + subscribersOnly: xSubscribersOnly, + }) : basePostsArray; const platformConfig = { enabled: true, @@ -1221,6 +1351,8 @@ async function cmdDraftsUpdate(args) { paid_partnership: 'boolean', 'made-with-ai': 'boolean', made_with_ai: 'boolean', + 'subscribers-only': 'boolean', + subscribers_only: 'boolean', 'exclude-comment-markers': 'boolean', exclude_comment_markers: 'boolean', 'force-overwrite-comments': 'boolean', @@ -1229,6 +1361,7 @@ async function cmdDraftsUpdate(args) { const { socialSetId, draftId } = resolveDraftTargetFromParsed(parsed, 'drafts:update'); const quotePostUrl = getQuotePostUrlFromParsed(parsed); const xContentDisclosures = getXContentDisclosuresFromParsed(parsed); + const xSubscribersOnly = getXSubscribersOnlySelectionFromParsed(parsed); // Get text content let text = parsed.text; @@ -1241,7 +1374,7 @@ async function cmdDraftsUpdate(args) { const body = {}; - const shouldUpdatePosts = Boolean(text || quotePostUrl || xContentDisclosures.hasAny); + const shouldUpdatePosts = Boolean(text || quotePostUrl || xContentDisclosures.hasAny || xSubscribersOnly.hasAny); if (shouldUpdatePosts) { const explicitPlatformList = parsed.platform ? parsed.platform.split(',').map(p => p.trim()) @@ -1250,6 +1383,7 @@ async function cmdDraftsUpdate(args) { validateXOnlyPostOptions(explicitPlatformList, { quotePostUrl, disclosures: xContentDisclosures, + subscribersOnly: xSubscribersOnly, }); } @@ -1258,6 +1392,7 @@ async function cmdDraftsUpdate(args) { // Fetch existing draft to determine platforms (and for --append, to get posts) const existing = await apiRequest('GET', `/social-sets/${socialSetId}/drafts/${draftId}`); + validateExistingXCommunityForSubscribersOnly(existing, xSubscribersOnly); // Determine which platforms to update let platformList; @@ -1283,6 +1418,7 @@ async function cmdDraftsUpdate(args) { validateXOnlyPostOptions(platformList, { quotePostUrl, disclosures: xContentDisclosures, + subscribersOnly: xSubscribersOnly, }); let postsArray; @@ -1316,10 +1452,10 @@ async function cmdDraftsUpdate(args) { }); } } else { - // X-only metadata update: preserve existing X posts and add quote/disclosure attrs. + // X-only metadata lives on each post, so metadata-only updates must round-trip existing X posts. const existingXPosts = existing.platforms?.x?.posts; if (!Array.isArray(existingXPosts) || existingXPosts.length === 0) { - if (quotePostUrl && !xContentDisclosures.hasAny) { + if (quotePostUrl && !xContentDisclosures.hasAny && !xSubscribersOnly.hasAny) { error('Cannot apply --quote-post-url because this draft has no existing X posts'); } error('Cannot apply X-only post options because this draft has no existing X posts'); @@ -1332,7 +1468,11 @@ async function cmdDraftsUpdate(args) { const platformsObj = {}; for (const p of platformList) { const platformPosts = p === 'x' - ? addXContentDisclosures(addQuotePostUrl(postsArray, quotePostUrl), xContentDisclosures) + ? applyXPostOptions(postsArray, { + quotePostUrl, + disclosures: xContentDisclosures, + subscribersOnly: xSubscribersOnly, + }) : postsArray; platformsObj[p] = { enabled: true, @@ -1367,7 +1507,7 @@ async function cmdDraftsUpdate(args) { } if (Object.keys(body).length === 0) { - error('At least one of --text, --file, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, or --force-overwrite-comments is required'); + error('At least one of --text, --file, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, --subscribers-only, --subscribers-only-posts, or --force-overwrite-comments is required'); } const params = new URLSearchParams(); @@ -1395,6 +1535,8 @@ async function cmdCreateDraftAlias(args) { paid_partnership: 'boolean', 'made-with-ai': 'boolean', made_with_ai: 'boolean', + 'subscribers-only': 'boolean', + subscribers_only: 'boolean', }); const socialSetId = requireSocialSetId(getSocialSetIdFromParsed(parsed)); @@ -1428,6 +1570,7 @@ async function cmdCreateDraftAlias(args) { if (quotePostUrl) forwarded.push('--quote-post-url', quotePostUrl); if (parsed['paid-partnership'] || parsed.paid_partnership) forwarded.push('--paid-partnership'); if (parsed['made-with-ai'] || parsed.made_with_ai) forwarded.push('--made-with-ai'); + forwardSubscribersOnlyFlags(forwarded, parsed); if (parsed.share) forwarded.push('--share'); pushStringFlag(forwarded, parsed, 'notes', '--notes'); @@ -1442,6 +1585,8 @@ async function cmdUpdateDraftAlias(args) { paid_partnership: 'boolean', 'made-with-ai': 'boolean', made_with_ai: 'boolean', + 'subscribers-only': 'boolean', + subscribers_only: 'boolean', }); const socialSetId = requireSocialSetId(getSocialSetIdFromParsed(parsed)); @@ -1474,6 +1619,7 @@ async function cmdUpdateDraftAlias(args) { if (quotePostUrl) forwarded.push('--quote-post-url', quotePostUrl); if (parsed['paid-partnership'] || parsed.paid_partnership) forwarded.push('--paid-partnership'); if (parsed['made-with-ai'] || parsed.made_with_ai) forwarded.push('--made-with-ai'); + forwardSubscribersOnlyFlags(forwarded, parsed); if (parsed.share) forwarded.push('--share'); pushStringFlag(forwarded, parsed, 'notes', '--notes'); @@ -1942,6 +2088,11 @@ COMMANDS: --quote-post-url, --quote-url Quote an X post URL (X only) --paid-partnership, --paid_partnership Label X posts as paid partnership --made-with-ai, --made_with_ai Label X posts as made with AI + --subscribers-only Mark all X posts as Subscribers-only + (eligible X Creator Subscriptions accounts only) + --subscribers-only-posts Mark exact zero-based X post indexes as + Subscribers-only. Also accepts: + --subscribers_only_posts --share Generate a public share URL for the draft --notes, --scratchpad Internal notes/scratchpad for the draft @@ -1958,6 +2109,11 @@ COMMANDS: --quote-post-url, --quote-url Quote an X post URL (X only) --paid-partnership, --paid_partnership Label X posts as paid partnership --made-with-ai, --made_with_ai Label X posts as made with AI + --subscribers-only Mark all X posts as Subscribers-only + (eligible X Creator Subscriptions accounts only) + --subscribers-only-posts Mark exact zero-based X post indexes as + Subscribers-only. Use "none" to clear. + Also accepts: --subscribers_only_posts --share Generate a public share URL for the draft --notes, --scratchpad Internal notes/scratchpad for the draft --exclude-comment-markers Render response posts[*].text without @@ -2143,9 +2299,18 @@ EXAMPLES: # Create an X post with content disclosure labels ./typefully.js drafts:create 123 --platform x --text "Sponsored AI-assisted update" --paid-partnership --made-with-ai + # Create an X Subscribers-only post (eligible Creator Subscriptions accounts only) + ./typefully.js drafts:create 123 --platform x --text "For subscribers" --subscribers-only + + # Create a mixed X thread with only the second post Subscribers-only + ./typefully.js drafts:create 123 --platform x --text $'Public intro\\n---\\nSubscriber detail' --subscribers-only-posts 1 + # Update an X draft to quote a post ./typefully.js drafts:update 123 456 --platform x --text "Updated take" --quote-post-url "https://x.com/user/status/1234567890123456789" + # Clear Subscribers-only flags from an existing X draft + ./typefully.js drafts:update 123 456 --subscribers-only-posts none + # Create draft with share URL ./typefully.js drafts:create 123 --platform x --text "Check this out" --share diff --git a/tests/drafts.test.js b/tests/drafts.test.js index e393b51..1e516b1 100644 --- a/tests/drafts.test.js +++ b/tests/drafts.test.js @@ -103,6 +103,76 @@ describe('drafts', () => { assert.equal(server.requests.length, 1); })); + it('create-draft alias forwards subscribers-only flags', 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.posts, [ + { text: 'Subscriber update', subscribers_only: true }, + ]); + }, + json: { id: 'new-draft' }, + }); + const result = await runCli( + ['create-draft', 'Subscriber update', '--social-set-id', '100812', '--platform', 'x', '--subscribers-only'], + { + 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('update-draft alias forwards subscribers-only post selectors', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey + }) => { + server.expect('GET', '/v2/social-sets/100813/drafts/d1', { + assert: authAssertFactory(apiKey), + json: { + id: 'd1', + platforms: { + x: { enabled: true, posts: [{ text: 'Existing' }] }, + }, + }, + }); + + server.expect('PATCH', '/v2/social-sets/100813/drafts/d1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson.platforms.x.posts, [ + { text: 'Existing', subscribers_only: false }, + ]); + }, + json: { id: 'd1', ok: true }, + }); + const result = await runCli( + ['update-draft', 'd1', '--social-set-id', '100813', '--subscribers-only-posts', 'none'], + { + 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, 2); + })); + it('draft target safety: drafts:get single arg with default social set requires --use-default', withCliHarness(async ({ sandbox, server, baseUrl }) => { @@ -314,7 +384,7 @@ describe('drafts', () => { ); assert.equal(result.code, 1); assert.deepEqual(parseJsonOrNull(result.stdout), { - error: 'At least one of --text, --file, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, or --force-overwrite-comments is required', + error: 'At least one of --text, --file, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, --subscribers-only, --subscribers-only-posts, or --force-overwrite-comments is required', }); assert.equal(server.requests.length, 0); })); @@ -592,6 +662,98 @@ describe('drafts', () => { server.assertNoPendingExpectations(); })); + it('drafts:create applies subscribers-only 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: 'Members update', subscribers_only: true }], + }, + linkedin: { + enabled: true, + posts: [{ text: 'Members update' }], + }, + }, + }); + }, + json: { id: 'd1' }, + }); + const result = await runCli( + ['drafts:create', '9', '--platform', 'x,linkedin', '--text', 'Members update', '--subscribers-only'], + { 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 subscribers-only to selected X post indexes', 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.posts, [ + { text: 'Public intro', subscribers_only: false }, + { text: 'Subscriber detail', subscribers_only: true }, + ]); + }, + json: { id: 'd1' }, + }); + const result = await runCli( + [ + 'drafts:create', + '9', + '--platform', + 'x', + '--text', + 'Public intro\n---\nSubscriber detail', + '--subscribers-only-posts', + '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: 'd1' }); + server.assertNoPendingExpectations(); + })); + + it('drafts:create accepts snake_case subscribers-only aliases', 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.posts, [ + { text: 'Subscriber intro', subscribers_only: true }, + { text: 'Public follow-up', subscribers_only: false }, + ]); + }, + json: { id: 'd1' }, + }); + const result = await runCli( + [ + 'drafts:create', + '9', + '--platform', + 'x', + '--text', + 'Subscriber intro\n---\nPublic follow-up', + '--subscribers_only_posts', + '0', + ], + { 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 }) => { @@ -606,6 +768,48 @@ describe('drafts', () => { assert.equal(server.requests.length, 0); })); + it('drafts:create errors when subscribers-only is used without X platform', withCliHarness(async ({ + sandbox, server + }) => { + const result = await runCli( + ['drafts:create', '9', '--platform', 'linkedin', '--text', 'Hello', '--subscribers-only'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } } + ); + assert.equal(result.code, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { + error: '--subscribers-only is only supported for X posts. Include x in --platform or remove the subscribers-only flag.', + }); + assert.equal(server.requests.length, 0); + })); + + it('drafts:create errors when subscribers-only indexes are out of range', withCliHarness(async ({ + sandbox, server + }) => { + const result = await runCli( + ['drafts:create', '9', '--platform', 'x', '--text', 'Hello', '--subscribers-only-posts', '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: '--subscribers-only-posts index 1 is out of range for 1 post(s)', + }); + assert.equal(server.requests.length, 0); + })); + + it('drafts:create errors when subscribers-only is combined with X community', withCliHarness(async ({ + sandbox, server + }) => { + const result = await runCli( + ['drafts:create', '9', '--platform', 'x', '--text', 'Hello', '--community', '999', '--subscribers-only'], + { 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 subscribers-only posts cannot also be posted to an X community. Remove --community or the subscribers-only flag.', + }); + assert.equal(server.requests.length, 0); + })); + it('drafts:create errors when X content disclosures are used without X platform', withCliHarness(async ({ sandbox, server }) => { @@ -1044,6 +1248,115 @@ describe('drafts', () => { server.assertNoPendingExpectations(); })); + it('drafts:update can set subscribers-only post indexes 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', subscribers_only: false }, + { text: 'Existing 2', subscribers_only: true }, + ], + }, + }, + }); + }, + json: { id: 'd1', ok: true }, + }); + const result = await runCli( + ['drafts:update', '9', 'd1', '--subscribers-only-posts', '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: 'd1', ok: true }); + server.assertNoPendingExpectations(); + })); + + it('drafts:update can clear subscribers-only post flags 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', subscribers_only: true }, + { text: 'Existing 2', subscribers_only: true }, + ], + }, + }, + }, + }); + + server.expect('PATCH', '/v2/social-sets/9/drafts/d1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson.platforms.x.posts, [ + { text: 'Existing 1', subscribers_only: false }, + { text: 'Existing 2', subscribers_only: false }, + ]); + }, + json: { id: 'd1', ok: true }, + }); + const result = await runCli( + ['drafts:update', '9', 'd1', '--subscribers-only-posts', 'none'], + { 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 before patching when existing X draft is a community 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: 'Existing' }], + settings: { community_id: '999' }, + }, + }, + }, + }); + + const result = await runCli( + ['drafts:update', '9', 'd1', '--subscribers-only'], + { 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: 'Cannot apply subscribers-only posts because this draft is configured for an X community. Remove the community audience first.', + }); + server.assertNoPendingExpectations(); + assert.equal(server.requests.length, 1); + })); + it('drafts:update errors when quote URL is used without X platform', withCliHarness(async ({ sandbox, server }) => { @@ -1058,6 +1371,20 @@ describe('drafts', () => { assert.equal(server.requests.length, 0); })); + it('drafts:update errors when subscribers-only is used without X platform', withCliHarness(async ({ + sandbox, server + }) => { + const result = await runCli( + ['drafts:update', '9', 'd1', '--platform', 'linkedin', '--text', 'Hello', '--subscribers-only'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } } + ); + assert.equal(result.code, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { + error: '--subscribers-only is only supported for X posts. Include x in --platform or remove the subscribers-only flag.', + }); + assert.equal(server.requests.length, 0); + })); + it('drafts:schedule errors when --time is missing', withCliHarness(async ({ sandbox, server, baseUrl }) => {