@storystick/cli 0.0.0-stage → 0.2.0-rc.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.
Files changed (123) hide show
  1. package/CONTENT-IMPORT.md +349 -0
  2. package/LICENSE +112 -0
  3. package/PUBLIC-API.md +30 -0
  4. package/README.md +263 -2
  5. package/assets/next-v1/app/[[...pathname]]/error.tsx +10 -0
  6. package/assets/next-v1/app/[[...pathname]]/not-found.tsx +8 -0
  7. package/assets/next-v1/app/[[...pathname]]/page.tsx +38 -0
  8. package/assets/next-v1/app/api/storystick/[...action]/route.ts +4 -0
  9. package/assets/next-v1/app/layout.tsx +28 -0
  10. package/assets/next-v1/app/sitemap.ts +3 -0
  11. package/assets/next-v1/env.local.example +13 -0
  12. package/assets/next-v1/globals.css +17 -0
  13. package/assets/next-v1/lib/site.ts +10 -0
  14. package/assets/next-v1/next.config.ts +4 -0
  15. package/assets/skills/storystick-development/SKILL.md +79 -0
  16. package/assets/skills/storystick-development/references/setup.md +152 -0
  17. package/assets/skills/storystick-development/references/story-and-next.md +49 -0
  18. package/dist/_backfill-recommendation.d.ts +12 -0
  19. package/dist/_backfill-recommendation.js +18 -0
  20. package/dist/asset-import.d.ts +50 -0
  21. package/dist/asset-import.js +160 -0
  22. package/dist/assistant-read-client.d.ts +3 -0
  23. package/dist/assistant-read-client.js +8 -0
  24. package/dist/auth-device.d.ts +153 -0
  25. package/dist/auth-device.js +390 -0
  26. package/dist/bootstrap-client.d.ts +52 -0
  27. package/dist/bootstrap-client.js +19 -0
  28. package/dist/cli-error.d.ts +54 -0
  29. package/dist/cli-error.js +107 -0
  30. package/dist/cli.d.ts +68 -0
  31. package/dist/cli.js +1520 -0
  32. package/dist/commands/archive.d.ts +17 -0
  33. package/dist/commands/archive.js +86 -0
  34. package/dist/commands/backfill-refs.d.ts +54 -0
  35. package/dist/commands/backfill-refs.js +217 -0
  36. package/dist/commands/connect.d.ts +11 -0
  37. package/dist/commands/connect.js +124 -0
  38. package/dist/commands/connection.d.ts +57 -0
  39. package/dist/commands/connection.js +83 -0
  40. package/dist/commands/content.d.ts +11 -0
  41. package/dist/commands/content.js +107 -0
  42. package/dist/commands/delivery.d.ts +22 -0
  43. package/dist/commands/delivery.js +99 -0
  44. package/dist/commands/info.d.ts +35 -0
  45. package/dist/commands/info.js +62 -0
  46. package/dist/commands/list.d.ts +28 -0
  47. package/dist/commands/list.js +116 -0
  48. package/dist/commands/migrate.d.ts +80 -0
  49. package/dist/commands/migrate.js +550 -0
  50. package/dist/commands/release.d.ts +8 -0
  51. package/dist/commands/release.js +60 -0
  52. package/dist/commands/setup.d.ts +6 -0
  53. package/dist/commands/setup.js +58 -0
  54. package/dist/commands/slice-previews.d.ts +17 -0
  55. package/dist/commands/slice-previews.js +233 -0
  56. package/dist/commands/source.d.ts +82 -0
  57. package/dist/commands/source.js +192 -0
  58. package/dist/content-import.d.ts +78 -0
  59. package/dist/content-import.js +210 -0
  60. package/dist/generated-transaction.d.ts +22 -0
  61. package/dist/generated-transaction.js +330 -0
  62. package/dist/hub-client.d.ts +72 -0
  63. package/dist/hub-client.js +178 -0
  64. package/dist/init-operation.d.ts +98 -0
  65. package/dist/init-operation.js +427 -0
  66. package/dist/init-readiness.d.ts +10 -0
  67. package/dist/init-readiness.js +50 -0
  68. package/dist/init-scaffold.d.ts +54 -0
  69. package/dist/init-scaffold.js +439 -0
  70. package/dist/init.d.ts +51 -0
  71. package/dist/init.js +789 -0
  72. package/dist/mcp-execution.d.ts +15 -0
  73. package/dist/mcp-execution.js +90 -0
  74. package/dist/mcp.d.ts +7 -0
  75. package/dist/mcp.js +41 -0
  76. package/dist/migration-artifact.d.ts +13 -0
  77. package/dist/migration-artifact.js +154 -0
  78. package/dist/operations-client.d.ts +137 -0
  79. package/dist/operations-client.js +130 -0
  80. package/dist/output.d.ts +347 -0
  81. package/dist/output.js +771 -0
  82. package/dist/project-access.d.ts +18 -0
  83. package/dist/project-access.js +1 -0
  84. package/dist/project-connection.d.ts +62 -0
  85. package/dist/project-connection.js +347 -0
  86. package/dist/project-execution-client.d.ts +2 -0
  87. package/dist/project-execution-client.js +33 -0
  88. package/dist/push-client.d.ts +100 -0
  89. package/dist/push-client.js +160 -0
  90. package/dist/release-context.d.ts +13 -0
  91. package/dist/release-context.js +39 -0
  92. package/dist/release-schema-client.d.ts +16 -0
  93. package/dist/release-schema-client.js +33 -0
  94. package/dist/release-sync.d.ts +36 -0
  95. package/dist/release-sync.js +31 -0
  96. package/dist/resolve.d.ts +15 -0
  97. package/dist/resolve.js +80 -0
  98. package/dist/runtime-config.d.ts +18 -0
  99. package/dist/runtime-config.js +86 -0
  100. package/dist/schema-export.d.ts +10 -0
  101. package/dist/schema-export.js +28 -0
  102. package/dist/setup.d.ts +65 -0
  103. package/dist/setup.js +135 -0
  104. package/dist/source-git.d.ts +36 -0
  105. package/dist/source-git.js +197 -0
  106. package/dist/starter-resolver.d.ts +82 -0
  107. package/dist/starter-resolver.js +495 -0
  108. package/dist/sync-transaction.d.ts +29 -0
  109. package/dist/sync-transaction.js +159 -0
  110. package/dist/transport.d.ts +8 -0
  111. package/dist/transport.js +73 -0
  112. package/examples/sanity/README.md +79 -0
  113. package/examples/sanity/convert.mjs +228 -0
  114. package/examples/sanity/fixture/data.ndjson +7 -0
  115. package/examples/sanity/fixture/mapping.json +44 -0
  116. package/examples/sanity/fixture/originals/mark.svg +1 -0
  117. package/examples/sanity/stories/articleBody.story +23 -0
  118. package/examples/sanity/stories/copy.story +7 -0
  119. package/examples/sanity/stories/page.story +19 -0
  120. package/examples/sanity/stories/picture.story +7 -0
  121. package/examples/sanity/stories/settings.story +9 -0
  122. package/examples/sanity/stories/stack.story +10 -0
  123. package/package.json +75 -3
