chirpie 1.0.44 → 1.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +46 -4
  2. package/dist/index.js +240 -47
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -30,11 +30,12 @@ export CHIRPIE_API_KEY=chirpie_sk_...
30
30
  | Command | What it does |
31
31
  |---------|--------------|
32
32
  | `chirpie login` / `logout` / `auth` / `whoami` | Manage credentials |
33
- | `chirpie post <text>` | Create a post. `-a` account, `-m` media files or URLs, `--alt` descriptions, `-s` schedule |
34
- | `chirpie thread <post> <post> ...` | Create a 2 to 25 post thread. `-f` takes a JSON payload file |
35
- | `chirpie posts` | List posts. `--status`, `--account`, `--limit` |
33
+ | `chirpie post <text>` | Create a post. `-a` account (repeat it for several accounts), `-m` media files or URLs, `--alt` descriptions, `--config` per-account overrides, `-s` schedule, `--draft` save without sending |
34
+ | `chirpie thread <post> <post> ...` | Create a 2 to 25 post thread, or 1 to 25 with `--draft`. `-a` is repeatable, `--config` takes per-account posts, `-f` takes a JSON payload file |
35
+ | `chirpie posts` | List posts. `--status`, `--account`, `--group`, `--limit` |
36
36
  | `chirpie posts get <id>` | Fetch one post |
37
- | `chirpie posts update <id>` | Edit a post that has not published yet. `--text`, `--media`, `--alt`, `--clear-media`, `--schedule-at`. Leaving `--schedule-at` out keeps the time it already has |
37
+ | `chirpie posts update <id>` | Edit a post that has not published yet, or finish a draft. `--text`, `--media`, `--alt`, `--clear-media`, `--schedule-at`, `--keep-draft`. Leaving `--schedule-at` out keeps the time it already has |
38
+ | `chirpie posts publish <id>` | Send a saved draft now. `-t` sets the final text first |
38
39
  | `chirpie posts delete <id>` | Delete a post. Deleting any post of a scheduled thread cancels the thread |
39
40
  | `chirpie accounts` | List connected accounts, active and inactive |
40
41
  | `chirpie accounts activate <id>` / `deactivate <id>` | Choose which accounts publish. Deactivating cancels that account's scheduled posts |
@@ -70,6 +71,47 @@ chirpie thread "One" "Two" "Three" -a 550e8400-e29b-41d4-a716-446655440000
70
71
  chirpie posts --status scheduled
