@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.
- package/CONTENT-IMPORT.md +349 -0
- package/LICENSE +112 -0
- package/PUBLIC-API.md +30 -0
- package/README.md +263 -2
- package/assets/next-v1/app/[[...pathname]]/error.tsx +10 -0
- package/assets/next-v1/app/[[...pathname]]/not-found.tsx +8 -0
- package/assets/next-v1/app/[[...pathname]]/page.tsx +38 -0
- package/assets/next-v1/app/api/storystick/[...action]/route.ts +4 -0
- package/assets/next-v1/app/layout.tsx +28 -0
- package/assets/next-v1/app/sitemap.ts +3 -0
- package/assets/next-v1/env.local.example +13 -0
- package/assets/next-v1/globals.css +17 -0
- package/assets/next-v1/lib/site.ts +10 -0
- package/assets/next-v1/next.config.ts +4 -0
- package/assets/skills/storystick-development/SKILL.md +79 -0
- package/assets/skills/storystick-development/references/setup.md +152 -0
- package/assets/skills/storystick-development/references/story-and-next.md +49 -0
- package/dist/_backfill-recommendation.d.ts +12 -0
- package/dist/_backfill-recommendation.js +18 -0
- package/dist/asset-import.d.ts +50 -0
- package/dist/asset-import.js +160 -0
- package/dist/assistant-read-client.d.ts +3 -0
- package/dist/assistant-read-client.js +8 -0
- package/dist/auth-device.d.ts +153 -0
- package/dist/auth-device.js +390 -0
- package/dist/bootstrap-client.d.ts +52 -0
- package/dist/bootstrap-client.js +19 -0
- package/dist/cli-error.d.ts +54 -0
- package/dist/cli-error.js +107 -0
- package/dist/cli.d.ts +68 -0
- package/dist/cli.js +1520 -0
- package/dist/commands/archive.d.ts +17 -0
- package/dist/commands/archive.js +86 -0
- package/dist/commands/backfill-refs.d.ts +54 -0
- package/dist/commands/backfill-refs.js +217 -0
- package/dist/commands/connect.d.ts +11 -0
- package/dist/commands/connect.js +124 -0
- package/dist/commands/connection.d.ts +57 -0
- package/dist/commands/connection.js +83 -0
- package/dist/commands/content.d.ts +11 -0
- package/dist/commands/content.js +107 -0
- package/dist/commands/delivery.d.ts +22 -0
- package/dist/commands/delivery.js +99 -0
- package/dist/commands/info.d.ts +35 -0
- package/dist/commands/info.js +62 -0
- package/dist/commands/list.d.ts +28 -0
- package/dist/commands/list.js +116 -0
- package/dist/commands/migrate.d.ts +80 -0
- package/dist/commands/migrate.js +550 -0
- package/dist/commands/release.d.ts +8 -0
- package/dist/commands/release.js +60 -0
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.js +58 -0
- package/dist/commands/slice-previews.d.ts +17 -0
- package/dist/commands/slice-previews.js +233 -0
- package/dist/commands/source.d.ts +82 -0
- package/dist/commands/source.js +192 -0
- package/dist/content-import.d.ts +78 -0
- package/dist/content-import.js +210 -0
- package/dist/generated-transaction.d.ts +22 -0
- package/dist/generated-transaction.js +330 -0
- package/dist/hub-client.d.ts +72 -0
- package/dist/hub-client.js +178 -0
- package/dist/init-operation.d.ts +98 -0
- package/dist/init-operation.js +427 -0
- package/dist/init-readiness.d.ts +10 -0
- package/dist/init-readiness.js +50 -0
- package/dist/init-scaffold.d.ts +54 -0
- package/dist/init-scaffold.js +439 -0
- package/dist/init.d.ts +51 -0
- package/dist/init.js +789 -0
- package/dist/mcp-execution.d.ts +15 -0
- package/dist/mcp-execution.js +90 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +41 -0
- package/dist/migration-artifact.d.ts +13 -0
- package/dist/migration-artifact.js +154 -0
- package/dist/operations-client.d.ts +137 -0
- package/dist/operations-client.js +130 -0
- package/dist/output.d.ts +347 -0
- package/dist/output.js +771 -0
- package/dist/project-access.d.ts +18 -0
- package/dist/project-access.js +1 -0
- package/dist/project-connection.d.ts +62 -0
- package/dist/project-connection.js +347 -0
- package/dist/project-execution-client.d.ts +2 -0
- package/dist/project-execution-client.js +33 -0
- package/dist/push-client.d.ts +100 -0
- package/dist/push-client.js +160 -0
- package/dist/release-context.d.ts +13 -0
- package/dist/release-context.js +39 -0
- package/dist/release-schema-client.d.ts +16 -0
- package/dist/release-schema-client.js +33 -0
- package/dist/release-sync.d.ts +36 -0
- package/dist/release-sync.js +31 -0
- package/dist/resolve.d.ts +15 -0
- package/dist/resolve.js +80 -0
- package/dist/runtime-config.d.ts +18 -0
- package/dist/runtime-config.js +86 -0
- package/dist/schema-export.d.ts +10 -0
- package/dist/schema-export.js +28 -0
- package/dist/setup.d.ts +65 -0
- package/dist/setup.js +135 -0
- package/dist/source-git.d.ts +36 -0
- package/dist/source-git.js +197 -0
- package/dist/starter-resolver.d.ts +82 -0
- package/dist/starter-resolver.js +495 -0
- package/dist/sync-transaction.d.ts +29 -0
- package/dist/sync-transaction.js +159 -0
- package/dist/transport.d.ts +8 -0
- package/dist/transport.js +73 -0
- package/examples/sanity/README.md +79 -0
- package/examples/sanity/convert.mjs +228 -0
- package/examples/sanity/fixture/data.ndjson +7 -0
- package/examples/sanity/fixture/mapping.json +44 -0
- package/examples/sanity/fixture/originals/mark.svg +1 -0
- package/examples/sanity/stories/articleBody.story +23 -0
- package/examples/sanity/stories/copy.story +7 -0
- package/examples/sanity/stories/page.story +19 -0
- package/examples/sanity/stories/picture.story +7 -0
- package/examples/sanity/stories/settings.story +9 -0
- package/examples/sanity/stories/stack.story +10 -0
- 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
|
+
}
|