deploylog 0.2.2 → 0.4.0

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.
package/dist/index.js CHANGED
@@ -1,15 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
- import { setApiKey, setApiUrl, getConfigPath, clearConfig } from './config.js';
5
- import { listProjects, listEntries, createEntry, summarize, ApiError, } from './api.js';
4
+ import { setApiKey, setApiUrl, getApiUrl, getConfigPath, clearConfig } from './config.js';
5
+ import { listProjects, listEntries, createProject, importGithub, whoami, ApiError, } from './api.js';
6
6
  import { readProjectConfig } from './project-config.js';
7
- import { isGitRepo, getLastTag, getHeadVersion, getCommitsSince, defaultTitleFromGit, formatCommitsAsMarkdown, } from './git.js';
7
+ import { runPush, defaultPushDeps } from './push.js';
8
+ import { runSetPublished, runView, runEdit, runDelete, defaultEntryCommandDeps, } from './entry-commands.js';
9
+ import { runInit, defaultInitDeps } from './init.js';
10
+ import { runOpen } from './open.js';
8
11
  const program = new Command();
9
12
  program
10
13
  .name('deploylog')
11
14
  .description('Push changelog entries from the terminal')
12
- .version('0.2.0');
15
+ // Without positional options, the global -V/--version greedily matches
16
+ // `push --version 1.4.0` and prints the CLI version instead of setting the
17
+ // entry's semver. Program options now must precede the subcommand.
18
+ .enablePositionalOptions()
19
+ .version('0.4.0');
13
20
  // ─── login ──────────────────────────────────────────────────────────────────
14
21
  program
15
22
  .command('login')
@@ -69,12 +76,18 @@ program
69
76
  console.log(chalk.green('Logged out. Credentials removed.'));
70
77
  });
71
78
  // ─── projects ───────────────────────────────────────────────────────────────
72
- program
79
+ const projectsCmd = program
73
80
  .command('projects')
81
+ .alias('proj')
74
82
  .description('List projects in your organization')