package/dist/output.js ADDED
@@ -0,0 +1,771 @@
1
+ /**
2
+ * Storystick CLI — output primitives (ADR-061).
3
+ *
4
+ * Every user-visible byte the CLI emits flows through this module. The
5
+ * boundary is enforced mechanically by `scripts/lint-output.mjs`, which
6
+ * forbids raw `console.*`, `process.(stdout|stderr).write`, and direct
7
+ * imports of `ora` / `@inquirer/prompts` anywhere outside this file.
8
+ *
9
+ * The five locked style rules (ADR-061 §Decision):
10
+ * 1. One active line at a time — spinner text mutates in place.
11
+ * 2. Two icons (✔ green, ✖ red), four colors (green / red / cyan-spinner
12
+ * / rare yellow+gray).
13
+ * 3. Zero chrome — no boxes, no gutters, no banners.
14
+ * 4. URLs emitted bare (terminal auto-linkifies).
15
+ * 5. stderr for status, stdout for data payloads.
16
+ *
17
+ * Mode state is module-global: a CLI is a single short-lived process, not
18
+ * a library. `resetOutput()` is the only test-isolation mechanism.
19
+ *
20
+ * `ora` and `@inquirer/prompts` are lazy-imported inside the primitives
21
+ * that need them — fast commands (`generate`, `push`, `sync`) never pay
22
+ * the startup cost.
23
+ */
24
+ import pc from 'picocolors';
25
+ import { relative } from 'node:path';
26
+ import { StorystickError } from './cli-error.js';
27
+ // ─── Module state (per-process, reset via resetOutput()) ─────
28
+ let currentMode = 'human';
29
+ let verboseMode = false;
30
+ let quietMode = false;
31
+ let capture = null;
32
+ let activeSpinner = null;
33
+ const listeners = [];
34
+ let sigintInstalled = false;
35
+ /**
36
+ * Configure the per-invocation output mode (ADR-072 §D12).
37
+ *
38
+ * Mutual exclusivity:
39
+ * - `verbose + quiet` → fatal (exit 1 with hint) — they're directly
40
+ * contradictory.
41
+ * - `verbose + json` → JSON wins. `verbose` still affects what makes
42
+ * it INTO the envelope (info diagnostics rendered) but the human
43
+ * rendering layer is muted under JSON regardless.
44
+ * - `quiet + json` → both apply. stdout still emits the envelope
45
+ * (data is data); stderr is silent except for failures.
46
+ */
47
+ export function configureOutput(opts = {}) {
48
+ if (opts.verbose === true && opts.quiet === true) {
49
+ // configureOutput is called BEFORE the canonical-shell try/catch in
50
+ // every action, so this surfaces as a clean fatal — not a stack trace.
51
+ currentMode = opts.json === true ? 'json' : 'human';
52
+ verboseMode = false;
53
+ quietMode = false;
54
+ ensureSigintHandler();
55
+ exitWithError('--verbose and --quiet are mutually exclusive.', 'Pass exactly one (or neither).');
56
+ }
57
+ currentMode = opts.json === true ? 'json' : 'human';
58
+ verboseMode = opts.verbose === true;
59
+ quietMode = opts.quiet === true;
60
+ ensureSigintHandler();
61
+ }
62
+ export function getOutputMode() {
63
+ return currentMode;
64
+ }
65
+ /**
66
+ * `--verbose` is set. Action handlers consult this to decide whether to
67
+ * render info-level diagnostics + list warning details post-success,
68
+ * instead of just counting them.
69
+ */
70
+ export function isVerbose() {
71
+ return verboseMode;
72
+ }
73
+ /**
74
+ * `--quiet` is set. Status / warning lines are muted under quiet — but
75
+ * failures (`logFailure`) and data (`logOutput`) always pass through
76
+ * per ADR-061 rule 5.
77
+ */
78
+ export function isQuiet() {
79
+ return quietMode;
80
+ }
81
+ /**
82
+ * Fully reset module state. Call from `beforeEach` in tests — the in-process
83
+ * test suite shares this module across cases.
84
+ */
85
+ export function resetOutput() {
86
+ if (activeSpinner) {
87
+ try {
88
+ activeSpinner.stop();
89
+ }
90
+ catch {
91
+ /* stopping a dead spinner shouldn't crash the test run */
92
+ }
93
+ activeSpinner = null;
94
+ }
95
+ currentMode = 'human';
96
+ verboseMode = false;
97
+ quietMode = false;
98
+ capture = null;
99
+ listeners.length = 0;
100
+ }
101
+ /**
102
+ * Start capturing output. Writes go to in-memory buffers instead of the
103
+ * real streams; events are appended to `events`. Inversion of control,
104
+ * no `process.std*` spying.
105
+ */
106
+ export function __testing_enableCapture() {
107
+ capture = { stdout: [], stderr: [], events: [] };
108
+ return capture;
109
+ }
110
+ export function onEvent(fn) {
111
+ listeners.push(fn);
112
+ return () => {
113
+ const idx = listeners.indexOf(fn);
114
+ if (idx >= 0)
115
+ listeners.splice(idx, 1);
116
+ };
117
+ }
118
+ function emit(event) {
119
+ if (capture)
120
+ capture.events.push(event);
121
+ for (const l of listeners) {
122
+ try {
123
+ l(event);
124
+ }
125
+ catch {
126
+ /* listener errors must not poison primitives */
127
+ }
128
+ }
129
+ }
130
+ function writeStderr(text) {
131
+ if (capture) {
132
+ capture.stderr.push(text);
133
+ return;
134
+ }
135
+ process.stderr.write(text);
136
+ }
137
+ function writeStdout(text) {
138
+ if (capture) {
139
+ capture.stdout.push(text);
140
+ return;
141
+ }
142
+ process.stdout.write(text);
143
+ }
144
+ // ─── Logging primitives ───────────────────────────────────────
145
+ /** Dimmed status line — muted under --json or --quiet. */
146
+ export function logMessage(text) {
147
+ emit({ type: 'message', text });
148
+ if (currentMode === 'json' || quietMode)
149
+ return;
150
+ writeStderr(text + '\n');
151
+ }
152
+ export function logFinishedStep(text) {
153
+ emit({ type: 'finished-step', text });
154
+ if (currentMode === 'json' || quietMode)
155
+ return;
156
+ writeStderr(`${pc.green('✔')} ${text}\n`);
157
+ }
158
+ export function logFailure(text) {
159
+ emit({ type: 'failure', text });
160
+ // logFailure is the ONE status primitive that is never muted — real
161
+ // errors must surface even under --json (stderr line still useful in
162
+ // mixed-pipe contexts) and even under --quiet (per ADR-061 rule 5).
163
+ writeStderr(`${pc.red('✖')} ${text}\n`);
164
+ }
165
+ export function logWarning(text) {
166
+ emit({ type: 'warning', text });
167
+ if (currentMode === 'json' || quietMode)
168
+ return;
169
+ writeStderr(`${pc.yellow(text)}\n`);
170
+ }
171
+ /**
172
+ * THE ONLY function writing to stdout. Payload must already carry its own
173
+ * trailing newline when the caller wants one (matches the Pattern D JSON
174
+ * convention at `list.ts` / `info.ts` today).
175
+ */
176
+ export function logOutput(payload) {
177
+ emit({ type: 'data' });
178
+ writeStdout(payload);
179
+ }
180
+ /**
181
+ * Identity helper retained for source stability — `Diagnostic` IS
182
+ * `JsonDiagnostic` post-batch-6, so the conversion is a no-op. Kept
183
+ * exported (vs deleted) so existing call sites don't churn ; can be
184
+ * inlined in a future cleanup batch.
185
+ */
186
+ export function diagnosticToJson(d) {
187
+ return d;
188
+ }
189
+ export function emitJsonResult(args) {
190
+ const envelope = args.ok
191
+ ? {
192
+ ok: true,
193
+ command: args.command,
194
+ version: '1',
195
+ data: args.data,
196
+ diagnostics: args.diagnostics ?? [],
197
+ }
198
+ : {
199
+ ok: false,
200
+ command: args.command,
201
+ version: '1',
202
+ // Caller must pass a JsonError when ok=false; the type guard
203
+ // above enforces it at call sites.
204
+ error: args.error,
205
+ diagnostics: args.diagnostics ?? [],
206
+ };
207
+ emit({ type: 'data' });
208
+ writeStdout(JSON.stringify(envelope) + '\n');
209
+ }
210
+ /**
211
+ * Emit a single line of the JSONL stream. Used by `backfill-refs --json`
212
+ * (ADR-072 §A3 / L5) — multiple `progress` events followed by exactly
213
+ * one `final` event.
214
+ */
215
+ export function emitJsonlEvent(args) {
216
+ const line = {
217
+ command: args.command,
218
+ version: '1',
219
+ event: args.event,
220
+ data: args.data,
221
+ };
222
+ if (args.event === 'final') {
223
+ line.ok = args.ok ?? true;
224
+ }
225
+ emit({ type: 'data-jsonl', event: args.event });
226
+ writeStdout(JSON.stringify(line) + '\n');
227
+ }
228
+ /** Emit the terminal canonical failure event for a JSONL command. */
229
+ export function emitJsonlFailure(args) {
230
+ emit({ type: 'data-jsonl', event: 'final' });
231
+ writeStdout(JSON.stringify({
232
+ command: args.command,
233
+ version: '1',
234
+ event: 'final',
235
+ ok: false,
236
+ error: jsonErrorFromUnknown(args.err, args.hint),
237
+ }) + '\n');
238
+ }
239
+ // ─── Diagnostic formatting (shared by generate / push / sync) ─
240
+ export function formatDiagnostic(d, rootDir) {
241
+ const icon = d.severity === 'error'
242
+ ? pc.red('✖')
243
+ : d.severity === 'warning'
244
+ ? pc.yellow('!')
245
+ : pc.dim('·');
246
+ const file = relative(rootDir, d.file);
247
+ // ADR-080 + ADR-079 D3 : `range` is LSP-style 0-indexed `{start, end}` ;
248
+ // editors/compilers conventionally show 1-indexed line:col, so +1 here.
249
+ const loc = d.range
250
+ ? `:${d.range.start.line + 1}:${d.range.start.character + 1}`
251
+ : '';
252
+ const code = pc.dim(`[${d.code}]`);
253
+ let msg = `${icon} ${file}${loc} ${code} ${d.message}`;
254
+ if (d.related) {
255
+ for (const r of d.related) {
256
+ const rFile = relative(rootDir, r.file);
257
+ msg += `\n ${pc.dim('—')} ${rFile} ${r.message}`;
258
+ }
259
+ }
260
+ return msg;
261
+ }
262
+ // ─── Spinner primitives ───────────────────────────────────────
263
+ let oraModulePromise = null;
264
+ async function loadOra() {
265
+ if (!oraModulePromise) {
266
+ oraModulePromise = import('ora').then((m) => m.default);
267
+ }
268
+ return await oraModulePromise;
269
+ }
270
+ /**
271
+ * Start a spinner. Under `--json` or capture, no TTY updates happen — the
272
+ * event is still emitted so tests can assert the orchestration shape.
273
+ */
274
+ export async function showSpinner(text) {
275
+ ensureSigintHandler();
276
+ emit({ type: 'spinner-start', text });
277
+ if (currentMode === 'json' || capture) {
278
+ activeSpinner = makeNoopSpinner(text);
279
+ return;
280
+ }
281
+ const ora = await loadOra();
282
+ const instance = ora({ text, color: 'cyan', stream: process.stderr }).start();
283
+ activeSpinner = {
284
+ get text() {
285
+ return instance.text;
286
+ },
287
+ set text(v) {
288
+ instance.text = v;
289
+ },
290
+ succeed: (t) => {
291
+ // Force the ✔ prefix to match our palette rules even if ora's default
292
+ // ever drifts. Stream discipline (stderr) comes from the stream option.
293
+ instance.stopAndPersist({
294
+ symbol: pc.green('✔'),
295
+ text: t ?? instance.text,
296
+ });
297
+ },
298
+ fail: (t) => {
299
+ instance.stopAndPersist({
300
+ symbol: pc.red('✖'),
301
+ text: t ?? instance.text,
302
+ });
303
+ },
304
+ stop: () => instance.stop(),
305
+ };
306
+ }
307
+ /** Mutate the active spinner's text in place. No-op if no spinner is live. */
308
+ export function changeSpinner(text) {
309
+ emit({ type: 'spinner-change', text });
310
+ if (!activeSpinner)
311
+ return;
312
+ activeSpinner.text = text;
313
+ }
314
+ /** Land the spinner with `✔ <text>` (or the current spinner text). */
315
+ export function succeedSpinner(text) {
316
+ if (activeSpinner) {
317
+ emit({ type: 'spinner-success', text: text ?? activeSpinner.text });
318
+ activeSpinner.succeed(text);
319
+ activeSpinner = null;
320
+ }
321
+ else {
322
+ // No spinner live — fall back to a plain finished-step line so callers
323
+ // can use `succeedSpinner` uniformly.
324
+ logFinishedStep(text ?? '');
325
+ }
326
+ }
327
+ /** Land the spinner with `✖ <text>`. */
328
+ export function failSpinner(text) {
329
+ if (activeSpinner) {
330
+ emit({ type: 'spinner-failure', text: text ?? activeSpinner.text });
331
+ activeSpinner.fail(text);
332
+ activeSpinner = null;
333
+ }
334
+ else {
335
+ logFailure(text ?? '');
336
+ }
337
+ }
338
+ /** Stop the spinner without landing a symbol. UI cleanup — no event. */
339
+ export function stopSpinner() {
340
+ if (!activeSpinner)
341
+ return;
342
+ emit({ type: 'spinner-stop' });
343
+ activeSpinner.stop();
344
+ activeSpinner = null;
345
+ }
346
+ /**
347
+ * Show a spinner ONLY if `fn` takes longer than `delayMs`. Used for init's
348
+ * six orchestration phases — fast ops (<delayMs) finish silently; slow ops
349
+ * land a `✔`; throws land a `✖` and rethrow.
350
+ *
351
+ * NOT for device-auth: that path goes through `promptDeviceAuth` directly.
352
+ *
353
+ * For multi-phase work that needs an external success line (push/sync),
354
+ * use `withSlowSpinner` instead — it suppresses the auto-✔ so the caller
355
+ * can print its own `logFinishedStep` line after.
356
+ */
357
+ export async function showSpinnerIfSlow(text, delayMs, fn) {
358
+ let shown = false;
359
+ const timer = setTimeout(() => {
360
+ shown = true;
361
+ void showSpinner(text);
362
+ }, delayMs);
363
+ try {
364
+ const result = await fn();
365
+ clearTimeout(timer);
366
+ if (shown)
367
+ succeedSpinner(text);
368
+ return result;
369
+ }
370
+ catch (err) {
371
+ clearTimeout(timer);
372
+ if (shown)
373
+ failSpinner(text);
374
+ throw err;
375
+ }
376
+ }
377
+ /**
378
+ * Thin slow-gate around the bare spinner primitives, for ADR-072 §D11
379
+ * (push/sync/generate spinners). Like `showSpinnerIfSlow` but:
380
+ *
381
+ * - Does NOT auto-succeed at the end — caller is expected to print its
382
+ * own success line via `logFinishedStep` after the work returns. This
383
+ * avoids the double-✔ that `showSpinnerIfSlow` produces when callers
384
+ * also want a structured success line.
385
+ * - Stops the spinner cleanly on success (no ✔ symbol) and on throw
386
+ * (no ✖ symbol either — failures are surfaced by the caller's
387
+ * diagnostic rendering + `logFailure` / `exitWithError`).
388
+ * - Inside `fn`, callers may call `changeSpinner('…')` to mutate the
389
+ * visible text across phases. The "single spinner that mutates"
390
+ * pattern in ADR-072 §D11.
391
+ *
392
+ * On fast paths (<delayMs), no spinner is ever shown — UX is silent
393
+ * during the work; the caller's success line is the only visible output.
394
+ */
395
+ export async function withSlowSpinner(text, delayMs, fn) {
396
+ let shown = false;
397
+ const timer = setTimeout(() => {
398
+ shown = true;
399
+ void showSpinner(text);
400
+ }, delayMs);
401
+ try {
402
+ const result = await fn();
403
+ clearTimeout(timer);
404
+ if (shown)
405
+ stopSpinner();
406
+ return result;
407
+ }
408
+ catch (err) {
409
+ clearTimeout(timer);
410
+ if (shown)
411
+ stopSpinner();
412
+ throw err;
413
+ }
414
+ }
415
+ // ─── Device-auth prompt (replaces the six pre-refactor copies) ─
416
+ /**
417
+ * Render the "visit URL + type code" block and resume with a spinner. If
418
+ * a spinner is active when the prompt fires (typical during `link`'s
419
+ * resolve-project phase), it is stopped before the block lands so the
420
+ * prompt doesn't race with the spinner frame.
421
+ *
422
+ * URL is emitted bare per rule 4 — no underline, no brackets, no color.
423
+ */
424
+ export function promptDeviceAuth(data) {
425
+ // Stop any live spinner FIRST so the prompt text doesn't race with
426
+ // spinner frames — the clack-gutter bug this refactor explicitly fixes.
427
+ if (activeSpinner)
428
+ stopSpinner();
429
+ emit({ type: 'prompt-device-auth' });
430
+ const minutes = Math.max(1, Math.round(data.expiresIn / 60));
431
+ writeStderr('\n');
432
+ if (data.issuer)
433
+ writeStderr(` Gateway: ${data.issuer}\n`);
434
+ writeStderr(` Visit ${data.verificationUrl}\n`);
435
+ writeStderr(` Your code: ${pc.bold(data.userCode)} ${pc.dim(`(expires in ${minutes} minutes)`)}\n`);
436
+ writeStderr('\n');
437
+ // Fire-and-forget browser launch — nice-to-have, silent on failure.
438
+ void maybeOpenBrowser(data.verificationUrl);
439
+ // Resume with "Waiting…" spinner — every call site shares this step.
440
+ void showSpinner(data.waitingText ?? 'Waiting for authorization…');
441
+ }
442
+ /**
443
+ * Attempt to launch the user's default browser at `url`. Scoped to cases
444
+ * where it actually helps: skip under test capture, JSON mode, non-TTY
445
+ * stdout (CI / SSH), or the explicit `STORYSTICK_NO_BROWSER=1` opt-out.
446
+ *
447
+ * Failures are swallowed — the printed URL stays as the source of truth,
448
+ * so missing `$DISPLAY` / sandboxed environments degrade cleanly.
449
+ */
450
+ async function maybeOpenBrowser(url) {
451
+ if (capture)
452
+ return;
453
+ if (currentMode === 'json')
454
+ return;
455
+ if (process.env.STORYSTICK_NO_BROWSER)
456
+ return;
457
+ if (!process.stdout.isTTY)
458
+ return;
459
+ try {
460
+ const open = (await import('open')).default;
461
+ await open(url);
462
+ }
463
+ catch {
464
+ /* silent — the printed URL is the fallback */
465
+ }
466
+ }
467
+ /**
468
+ * Type guard for the "user cancelled a prompt" case. `@inquirer/prompts`
469
+ * throws a specific error class on Ctrl-C; every prompt call site catches
470
+ * this and treats it as an exit-0 cancel (per-command semantic, ADR-061
471
+ * §SIGINT scope).
472
+ *
473
+ * Lives here so the boundary-lint can keep @inquirer/core out of command
474
+ * files — matching by `name` is sufficient since the error surfaces from
475
+ * our own wrappers.
476
+ */
477
+ export function isExitPromptCancel(err) {
478
+ return (err instanceof Error &&
479
+ (err.name === 'ExitPromptError' || err.constructor?.name === 'ExitPromptError'));
480
+ }
481
+ // ─── @inquirer/prompts wrappers (lazy-imported) ───────────────
482
+ let promptsModulePromise = null;
483
+ async function loadPrompts() {
484
+ if (!promptsModulePromise) {
485
+ promptsModulePromise = import('@inquirer/prompts');
486
+ }
487
+ return await promptsModulePromise;
488
+ }
489
+ /**
490
+ * Unified no-human-in-loop check — any path that needs a human must call
491
+ * this first. Fails fast with non-zero exit + stderr diagnostic + empty
492
+ * stdout when stdin is not a TTY OR when `--json` is active.
493
+ */
494
+ function assertInteractive(remediation) {
495
+ if (currentMode === 'json' || !process.stdin.isTTY) {
496
+ logFailure(remediation);
497
+ exit(1);
498
+ }
499
+ }
500
+ export async function promptText(message, opts = {}) {
501
+ assertInteractive(`Cannot prompt for "${message}" — stdin is not a TTY.`);
502
+ emit({ type: 'prompt-input' });
503
+ const { input } = await loadPrompts();
504
+ return await input({
505
+ message,
506
+ default: opts.placeholder,
507
+ validate: opts.validate
508
+ ? (value) => {
509
+ const result = opts.validate(value);
510
+ return result === undefined ? true : result;
511
+ }
512
+ : undefined,
513
+ });
514
+ }
515
+ export async function promptSelect(message, choices) {
516
+ assertInteractive(`Cannot prompt for "${message}" — stdin is not a TTY.`);
517
+ emit({ type: 'prompt-select' });
518
+ const { select } = await loadPrompts();
519
+ return (await select({
520
+ message,
521
+ choices: choices.map((c) => ({
522
+ value: c.value,
523
+ name: c.name,
524
+ description: c.description,
525
+ })),
526
+ }));
527
+ }
528
+ export async function promptConfirm(message, opts = {}) {
529
+ assertInteractive(`Cannot prompt for "${message}" — stdin is not a TTY.`);
530
+ emit({ type: 'prompt-confirm' });
531
+ const { confirm } = await loadPrompts();
532
+ return await confirm({ message, default: opts.default ?? false });
533
+ }
534
+ // ─── Error exit — replaces the four copies of handleCliError ──
535
+ /**
536
+ * Network error codes that route to exit 2 (system-level failures the
537
+ * caller can retry). Per ADR-072 §D13 / lock A4 — Unix canonical
538
+ * "transient retryable" set.
539
+ */
540
+ const NETWORK_RETRY_CODES = new Set([
541
+ 'ECONNREFUSED',
542
+ 'ENOTFOUND',
543
+ 'ETIMEDOUT',
544
+ 'ECONNRESET',
545
+ 'EAI_AGAIN',
546
+ ]);
547
+ /**
548
+ * Map an error onto its semantic exit code (ADR-072 §D13 / A4):
549
+ * 130 — user cancel (Ctrl-C / ExitPromptError)
550
+ * 2 — system failure (5xx / connection refused / DNS / timeout)
551
+ * 1 — user error (parse, validation, 4xx, missing env, …)
552
+ *
553
+ * 0 is reserved for success (no error → caller never reaches this path).
554
+ *
555
+ * The predicate handles errors at any wrapping depth: post-Batch 3,
556
+ * `StorystickError(cause: TypeError(cause: { code: 'ECONNREFUSED' }))` is
557
+ * the canonical shape for fetch failures. We descend up to two `cause`
558
+ * levels so the predicate stays robust against future shapes.
559
+ */
560
+ export function routeExitCode(err) {
561
+ if (isExitPromptCancel(err))
562
+ return 130;
563
+ if (err instanceof Error && err.name === 'AbortError')
564
+ return 130;
565
+ if (err instanceof StorystickError && typeof err.status === 'number' && err.status >= 500) {
566
+ return 2;
567
+ }
568
+ // Walk up to two cause levels — TypeError-from-fetch is `err.cause`
569
+ // (when wrapped) or `err` itself, with the system error living one
570
+ // level deeper as `cause.cause`. Anywhere along the chain that we
571
+ // find an ECONNREFUSED-style code, route to 2.
572
+ const visited = new Set();
573
+ let cur = err;
574
+ for (let depth = 0; depth < 4 && cur; depth++) {
575
+ if (visited.has(cur))
576
+ break;
577
+ visited.add(cur);
578
+ const sysCode = cur.code;
579
+ if (typeof sysCode === 'string' && NETWORK_RETRY_CODES.has(sysCode)) {
580
+ return 2;
581
+ }
582
+ cur = cur.cause;
583
+ }
584
+ return 1;
585
+ }
586
+ /**
587
+ * Print an error and exit. Accepts our `StorystickError` (for which we
588
+ * dim-append the Pattern D code) or any other `Error | unknown`.
589
+ *
590
+ * The caller passes `hint` for structured follow-up text (rendered as a
591
+ * dim line). Exit code defaults to `routeExitCode(err)` (ADR-072 §D13);
592
+ * pass `{ exitCode }` to force a specific value.
593
+ *
594
+ * Under JSON mode, the human-readable failure line is suppressed — the
595
+ * caller is expected to have already emitted (or to emit immediately
596
+ * after) a `{ ok: false, ... }` envelope on stdout.
597
+ *
598
+ * Mock via `vi.spyOn(process, 'exit')` in tests.
599
+ */
600
+ export function exitWithError(err, hint, opts = {}) {
601
+ if (currentMode !== 'json') {
602
+ const message = err instanceof Error ? err.message : String(err);
603
+ logFailure(message);
604
+ // Surface Pattern D error code if present without leaking the class.
605
+ const code = err.code;
606
+ if (typeof code === 'string' && code.length > 0) {
607
+ writeStderr(` ${pc.dim(`(${code})`)}\n`);
608
+ }
609
+ if (hint)
610
+ writeStderr(` ${pc.dim(hint)}\n`);
611
+ }
612
+ else {
613
+ // Always emit the failure event so tests can assert orchestration
614
+ // even under --json (the human line is muted).
615
+ emit({
616
+ type: 'failure',
617
+ text: err instanceof Error ? err.message : String(err),
618
+ });
619
+ }
620
+ const code = opts.exitCode ?? routeExitCode(err);
621
+ exit(code);
622
+ }
623
+ /**
624
+ * Centralized Ctrl-C → exit 130 routing (ADR-072 §A5 / D16). Replaces
625
+ * the per-call `if (isExitPromptCancel(err)) process.exit(0)` pattern at
626
+ * 4 sites pre-ADR-072. Only exits if the error IS a prompt cancel; no-op
627
+ * otherwise so callers can continue with their normal catch path.
628
+ */
629
+ export function maybeExitOnCancel(err) {
630
+ if (isExitPromptCancel(err))
631
+ exit(130);
632
+ }
633
+ /**
634
+ * Combined "emit JSON failure envelope + exit" for action handlers under
635
+ * --json mode. Calling exitWithError directly from the canonical-shell
636
+ * catch block under JSON mode emits a failure event but no envelope —
637
+ * this helper closes that gap. Caller is responsible for choosing a
638
+ * meaningful `error.code`; falls back to the StorystickError's code or
639
+ * a generic 'INTERNAL' marker.
640
+ */
641
+ export function exitJsonFailure(args) {
642
+ const { command, err, diagnostics, hint } = args;
643
+ const envelope = jsonErrorFromUnknown(err, hint);
644
+ emitJsonResult({
645
+ command,
646
+ ok: false,
647
+ error: envelope,
648
+ diagnostics: diagnostics ?? [],
649
+ });
650
+ exit(routeExitCode(err));
651
+ }
652
+ /** Convert an arbitrary command error into the canonical JSON wire shape. */
653
+ export function jsonErrorFromUnknown(err, hint) {
654
+ const rawCode = err instanceof StorystickError && err.code
655
+ ? err.code
656
+ : err instanceof Error && err.name === 'StorystickError'
657
+ ? 'STORYSTICK_ERROR'
658
+ : 'INTERNAL';
659
+ // Auto-namespace bare CLI codes per ADR-079 batch 6 wire-format
660
+ // contract (canonical Envelope `code: storystick/<NAME>`).
661
+ const code = rawCode.startsWith('storystick/')
662
+ ? rawCode
663
+ : `storystick/${rawCode}`;
664
+ const message = err instanceof Error ? err.message : String(err);
665
+ const structured = err instanceof StorystickError ? err : undefined;
666
+ const structuredCategory = structured?.category;
667
+ const category = structuredCategory === 'validation' ||
668
+ structuredCategory === 'conflict' ||
669
+ structuredCategory === 'auth' ||
670
+ structuredCategory === 'system' ||
671
+ structuredCategory === 'cancel'
672
+ ? structuredCategory
673
+ : 'system';
674
+ // Preserve a canonical gateway envelope end-to-end. CLI-local errors that
675
+ // lack catalogue metadata retain conservative defaults; network failures
676
+ // detected by routeExitCode remain retryable.
677
+ const exitCode = routeExitCode(err);
678
+ const envelope = {
679
+ code,
680
+ message,
681
+ category,
682
+ recoverable: structured?.recoverable ?? false,
683
+ retryable: structured?.retryable ?? exitCode === 2,
684
+ ...(structured?.payload !== undefined
685
+ ? { payload: structured.payload }
686
+ : {}),
687
+ ...(structured?.messageKey
688
+ ? { messageKey: structured.messageKey }
689
+ : hint
690
+ ? { messageKey: 'cli.hint' }
691
+ : {}),
692
+ ...(structured?.params
693
+ ? { params: structured.params }
694
+ : hint
695
+ ? { params: { hint } }
696
+ : {}),
697
+ ...(structured?.ref ? { ref: structured.ref } : {}),
698
+ ...(structured?.docUrl ? { docUrl: structured.docUrl } : {}),
699
+ };
700
+ return envelope;
701
+ }
702
+ /** Indirection so tests can stub `process.exit` without patching the primitive. */
703
+ function exit(code) {
704
+ process.exit(code);
705
+ }
706
+ // ─── Cancellable-work helper (per-command semantic abort) ────
707
+ /**
708
+ * Bind an `AbortController` to SIGINT for the duration of a piece of work.
709
+ * Complements the output.ts SIGINT handler (which only cleans UI): this is
710
+ * how a command translates Ctrl-C into "throw out of the in-flight poll /
711
+ * fetch". Call `dispose()` in a `finally` so the listener doesn't leak.
712
+ *
713
+ * ```ts
714
+ * const { signal, dispose } = withInterrupt()
715
+ * try {
716
+ * await callHubApi(path, { signal, onAuthPrompt: promptDeviceAuth })
717
+ * } finally {
718
+ * dispose()
719
+ * }
720
+ * ```
721
+ */
722
+ export function withInterrupt() {
723
+ const ac = new AbortController();
724
+ const onInt = () => ac.abort();
725
+ process.on('SIGINT', onInt);
726
+ return {
727
+ signal: ac.signal,
728
+ dispose: () => {
729
+ process.off('SIGINT', onInt);
730
+ },
731
+ };
732
+ }
733
+ // ─── SIGINT — UI cleanup only (no process.exit) ──────────────
734
+ function ensureSigintHandler() {
735
+ if (sigintInstalled)
736
+ return;
737
+ sigintInstalled = true;
738
+ process.on('SIGINT', onSigint);
739
+ }
740
+ function onSigint() {
741
+ if (activeSpinner) {
742
+ try {
743
+ activeSpinner.stop();
744
+ }
745
+ catch {
746
+ /* spinner teardown shouldn't crash the signal path */
747
+ }
748
+ activeSpinner = null;
749
+ }
750
+ // Restore the cursor in case a library hid it (DECTCEM show).
751
+ if (!capture)
752
+ process.stderr.write('\x1b[?25h');
753
+ // Per ADR-061: we do NOT call process.exit. Commands that need semantic
754
+ // cancellation (login's AbortController) register their own listener —
755
+ // Node fires all listeners.
756
+ }
757
+ // ─── Internal helpers ────────────────────────────────────────
758
+ function makeNoopSpinner(text) {
759
+ let current = text;
760
+ return {
761
+ get text() {
762
+ return current;
763
+ },
764
+ set text(v) {
765
+ current = v;
766
+ },
767
+ succeed: () => { },
768
+ fail: () => { },
769
+ stop: () => { },
770
+ };
771
+ }