71
72
  ```
72
73
 
74
+ ## Drafts
75
+
76
+ `--draft` saves a post or a thread without sending it. Nothing reaches the platform and nothing counts against your quota until you promote it. Anything that would go wrong is printed back, one warning per line.
77
+
78
+ ```bash
79
+ chirpie post "Half an idea" --draft
80
+ chirpie thread "Opening line" --draft # a draft thread may be one post
81
+ chirpie posts --status draft # what is saved
82
+
83
+ # Change only the time it remembers
84
+ chirpie posts update "$POST_ID" --schedule-at 2027-04-02T09:00:00Z --keep-draft
85
+
86
+ # Promote it: queue it, or send it now
87
+ chirpie posts update "$POST_ID" --schedule-at 2027-04-02T09:00:00Z
88
+ chirpie posts publish "$POST_ID"
89
+ ```
90
+
91
+ Promoting runs every rule a normal post runs and takes the quota, so a draft that would be refused stays a draft, unchanged. A draft thread is promoted whole.
92
+
93
+ ## Several accounts at once
94
+
95
+ Repeat `-a` to publish the same post to up to 25 accounts in one call.
96
+
97
+ ```bash
98
+ chirpie post "Shipped a new release." -a "$X_ACCOUNT" -a "$BSKY_ACCOUNT"
99
+
100
+ # Give one account its own text, inline or from a file
101
+ chirpie post "Shipped a new release." -a "$X_ACCOUNT" -a "$BSKY_ACCOUNT" \
102
+ --config "{\"$BSKY_ACCOUNT\":{\"text\":\"Shorter, for Bluesky\"}}"
103
+ chirpie post "Shipped a new release." -a "$X_ACCOUNT" -a "$BSKY_ACCOUNT" --config ./overrides.json
104
+
105
+ # A thread, with a thread of its own for one account
106
+ chirpie thread "One" "Two" -a "$X_ACCOUNT" -a "$BSKY_ACCOUNT" \
107
+ --config "{\"$BSKY_ACCOUNT\":{\"posts\":[{\"text\":\"A\"},{\"text\":\"B\"}]}}"
108
+
109
+ # Read the whole group back
110
+ chirpie posts --group 550e8400-e29b-41d4-a716-446655440000
111
+ ```
112
+
113
+ One line is printed per account, with a group ID above them. An account the platform refused is shown with its error while the others stay published, and the command leaves with a non-zero status when any account failed. An override says only what differs, and naming media replaces the shared media for that account.
114
+
73
115
  ## JSON output
74
116
 
75
117
  Every command accepts `--json` and prints the raw API response, which is what you want in a script:
package/dist/index.js CHANGED
@@ -181,34 +181,150 @@ async function resolveMediaArgs(client, media, alt) {
181
181
  var MEDIA_OPTION_HELP = "Image or video files on this machine, or public URLs. Files are uploaded first. Image caps: X, Bluesky, LinkedIn and Mastodon 4, Threads 1, Instagram, Facebook and Telegram 10. Video: X, Mastodon and Telegram only, one per post, never with images. Anything a platform cannot take is refused rather than dropped.";
182
182
  var ALT_OPTION_HELP = "Describe each item for people using a screen reader, in the same order as --media. Carried to X, Bluesky, LinkedIn, Mastodon, Instagram and Facebook.";
183
183
 
184
- // src/commands/post.ts
185
- var postCommand = new Command3("post").description("Create a post").argument("<text>", "Post text").option("-a, --account <id>", "Account ID").option("-m, --media <files...>", MEDIA_OPTION_HELP).option("--alt <text...>", ALT_OPTION_HELP).option("-s, --schedule <datetime>", "Schedule for later. Any ISO 8601 timestamp with a timezone (2026-04-01T14:00:00Z or 2026-04-01T16:00:00+02:00). Posts publish within ~5 minutes of the scheduled time.").option("--json", "Output raw JSON").action(async (text, opts) => {
186
- const client = getClient();
187
- let accountId = opts.account;
188
- if (!accountId) {
189
- const accounts = await client.listAccounts();
190
- const active = accounts.filter((a) => a.is_active);
191
- if (active.length === 0) {
192
- error("No connected accounts. Run: chirpie accounts connect");
184
+ // src/lib/accounts.ts
185
+ import { readFileSync } from "fs";
186
+ function collect(value, previous = []) {
187
+ return [...previous, value];
188
+ }
189
+ async function resolveSingleAccountId(client) {
190
+ const accounts = await client.listAccounts();
191
+ const active = accounts.filter((a) => a.is_active);
192
+ if (active.length === 0) {
193
+ error("No connected accounts. Run: chirpie accounts connect");
194
+ process.exit(1);
195
+ }
196
+ if (active.length > 1) {
197
+ error("Multiple accounts found. Use --account <id> to specify:");
198
+ active.forEach((a) => console.log(` ${a.id} @${a.username}`));
199
+ process.exit(1);
200
+ }
201
+ return active[0].id;
202
+ }
203
+ function parseAccountConfigurations(value) {
204
+ if (!value) return void 0;
205
+ const trimmed = value.trim();
206
+ let raw;
207
+ if (trimmed.startsWith("{")) {
208
+ raw = trimmed;
209
+ } else {
210
+ try {
211
+ raw = readFileSync(trimmed, "utf8");
212
+ } catch {
213
+ error(`Could not read --config file: ${trimmed}`);
193
214
  process.exit(1);
194
215
  }
195
- if (active.length > 1) {
196
- error("Multiple accounts found. Use --account <id> to specify:");
197
- active.forEach((a) => console.log(` ${a.id} @${a.username}`));
198
- process.exit(1);
216
+ }
217
+ let parsed;
218
+ try {
219
+ parsed = JSON.parse(raw);
220
+ } catch {
221
+ error("--config must be a JSON object, or a path to a file holding one");
222
+ process.exit(1);
223
+ }
224
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
225
+ error("--config must be a JSON object keyed by account ID");
226
+ process.exit(1);
227
+ }
228
+ return parsed;
229
+ }
230
+ function printDraftWarnings(warnings) {
231
+ if (!Array.isArray(warnings)) return;
232
+ for (const warning of warnings) {
233
+ const entry = warning;
234
+ if (typeof entry?.message !== "string") continue;
235
+ info(`${entry.platform} ${entry.account_id}: ${entry.message}`);
236
+ }
237
+ }
238
+ function printFanOut(groupId, lines) {
239
+ const failed = lines.filter((line) => !line.success);
240
+ console.log(`Group ${groupId}`);
241
+ for (const line of lines) {
242
+ if (line.success) {
243
+ const where = line.platform_post_url ? ` ${line.platform_post_url}` : "";
244
+ console.log(
245
+ `\x1B[32m\u2713\x1B[0m ${line.platform} ${line.account_id} ${line.status ?? ""} ${line.id ?? ""}${where}`.trimEnd()
246
+ );
247
+ } else {
248
+ const reason = line.error ? `${line.error.code}: ${line.error.message}` : "Failed";
249
+ console.log(
250
+ `\x1B[31m\u2717\x1B[0m ${line.platform} ${line.account_id} ${reason}`
251
+ );
199
252
  }
200
- accountId = active[0].id;
201
253
  }
254
+ console.log(
255
+ `${lines.length - failed.length} of ${lines.length} accounts succeeded`
256
+ );
257
+ return failed.length;
258
+ }
259
+
260
+ // src/commands/post.ts
261
+ var postCommand = new Command3("post").description("Create a post").argument("<text>", "Post text").option(
262
+ "-a, --account <id>",
263
+ "Account ID. Repeat it to publish the same post to several accounts at once (up to 25)",
264
+ collect
265
+ ).option("-m, --media <files...>", MEDIA_OPTION_HELP).option("--alt <text...>", ALT_OPTION_HELP).option(
266
+ "--config <json-or-file>",
267
+ 'Per-account overrides as JSON, or a path to a JSON file holding them: { "<account id>": { "text": "...", "media_urls": ["..."] } }. Every key must be an account named with --account'
268
+ ).option("-s, --schedule <datetime>", "Schedule for later. Any ISO 8601 timestamp with a timezone (2026-04-01T14:00:00Z or 2026-04-01T16:00:00+02:00). Posts publish within ~5 minutes of the scheduled time.").option(
269
+ "--draft",
270
+ "Save without publishing. Nothing is sent, nothing counts against your quota, and any problems are listed back"
271
+ ).option("--json", "Output raw JSON").action(async (text, opts) => {
272
+ const client = getClient();
273
+ const named = opts.account ?? [];
274
+ if (opts.config && named.length === 0) {
275
+ error("--config names accounts, so pass --account <id> for each of them");
276
+ process.exit(1);
277
+ }
278
+ const accountIds = named.length > 0 ? named : [await resolveSingleAccountId(client)];
279
+ const fanOut = named.length > 1 || Boolean(opts.config);
202
280
  try {
203
281
  const media = await resolveMediaArgs(client, opts.media, opts.alt);
282
+ if (fanOut) {
283
+ const result = await client.createPost({
284
+ account_ids: accountIds,
285
+ text,
286
+ ...media ? { media } : {},
287
+ ...opts.config ? {
288
+ account_configurations: parseAccountConfigurations(
289
+ opts.config
290
+ )
291
+ } : {},
292
+ schedule_at: opts.schedule,
293
+ ...opts.draft ? { draft: true } : {}
294
+ });
295
+ if (opts.json) {
296
+ json(result);
297
+ if (result.results.some((r) => !r.success)) process.exit(1);
298
+ return;
299
+ }
300
+ if ("warnings" in result) printDraftWarnings(result.warnings);
301
+ const failed = printFanOut(
302
+ result.group_id,
303
+ result.results.map((r) => ({
304
+ account_id: r.account_id,
305
+ platform: r.platform,
306
+ success: r.success,
307
+ id: r.post_id,
308
+ status: r.status,
309
+ platform_post_url: r.platform_post_url,
310
+ error: r.error
311
+ }))
312
+ );
313
+ if (failed > 0) process.exit(1);
314
+ return;
315
+ }
204
316
  const post = await client.createPost({
205
- account_id: accountId,
317
+ account_id: accountIds[0],
206
318
  text,
207
319
  ...media ? { media } : {},
208
- schedule_at: opts.schedule
320
+ schedule_at: opts.schedule,
321
+ ...opts.draft ? { draft: true } : {}
209
322
  });
210
323
  if (opts.json) {
211
324
  json(post);
325
+ } else if (post.status === "draft") {
326
+ success(`Draft saved (${post.id})`);
327
+ if ("warnings" in post) printDraftWarnings(post.warnings);
212
328
  } else if (post.status === "scheduled") {
213
329
  success(`Post scheduled for ${post.scheduled_at} (${post.id})`);
214
330
  } else {
@@ -222,7 +338,7 @@ var postCommand = new Command3("post").description("Create a post").argument("<t
222
338
 
223
339
  // src/commands/thread.ts
224
340
  import { Command as Command4 } from "commander";
225
- import { readFileSync } from "fs";
341
+ import { readFileSync as readFileSync2 } from "fs";
226
342
  function parseMediaSpec(spec) {
227
343
  const m = spec.match(/^(\d+):(.+)$/);
228
344
  if (!m) return null;
@@ -238,7 +354,11 @@ function parseAltSpec(spec) {
238
354
  alts: m[2].split(",").map((a) => a.trim())
239
355
  };
240
356
  }
241
- var threadCommand = new Command4("thread").description("Create a thread").argument("[posts...]", "Thread posts (each argument is one post). Omit when using --file.").option("-a, --account <id>", "Account ID").option(
357
+ var threadCommand = new Command4("thread").description("Create a thread").argument("[posts...]", "Thread posts (each argument is one post). Omit when using --file.").option(
358
+ "-a, --account <id>",
359
+ "Account ID. Repeat it to publish the same thread to several accounts at once (up to 25)",
360
+ collect
361
+ ).option(
242
362
  "-m, --media <specs...>",
243
363
  'Per-post media as "INDEX:file1,file2" (repeatable). Files on this machine are uploaded first; public URLs are used as they are. Example: -m 0:./shot.png 2:https://b.png,https://c.png'
244
364
  ).option(
@@ -246,39 +366,39 @@ var threadCommand = new Command4("thread").description("Create a thread").argume
246
366
  'Per-post alt text as "INDEX:first,second", lining up with --media. Carried to X, Bluesky, LinkedIn, Mastodon, Instagram and Facebook.'
247
367
  ).option(
248
368
  "-f, --file <path>",
249
- "JSON file with a full thread payload: { posts: [{ text, media_urls? }, ...], schedule_at?: string }"
250
- ).option("-s, --schedule <datetime>", "Schedule for later. Any ISO 8601 timestamp with a timezone (2026-04-01T14:00:00Z or 2026-04-01T16:00:00+02:00). Threads publish within ~5 minutes of the scheduled time.").option("--json", "Output raw JSON").action(async (posts, opts) => {
369
+ "JSON file with a full thread payload: { posts: [{ text, media_urls? }, ...], schedule_at?: string }. It may also carry account_ids and account_configurations."
370
+ ).option(
371
+ "--config <json-or-file>",
372
+ `Per-account overrides as JSON, or a path to a JSON file holding them: { "<account id>": { "posts": [{ "text": "one" }, { "text": "two" }] } }. An account's posts replace the whole thread for that account`
373
+ ).option("-s, --schedule <datetime>", "Schedule for later. Any ISO 8601 timestamp with a timezone (2026-04-01T14:00:00Z or 2026-04-01T16:00:00+02:00). Threads publish within ~5 minutes of the scheduled time.").option(
374
+ "--draft",
375
+ "Save without publishing. Nothing is sent, nothing counts against your quota, and any problems are listed back"
376
+ ).option("--json", "Output raw JSON").action(async (posts, opts) => {
251
377
  const client = getClient();
252
- let accountId = opts.account;
253
- if (!accountId) {
254
- const accounts = await client.listAccounts();
255
- const active = accounts.filter((a) => a.is_active);
256
- if (active.length === 0) {
257
- error("No connected accounts. Run: chirpie accounts connect");
258
- process.exit(1);
259
- }
260
- if (active.length > 1) {
261
- error("Multiple accounts found. Use --account <id> to specify:");
262
- active.forEach((a) => console.log(` ${a.id} @${a.username}`));
263
- process.exit(1);
264
- }
265
- accountId = active[0].id;
266
- }
267
378
  let postPayload;
268
379
  let scheduleAt = opts.schedule;
380
+ let fileAccountIds;
381
+ let fileConfigurations;
269
382
  if (opts.file) {
270
383
  try {
271
- const raw = JSON.parse(readFileSync(opts.file, "utf8"));
384
+ const raw = JSON.parse(readFileSync2(opts.file, "utf8"));
272
385
  if (!Array.isArray(raw.posts)) throw new Error("File must contain { posts: [...] }");
273
386
  postPayload = raw.posts;
274
387
  scheduleAt = scheduleAt ?? raw.schedule_at;
388
+ if (raw.account_ids !== void 0 && (!Array.isArray(raw.account_ids) || raw.account_ids.some((id) => typeof id !== "string"))) {
389
+ throw new Error("account_ids in the file must be an array of account IDs");
390
+ }
391
+ fileAccountIds = raw.account_ids;
392
+ fileConfigurations = raw.account_configurations;
275
393
  } catch (err) {
276
394
  error(err instanceof Error ? err.message : "Failed to read thread file");
277
395
  process.exit(1);
278
396
  }
279
397
  } else {
280
- if (posts.length < 2) {
281
- error("Thread must have at least 2 posts (or use --file)");
398
+ if (posts.length < (opts.draft ? 1 : 2)) {
399
+ error(
400
+ opts.draft ? "Pass at least one post (or use --file)" : "Thread must have at least 2 posts (or use --file)"
401
+ );
282
402
  process.exit(1);
283
403
  }
284
404
  postPayload = posts.map((text) => ({ text }));
@@ -314,14 +434,55 @@ var threadCommand = new Command4("thread").description("Create a thread").argume
314
434
  }
315
435
  }
316
436
  }
437
+ const configurations = parseAccountConfigurations(opts.config) ?? fileConfigurations;
438
+ const named = opts.account ?? fileAccountIds ?? [];
439
+ if (configurations && named.length === 0) {
440
+ error("Per-account posts name accounts, so pass --account <id> for each of them");
441
+ process.exit(1);
442
+ }
443
+ const accountIds = named.length > 0 ? named : [await resolveSingleAccountId(client)];
444
+ const fanOut = named.length > 1 || Boolean(configurations);
317
445
  try {
446
+ if (fanOut) {
447
+ const result = await client.createThread({
448
+ account_ids: accountIds,
449
+ posts: postPayload,
450
+ ...configurations ? { account_configurations: configurations } : {},
451
+ schedule_at: scheduleAt,
452
+ ...opts.draft ? { draft: true } : {}
453
+ });
454
+ if (opts.json) {
455
+ json(result);
456
+ if (result.results.some((r) => !r.success)) process.exit(1);
457
+ return;
458
+ }
459
+ if ("warnings" in result) printDraftWarnings(result.warnings);
460
+ const failed = printFanOut(
461
+ result.group_id,
462
+ result.results.map((r) => ({
463
+ account_id: r.account_id,
464
+ platform: r.platform,
465
+ success: r.success,
466
+ id: r.thread_id,
467
+ status: r.status,
468
+ platform_post_url: r.platform_post_url,
469
+ error: r.error
470
+ }))
471
+ );
472
+ if (failed > 0) process.exit(1);
473
+ return;
474
+ }
318
475
  const thread = await client.createThread({
319
- account_id: accountId,
476
+ account_id: accountIds[0],
320
477
  posts: postPayload,
321
- schedule_at: scheduleAt
478
+ schedule_at: scheduleAt,
479
+ ...opts.draft ? { draft: true } : {}
322
480
  });
323
481
  if (opts.json) {
324
482
  json(thread);
483
+ } else if (thread.status === "draft") {
484
+ success(`Draft thread saved with ${thread.posts.length} posts (${thread.id})`);
485
+ if ("warnings" in thread) printDraftWarnings(thread.warnings);
325
486
  } else {
326
487
  success(`Thread created with ${thread.posts.length} posts (${thread.id})`);
327
488
  }
@@ -342,12 +503,19 @@ function wantsJson(opts, cmd) {
342
503
  }
343
504
 
344
505
  // src/commands/posts.ts
345
- var postsCommand = new Command5("posts").description("List, get, update, or delete posts").option("--status <status>", "Filter by status").option("--account <id>", "Filter by account").option("--limit <n>", "Limit results", "20").option("--json", "Output raw JSON").action(async (opts, cmd) => {
506
+ var postsCommand = new Command5("posts").description("List, get, update, or delete posts").option(
507
+ "--status <status>",
508
+ "Filter by status: draft, scheduled, publishing, published, failed, deleted"
509
+ ).option("--account <id>", "Filter by account").option(
510
+ "--group <id>",
511
+ "Only the posts of one fan-out, from the group ID a multi-account post returned"
512
+ ).option("--limit <n>", "Limit results", "20").option("--json", "Output raw JSON").action(async (opts, cmd) => {
346
513
  const client = getClient();
347
514
  try {
348
515
  const posts = await client.listPosts({
349
516
  status: opts.status,
350
517
  account_id: opts.account,
518
+ group_id: opts.group,
351
519
  limit: parseInt(opts.limit)
352
520
  });
353
521
  if (wantsJson(opts, cmd)) {
@@ -395,20 +563,25 @@ postsCommand.command("get <id>").description("Get a post by ID").option("--json"
395
563
  }
396
564
  });
397
565
  postsCommand.command("update <id>").description(
398
- "Edit a post that has not published yet. Leaving --schedule-at out keeps the time it already has"
566
+ "Edit a post that has not published yet, or finish a draft. Leaving --schedule-at out keeps the time a queued post already has"
399
567
  ).option("-t, --text <text>", "Replacement post text").option(
400
568
  "-m, --media <files...>",
401
569
  "Replacement image or video files on this machine, or public URLs, following the same per-platform rules as creating a post. Pass --clear-media to remove the media instead."
402
570
  ).option("--alt <text...>", ALT_OPTION_HELP).option("--clear-media", "Remove the media from the post").option(
403
571
  "-s, --schedule-at <datetime>",
404
- "New publish time. Any ISO 8601 timestamp with a timezone (2027-04-01T14:00:00Z or 2027-04-01T16:00:00+02:00). Omit it to keep the time the post already has. Rescheduling one part of a thread moves the whole thread."
572
+ "New publish time. Any ISO 8601 timestamp with a timezone (2027-04-01T14:00:00Z or 2027-04-01T16:00:00+02:00). Omit it to keep the time the post already has. Rescheduling one part of a thread moves the whole thread. On a draft this queues it, unless you also pass --keep-draft."
573
+ ).option(
574
+ "--keep-draft",
575
+ "Keep a draft a draft, so --schedule-at only changes the time it remembers"
405
576
  ).option("--json", "Output raw JSON").action(async (id, opts, cmd) => {
406
577
  if (opts.media && opts.clearMedia) {
407
578
  error("Pass either --media or --clear-media, not both");
408
579
  process.exit(1);
409
580
  }
410
- if (!opts.text && !opts.media && !opts.clearMedia && !opts.scheduleAt) {
411
- error("Nothing to change. Pass --text, --media, --clear-media or --schedule-at");
581
+ if (!opts.text && !opts.media && !opts.clearMedia && !opts.scheduleAt && !opts.keepDraft) {
582
+ error(
583
+ "Nothing to change. Pass --text, --media, --clear-media, --schedule-at or --keep-draft"
584
+ );
412
585
  process.exit(1);
413
586
  }
414
587
  const client = getClient();
@@ -418,24 +591,44 @@ postsCommand.command("update <id>").description(
418
591
  ...opts.text !== void 0 ? { text: opts.text } : {},
419
592
  ...opts.clearMedia ? { media: [] } : {},
420
593
  ...media ? { media } : {},
421
- ...opts.scheduleAt ? { schedule_at: opts.scheduleAt } : {}
594
+ ...opts.scheduleAt ? { schedule_at: opts.scheduleAt } : {},
595
+ ...opts.keepDraft ? { draft: true } : {}
422
596
  });
423
597
  if (wantsJson(opts, cmd)) {
424
598
  json(post);
425
599
  } else {
426
600
  const moved = post.rescheduled_post_ids ?? [];
427
- success(`Post ${post.id} updated`);
601
+ const promoted = post.promoted_from_draft_id ?? post.promoted_from_draft_ids?.[0];
602
+ success(promoted ? `Draft ${promoted} is now ${post.status}` : `Post ${post.id} updated`);
428
603
  if (opts.scheduleAt) {
429
604
  success(
430
605
  moved.length > 1 ? `Thread rescheduled for ${post.scheduled_at} (${moved.length} posts)` : `Scheduled for ${post.scheduled_at}`
431
606
  );
432
607
  }
608
+ if (post.warnings?.length) printDraftWarnings(post.warnings);
433
609
  }
434
610
  } catch (err) {
435
611
  apiError(err, "Failed to update post");
436
612
  process.exit(1);
437
613
  }
438
614
  });
615
+ postsCommand.command("publish <id>").description("Publish a draft now. Every rule and the monthly quota apply").option("-t, --text <text>", "Final post text, saved before publishing").option("--json", "Output raw JSON").action(async (id, opts, cmd) => {
616
+ const client = getClient();
617
+ try {
618
+ const post = await client.updatePost(id, {
619
+ publish: true,
620
+ ...opts.text !== void 0 ? { text: opts.text } : {}
621
+ });
622
+ if (wantsJson(opts, cmd)) {
623
+ json(post);
624
+ } else {
625
+ success(`Draft ${id} published (${post.id})`);
626
+ }
627
+ } catch (err) {
628
+ apiError(err, "Failed to publish draft");
629
+ process.exit(1);
630
+ }
631
+ });
439
632
  postsCommand.command("delete <id>").description(
440
633
  "Delete a post (deleting any post of a scheduled thread cancels the thread)"
441
634
  ).option("--json", "Output raw JSON").action(async (id, opts, cmd) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chirpie",
3
- "version": "1.0.44",
3
+ "version": "1.0.46",
4
4
  "description": "Chirpie CLI: post to social media from the command line",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,7 +44,7 @@
44
44
  ],
45
45
  "license": "MIT",
46
46
  "dependencies": {
47
- "@chirpie/sdk": "^1.0.41",
47
+ "@chirpie/sdk": "^1.0.43",
48
48
  "commander": "^13"
49
49
  },
50
50
  "devDependencies": {