75
- .action(async () => {
83
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
84
+ .action(async (opts) => {
76
85
  try {
77
86
  const projects = await listProjects();
87
+ if (opts.json) {
88
+ printJson(projects);
89
+ return;
90
+ }
78
91
  if (projects.length === 0) {
79
92
  console.log(chalk.dim('No projects found.'));
80
93
  return;
@@ -85,18 +98,92 @@ program
85
98
  }
86
99
  }
87
100
  catch (err) {
88
- handleError(err);
101
+ handleError(err, opts.json);
102
+ }
103
+ });
104
+ projectsCmd
105
+ .command('create <name>')
106
+ .description('Create a new project (slug is generated from the name)')
107
+ .option('--url <url>', 'Project website URL')
108
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
109
+ .action(async (name, opts, command) => {
110
+ // The parent `projects` command also defines --json and commander hands
111
+ // the flag to whichever parsed it — honor both.
112
+ const json = Boolean(opts.json || command.optsWithGlobals().json);
113
+ try {
114
+ const project = await createProject(name, opts.url);
115
+ if (json) {
116
+ printJson(project);
117
+ return;
118
+ }
119
+ console.log(`${chalk.green('✓')} Project created: ${chalk.bold(project.name)}`);
120
+ console.log(` Slug: ${chalk.cyan(project.slug)}`);
121
+ console.log(chalk.dim(` Public changelog: ${getApiUrl()}/p/${project.slug}/changelog`));
122
+ console.log(chalk.dim(' Run `deploylog init` in the repo to wire pushes to it.'));
123
+ }
124
+ catch (err) {
125
+ handleError(err, json);
126
+ }
127
+ });
128
+ // ─── whoami ─────────────────────────────────────────────────────────────────
129
+ program
130
+ .command('whoami')
131
+ .description('Show the authenticated org, plan, API key, and AI usage')
132
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
133
+ .action(async (opts) => {
134
+ try {
135
+ const me = await whoami();
136
+ if (opts.json) {
137
+ printJson({ ...me, api_url: getApiUrl(), config_path: getConfigPath() });
138
+ return;
139
+ }
140
+ const limitLabel = me.ai_usage.limit === null ? '∞' : String(me.ai_usage.limit);
141
+ console.log(`${chalk.bold(me.organization.name)} ${chalk.dim(me.organization.slug)}`);
142
+ console.log(` Plan: ${chalk.cyan(me.organization.plan ?? 'free')}`);
143
+ console.log(` API key: ${me.api_key.name} ${chalk.dim(`${me.api_key.prefix}… [${me.api_key.permissions.join(', ')}]`)}`);
144
+ if (me.api_key.last_used_at) {
145
+ console.log(chalk.dim(` Last used: ${me.api_key.last_used_at}`));
146
+ }
147
+ console.log(` AI usage: ${me.ai_usage.used}/${limitLabel} this month ${chalk.dim(`(${me.ai_usage.month_key})`)}`);
148
+ console.log(chalk.dim(` API URL: ${getApiUrl()}`));
149
+ console.log(chalk.dim(` Config: ${getConfigPath()}`));
150
+ }
151
+ catch (err) {
152
+ handleError(err, opts.json);
89
153
  }
90
154
  });
91
155
  // ─── list ───────────────────────────────────────────────────────────────────
92
156
  program
93
157
  .command('list')
158
+ .alias('ls')
94
159
  .description('List recent entries for a project')
95
160
  .option('-p, --project <slug>', 'Project slug (or set in .deploylog.yml)')
161
+ .option('--drafts', 'Only drafts')
162
+ .option('--published', 'Only published entries')
163
+ .option('-T, --type <type>', 'Filter by entry type')
164
+ .option('-n, --limit <n>', 'Max entries to show (1-50)', (v) => parseInt(v, 10))
165
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
96
166
  .action(async (opts) => {
97
167
  try {
168
+ if (opts.drafts && opts.published) {
169
+ console.error(chalk.red('Use at most one of --drafts / --published.'));
170
+ process.exit(1);
171
+ }
98
172
  const slug = resolveProject(opts.project);
99
- const entries = await listEntries(slug);
173
+ const filters = {};
174
+ if (opts.drafts)
175
+ filters.status = 'draft';
176
+ if (opts.published)
177
+ filters.status = 'published';
178
+ if (opts.type)
179
+ filters.type = opts.type;
180
+ if (opts.limit !== undefined)
181
+ filters.limit = opts.limit;
182
+ const entries = await listEntries(slug, filters);
183
+ if (opts.json) {
184
+ printJson(entries);
185
+ return;
186
+ }
100
187
  if (entries.length === 0) {
101
188
  console.log(chalk.dim('No entries found.'));
102
189
  return;
@@ -113,10 +200,57 @@ program
113
200
  day: 'numeric',
114
201
  });
115
202
  console.log(` ${status} ${e.title} ${type} ${version} ${chalk.dim(date)}`);
203
+ console.log(` ${chalk.dim(`${e.slug} ${e.id}`)}`);
204
+ }
205
+ const effectiveLimit = filters.limit ?? 50;
206
+ if (entries.length >= effectiveLimit) {
207
+ console.log(chalk.dim(`\nShowing the ${entries.length} most recent. Older entries: narrow with --drafts / --published / --type, or reference by id.`));
208
+ }
209
+ }
210
+ catch (err) {
211
+ handleError(err, opts.json);
212
+ }
213
+ });
214
+ // ─── view ───────────────────────────────────────────────────────────────────
215
+ program
216
+ .command('view <entry>')
217
+ .alias('show')
218
+ .description('Show a full entry (slug or id), including its markdown body')
219
+ .option('-p, --project <slug>', 'Project slug (or set in .deploylog.yml)')
220
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
221
+ .action(async (ref, opts) => {
222
+ try {
223
+ const result = await runView({ ref, project: opts.project });
224
+ if (result.kind !== 'found') {
225
+ if (opts.json) {
226
+ printJsonError('NOT_FOUND', result.message);
227
+ }
228
+ else {
229
+ console.error(chalk.red(result.message));
230
+ }
231
+ process.exit(1);
116
232
  }
233
+ const e = result.entry;
234
+ if (opts.json) {
235
+ printJson(e);
236
+ return;
237
+ }
238
+ const status = e.published ? chalk.green('published') : chalk.yellow('draft');
239
+ console.log(`${chalk.bold(e.title)} ${status}`);
240
+ console.log(chalk.dim(` id: ${e.id}`));
241
+ console.log(chalk.dim(` slug: ${e.slug}`));
242
+ if (e.entry_type)
243
+ console.log(chalk.dim(` type: ${e.entry_type}`));
244
+ if (e.version)
245
+ console.log(chalk.dim(` version: v${e.version}`));
246
+ console.log(chalk.dim(` created: ${e.created_at}`));
247
+ if (e.published_at)
248
+ console.log(chalk.dim(` published: ${e.published_at}`));
249
+ console.log();
250
+ console.log(e.body_markdown);
117
251
  }
118
252
  catch (err) {
119
- handleError(err);
253
+ handleError(err, opts.json);
120
254
  }
121
255
  });
122
256
  // ─── push ───────────────────────────────────────────────────────────────────
@@ -126,125 +260,350 @@ program
126
260
  .option('-t, --title <title>', 'Entry title (required unless --from-git or --ai-summarize)')
127
261
  .option('-b, --body <markdown>', 'Entry body (Markdown)')
128
262
  .option('-p, --project <slug>', 'Project slug (or set in .deploylog.yml)')
129
- .option('--type <type>', 'Entry type: feature, fix, improvement, breaking, announcement')
263
+ .option('-T, --type <type>', 'Entry type: feature, fix, improvement, breaking, announcement')
130
264
  .option('--version <version>', 'Semver version (e.g. 1.2.3)')
131
- .option('--publish', 'Publish immediately (default: draft)')
132
- .option('--draft', 'Save as draft (default)')
133
- .option('--from-git', 'Derive title/body from commits since the last tag')
134
- .option('--ai-summarize', 'Rewrite the entry with Claude Haiku (user-friendly release notes)')
265
+ .option('-P, --publish', 'Publish immediately (default: draft)')
266
+ .option('-D, --draft', 'Save as draft (default)')
267
+ .option('-g, --from-git', 'Derive title/body from commits since the last tag')
268
+ .option('--git', 'Alias of --from-git')
269
+ .option('-a, --ai-summarize', 'Rewrite the entry with Claude Haiku (user-friendly release notes)')
270
+ .option('--ai', 'Alias of --ai-summarize')
135
271
  .option('-y, --yes', 'Skip interactive confirmation for AI-generated content')
272
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
136
273
  .action(async (opts) => {
137
274
  try {
138
- const slug = resolveProject(opts.project);
139
- const projectConfig = readProjectConfig();
140
- // Gather source material (commits + version) if --from-git.
141
- let commits = [];
142
- let gitVersion = null;
143
- let gitTitle = null;
144
- let gitBody = null;
145
- if (opts.fromGit) {
146
- if (!isGitRepo()) {
147
- console.error(chalk.red('Not in a git repository. Remove --from-git or cd to a repo.'));
148
- process.exit(1);
275
+ // JSON mode is a machine contract: no prompts (non-TTY path), progress
276
+ // and notices to stderr, stdout reserved for the data payload.
277
+ const deps = opts.json
278
+ ? {
279
+ ...defaultPushDeps,
280
+ isTTY: false,
281
+ out: {
282
+ progressStart: (msg) => process.stderr.write(`${msg} `),
283
+ progressDone: (msg) => process.stderr.write(`${msg}\n`),
284
+ notice: (msg) => console.error(msg),
285
+ aiPreview: () => { },
286
+ },
149
287
  }
150
- const lastTag = getLastTag();
151
- commits = getCommitsSince(lastTag);
152
- gitVersion = getHeadVersion();
153
- gitTitle = defaultTitleFromGit(gitVersion, lastTag);
154
- gitBody = formatCommitsAsMarkdown(commits);
155
- if (commits.length === 0 && !opts.body && !opts.aiSummarize) {
156
- console.error(chalk.yellow(lastTag
157
- ? `No commits since tag ${lastTag}. Nothing to summarize.`
158
- : 'No commits found on HEAD.'));
288
+ : defaultPushDeps;
289
+ const result = await runPush(opts, deps);
290
+ switch (result.kind) {
291
+ case 'created': {
292
+ const entry = result.entry;
293
+ if (opts.json) {
294
+ printJson(entry);
295
+ break;
296
+ }
297
+ const status = entry.published ? chalk.green('Published') : chalk.yellow('Draft');
298
+ console.log(`\n${chalk.green('✓')} Entry created: ${chalk.bold(entry.title)}`);
299
+ console.log(` Status: ${status}`);
300
+ console.log(` Slug: ${chalk.dim(entry.slug)}`);
301
+ if (entry.version)
302
+ console.log(` Version: ${chalk.dim(`v${entry.version}`)}`);
303
+ break;
304
+ }
305
+ case 'cancelled':
306
+ // User declined at the confirm prompt — a clean, non-error stop.
307
+ console.log(chalk.yellow(result.message));
308
+ break;
309
+ case 'no-commits':
310
+ // Valid command, nothing to do — warn (yellow) and exit non-zero.
311
+ if (opts.json)
312
+ printJsonError('NO_COMMITS', result.message);
313
+ else
314
+ console.error(chalk.yellow(result.message));
315
+ process.exit(1);
316
+ default:
317
+ // no-ai-source | missing-fields — a misuse refusal.
318
+ if (opts.json)
319
+ printJsonError(result.kind.toUpperCase().replace(/-/g, '_'), result.message);
320
+ else
321
+ console.error(chalk.red(result.message));
159
322
  process.exit(1);
323
+ }
324
+ }
325
+ catch (err) {
326
+ handleError(err, opts.json);
327
+ }
328
+ });
329
+ // ─── edit ───────────────────────────────────────────────────────────────────
330
+ program
331
+ .command('edit <entry>')
332
+ .description('Edit an entry (slug or id) via flags, --body-file, or $EDITOR')
333
+ .option('-p, --project <slug>', 'Project slug (or set in .deploylog.yml)')
334
+ .option('-t, --title <title>', "New title (may change a draft's slug)")
335
+ .option('-T, --type <type>', 'Entry type: feature, fix, improvement, breaking, announcement')
336
+ .option('--version <version>', 'Semver version (pass "" to clear)')
337
+ .option('-b, --body <markdown>', 'New body (Markdown)')
338
+ .option('--body-file <path>', 'Read the new body from a file (- for stdin)')
339
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
340
+ .action(async (ref, opts) => {
341
+ try {
342
+ // JSON mode never opens $EDITOR — force the non-interactive path.
343
+ const deps = opts.json ? { ...defaultEntryCommandDeps, isTTY: false } : undefined;
344
+ const result = await runEdit({ ref, ...opts }, deps);
345
+ switch (result.kind) {
346
+ case 'updated': {
347
+ const e = result.entry;
348
+ if (opts.json) {
349
+ printJson({ ...e, previous_slug: result.previousSlug });
350
+ break;
351
+ }
352
+ console.log(`${chalk.green('✓')} Updated: ${chalk.bold(e.title)}`);
353
+ if (e.slug !== result.previousSlug) {
354
+ console.log(chalk.yellow(` Slug changed from '${result.previousSlug}' to '${e.slug}'.`));
355
+ console.log(chalk.dim(' Scripts referencing the old slug should switch to the id.'));
356
+ }
357
+ else {
358
+ console.log(` Slug: ${chalk.dim(e.slug)}`);
359
+ }
360
+ break;
160
361
  }
362
+ case 'unchanged':
363
+ case 'cancelled':
364
+ if (opts.json)
365
+ printJsonError(result.kind.toUpperCase(), result.message);
366
+ else
367
+ console.log(chalk.yellow(result.message));
368
+ break;
369
+ case 'body-rejected':
370
+ if (opts.json)
371
+ printJsonError('VALIDATION_ERROR', result.message);
372
+ else
373
+ console.error(chalk.red(result.message));
374
+ process.exit(1);
375
+ default:
376
+ if (opts.json)
377
+ printJsonError(result.kind.toUpperCase().replace(/-/g, '_'), result.message);
378
+ else
379
+ console.error(chalk.red(result.message));
380
+ process.exit(1);
381
+ }
382
+ }
383
+ catch (err) {
384
+ handleError(err, opts.json);
385
+ }
386
+ });
387
+ // ─── import ─────────────────────────────────────────────────────────────────
388
+ const importCmd = program
389
+ .command('import')
390
+ .description('Import entries from an external source');
391
+ importCmd
392
+ .command('github <repo>')
393
+ .description('Import GitHub releases as draft entries (owner/repo or a github.com URL)')
394
+ .option('-p, --project <slug>', 'Project slug (or set in .deploylog.yml)')
395
+ .option('--token <token>', 'GitHub personal access token for private repos / rate limits (or set DEPLOYLOG_GITHUB_TOKEN); never stored')
396
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
397
+ .action(async (repo, opts) => {
398
+ try {
399
+ const slug = resolveProject(opts.project);
400
+ const token = opts.token ?? process.env.DEPLOYLOG_GITHUB_TOKEN;
401
+ const result = await importGithub(slug, repo, token);
402
+ if (opts.json) {
403
+ printJson(result);
404
+ return;
161
405
  }
162
- // Build the entry (with optional AI rewrite).
163
- let title = opts.title ?? gitTitle ?? '';
164
- let body = opts.body ?? gitBody ?? '';
165
- let entryType = opts.type ?? projectConfig?.default_type ?? null;
166
- const version = opts.version ?? gitVersion ?? undefined;
167
- if (opts.aiSummarize) {
168
- const hasSource = commits.length > 0 || (opts.body && opts.body.trim().length > 0);
169
- if (!hasSource) {
170
- console.error(chalk.red('--ai-summarize needs source material. Pass --from-git or provide --body as raw notes.'));
406
+ if (result.imported === 0 && result.skipped === 0) {
407
+ console.log(chalk.yellow('No releases found to import.'));
408
+ return;
409
+ }
410
+ console.log(`${chalk.green('✓')} Imported ${chalk.bold(String(result.imported))} release${result.imported === 1 ? '' : 's'} as drafts` +
411
+ (result.skipped > 0 ? chalk.dim(` (${result.skipped} already imported, skipped)`) : ''));
412
+ if (result.imported > 0) {
413
+ console.log(chalk.dim(' Review with `deploylog list --drafts`, then `deploylog publish <slug>`.'));
414
+ }
415
+ }
416
+ catch (err) {
417
+ handleError(err, opts.json);
418
+ }
419
+ });
420
+ // ─── init ───────────────────────────────────────────────────────────────────
421
+ program
422
+ .command('init')
423
+ .description('Scaffold a .deploylog.yml in this directory (project + optional default type)')
424
+ .option('-p, --project <slug>', 'Project slug (interactive pick if omitted)')
425
+ .option('-T, --type <type>', 'Default entry type for pushes from this repo')
426
+ .option('--force', 'Overwrite an existing .deploylog.yml')
427
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
428
+ .action(async (opts) => {
429
+ try {
430
+ const deps = opts.json ? { ...defaultInitDeps, isTTY: false } : undefined;
431
+ const result = await runInit(opts, deps);
432
+ switch (result.kind) {
433
+ case 'written':
434
+ if (opts.json) {
435
+ printJson({ path: result.path, project: result.project, default_type: result.defaultType ?? null });
436
+ break;
437
+ }
438
+ console.log(`${chalk.green('✓')} Wrote ${result.path}`);
439
+ console.log(` project: ${chalk.cyan(result.project)}`);
440
+ if (result.defaultType)
441
+ console.log(` default_type: ${chalk.cyan(result.defaultType)}`);
442
+ console.log(chalk.dim(' `deploylog push` in this directory now targets that project.'));
443
+ break;
444
+ case 'cancelled':
445
+ console.log(chalk.yellow(result.message));
446
+ break;
447
+ default:
448
+ if (opts.json)
449
+ printJsonError(result.kind.toUpperCase().replace(/-/g, '_'), result.message);
450
+ else
451
+ console.error(chalk.red(result.message));
171
452
  process.exit(1);
172
- }
173
- process.stdout.write(chalk.dim('Summarizing with Claude Haiku... '));
174
- const res = await summarize({
175
- project_slug: slug,
176
- commits: commits.map((c) => c.subject),
177
- release_notes: opts.body,
178
- version,
179
- });
180
- process.stdout.write(chalk.green('done\n'));
181
- title = opts.title ?? res.summary.title;
182
- body = res.summary.body_markdown;
183
- entryType = opts.type ?? res.summary.entry_type;
184
- printAiPreview(res.summary, res.usage);
185
- if (!opts.yes && process.stdin.isTTY) {
186
- const ok = await confirm('Publish this entry?');
187
- if (!ok) {
188
- console.log(chalk.yellow('Cancelled. No entry was created.'));
189
- return;
453
+ }
454
+ }
455
+ catch (err) {
456
+ handleError(err, opts.json);
457
+ }
458
+ });
459
+ // ─── delete ─────────────────────────────────────────────────────────────────
460
+ program
461
+ .command('delete <entry>')
462
+ .alias('rm')
463
+ .description('Delete an entry permanently (slug or id)')
464
+ .option('-p, --project <slug>', 'Project slug (or set in .deploylog.yml)')
465
+ .option('-y, --yes', 'Skip the confirmation prompt (required in non-interactive mode)')
466
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
467
+ .action(async (ref, opts) => {
468
+ try {
469
+ // JSON mode never prompts — deletion then requires an explicit --yes.
470
+ const deps = opts.json ? { ...defaultEntryCommandDeps, isTTY: false } : undefined;
471
+ const result = await runDelete({ ref, project: opts.project, yes: opts.yes }, deps);
472
+ switch (result.kind) {
473
+ case 'deleted':
474
+ if (opts.json) {
475
+ printJson({ id: result.id, title: result.title, deleted: true });
476
+ break;
190
477
  }
478
+ console.log(`${chalk.green('✓')} Deleted: ${chalk.bold(result.title)}`);
479
+ break;
480
+ case 'cancelled':
481
+ console.log(chalk.yellow(result.message));
482
+ break;
483
+ default:
484
+ if (opts.json)
485
+ printJsonError(result.kind.toUpperCase().replace(/-/g, '_'), result.message);
486
+ else
487
+ console.error(chalk.red(result.message));
488
+ process.exit(1);
489
+ }
490
+ }
491
+ catch (err) {
492
+ handleError(err, opts.json);
493
+ }
494
+ });
495
+ // ─── publish / unpublish ────────────────────────────────────────────────────
496
+ program
497
+ .command('publish <entry>')
498
+ .alias('pub')
499
+ .description('Publish a draft entry (slug or id); sends the email digest on Pro')
500
+ .option('-p, --project <slug>', 'Project slug (or set in .deploylog.yml)')
501
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
502
+ .action(async (ref, opts) => {
503
+ try {
504
+ const result = await runSetPublished({ ref, project: opts.project, publish: true });
505
+ switch (result.kind) {
506
+ case 'updated': {
507
+ const e = result.entry;
508
+ if (opts.json) {
509
+ printJson(e);
510
+ break;
511
+ }
512
+ if (!e.changed) {
513
+ console.log(chalk.yellow(`Entry is already published — nothing to do.`));
514
+ console.log(` ${chalk.dim('Slug:')} ${e.slug}`);
515
+ break;
516
+ }
517
+ console.log(`${chalk.green('✓')} Published: ${chalk.bold(e.title)}`);
518
+ console.log(` Slug: ${chalk.dim(e.slug)}`);
519
+ console.log(chalk.dim(' Subscribers get the email digest (Pro plans, first publish only).'));
520
+ break;
191
521
  }
192
- else if (!opts.yes) {
193
- // Non-interactive shell (CI, piped stdin): there's no prompt to show,
194
- // so make the unreviewed auto-proceed explicit. (BUG-019)
195
- console.log(chalk.yellow('Non-interactive shell: proceeding with the AI-generated entry without confirmation.'));
196
- }
522
+ default:
523
+ if (opts.json)
524
+ printJsonError(result.kind.toUpperCase().replace(/-/g, '_'), result.message);
525
+ else
526
+ console.error(chalk.red(result.message));
527
+ process.exit(1);
197
528
  }
198
- if (!title || !body) {
199
- console.error(chalk.red('Entry requires --title and --body (or --from-git / --ai-summarize to derive them).'));
200
- process.exit(1);
529
+ }
530
+ catch (err) {
531
+ handleError(err, opts.json);
532
+ }
533
+ });
534
+ program
535
+ .command('unpublish <entry>')
536
+ .alias('unpub')
537
+ .description('Revert a published entry to draft (its public URL and feeds drop it)')
538
+ .option('-p, --project <slug>', 'Project slug (or set in .deploylog.yml)')
539
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
540
+ .action(async (ref, opts) => {
541
+ try {
542
+ const result = await runSetPublished({ ref, project: opts.project, publish: false });
543
+ switch (result.kind) {
544
+ case 'updated': {
545
+ const e = result.entry;
546
+ if (opts.json) {
547
+ printJson(e);
548
+ break;
549
+ }
550
+ if (!e.changed) {
551
+ console.log(chalk.yellow(`Entry is already a draft — nothing to do.`));
552
+ break;
553
+ }
554
+ console.log(`${chalk.green('✓')} Unpublished: ${chalk.bold(e.title)}`);
555
+ console.log(chalk.dim(' The public page and feeds drop it (cached copies may lag a few minutes).\n' +
556
+ ' Republishing sets a new publish date.'));
557
+ break;
558
+ }
559
+ default:
560
+ if (opts.json)
561
+ printJsonError(result.kind.toUpperCase().replace(/-/g, '_'), result.message);
562
+ else
563
+ console.error(chalk.red(result.message));
564
+ process.exit(1);
201
565
  }
202
- if (opts.publish && opts.draft) {
203
- console.log(chalk.yellow('Both --publish and --draft passed; saving as a draft.'));
566
+ }
567
+ catch (err) {
568
+ handleError(err, opts.json);
569
+ }
570
+ });
571
+ // ─── open ───────────────────────────────────────────────────────────────────
572
+ program
573
+ .command('open [entry]')
574
+ .alias('o')
575
+ .description("Open the project's public changelog (or one entry's page) in the browser")
576
+ .option('-p, --project <slug>', 'Project slug (or set in .deploylog.yml)')
577
+ .option('--json', 'Output JSON (machine-readable, never prompts)')
578
+ .action(async (ref, opts) => {
579
+ try {
580
+ const result = await runOpen({ ref, project: opts.project });
581
+ switch (result.kind) {
582
+ case 'opened':
583
+ if (opts.json)
584
+ printJson({ url: result.url, opened: true });
585
+ else
586
+ console.log(chalk.dim(result.url));
587
+ break;
588
+ case 'printed':
589
+ if (opts.json)
590
+ printJson({ url: result.url, opened: false });
591
+ else
592
+ console.log(result.url);
593
+ break;
594
+ default:
595
+ if (opts.json)
596
+ printJsonError(result.kind.toUpperCase().replace(/-/g, '_'), result.message);
597
+ else
598
+ console.error(chalk.red(result.message));
599
+ process.exit(1);
204
600
  }
205
- const entry = await createEntry(slug, {
206
- title,
207
- body_markdown: body,
208
- entry_type: entryType,
209
- version,
210
- publish: opts.publish && !opts.draft,
211
- });
212
- const status = entry.published
213
- ? chalk.green('Published')
214
- : chalk.yellow('Draft');
215
- console.log(`\n${chalk.green('✓')} Entry created: ${chalk.bold(entry.title)}`);
216
- console.log(` Status: ${status}`);
217
- console.log(` Slug: ${chalk.dim(entry.slug)}`);
218
- if (entry.version)
219
- console.log(` Version: ${chalk.dim(`v${entry.version}`)}`);
220
601
  }
221
602
  catch (err) {
222
- handleError(err);
603
+ handleError(err, opts.json);
223
604
  }
224
605
  });
225
606
  // ─── helpers ────────────────────────────────────────────────────────────────
226
- function printAiPreview(summary, usage) {
227
- console.log();
228
- console.log(chalk.bold('AI-generated entry:'));
229
- console.log(` ${chalk.dim('Title:')} ${summary.title}`);
230
- console.log(` ${chalk.dim('Type:')} ${summary.entry_type}`);
231
- console.log(chalk.dim(' Body:'));
232
- for (const line of summary.body_markdown.split('\n')) {
233
- console.log(` ${line}`);
234
- }
235
- const limitLabel = usage.limit === null ? '∞' : String(usage.limit);
236
- console.log(chalk.dim(` Usage: ${usage.used}/${limitLabel} this month (${usage.month_key})`));
237
- console.log();
238
- }
239
- async function confirm(question) {
240
- const { createInterface } = await import('node:readline');
241
- const rl = createInterface({ input: process.stdin, output: process.stdout });
242
- const answer = await new Promise((resolve) => {
243
- rl.question(`${question} [y/N] `, resolve);
244
- });
245
- rl.close();
246
- return /^y(es)?$/i.test(answer.trim());
247
- }
248
607
  function resolveProject(cliArg) {
249
608
  if (cliArg)
250
609
  return cliArg;
@@ -256,7 +615,24 @@ function resolveProject(cliArg) {
256
615
  console.error(chalk.dim(' project: my-app'));
257
616
  process.exit(1);
258
617
  }
259
- function handleError(err) {
618
+ /** JSON mode: raw data on stdout, so output pipes into jq/scripts cleanly. */
619
+ function printJson(data) {
620
+ console.log(JSON.stringify(data, null, 2));
621
+ }
622
+ /** JSON mode errors go to stderr in the API's own error envelope shape. */
623
+ function printJsonError(code, message) {
624
+ console.error(JSON.stringify({ error: { code, message } }));
625
+ }
626
+ function handleError(err, json) {
627
+ if (json) {
628
+ if (err instanceof ApiError) {
629
+ printJsonError(err.code, err.message);
630
+ }
631
+ else {
632
+ printJsonError('ERROR', err instanceof Error ? err.message : 'An unknown error occurred');
633
+ }
634
+ process.exit(1);
635
+ }
260
636
  if (err instanceof ApiError) {
261
637
  console.error(chalk.red(`Error: ${err.message}`));
262
638
  if (err.status === 401) {