promptdock 1.2.0 → 1.2.1

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.
@@ -5,6 +5,7 @@ import { existsSync, lstatSync, rmdirSync, rmSync, unlinkSync } from "node:fs";
5
5
  import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
6
6
  import { ensureAuth } from "../auth.js";
7
7
  import { CliError, EXIT, mapFsError, usageError } from "../errors.js";
8
+ import { SERVER_LABEL_MAX, SERVER_PATH_MAX, terminalLine } from "../terminal-text.js";
8
9
  import { checkInstallResponse, performInstall } from "../installer.js";
9
10
  import { formatRef, parseSkillRef } from "../ref.js";
10
11
  import { modifiedFiles, readReceipt, scanReceipts, RECEIPT_NAME, } from "../receipts.js";
@@ -16,8 +17,88 @@ function scopeOf(flags) {
16
17
  return "both";
17
18
  return flags.global === true ? "global" : "local";
18
19
  }
19
- function newerSchemaError(dir, schema, argsLine) {
20
- return new CliError(`${dir} was installed by a newer promptdock CLI (receipt schema ${schema}). Run: npx promptdock@latest ${argsLine}`, EXIT.INTEGRITY, { footer: "receipt_schema" });
20
+ /**
21
+ * A re-run line built from the PARSED invocation never from `ctx.argsLine`.
22
+ *
23
+ * `argsLine` is the REDACTED copy that rides `X-Promptdock-Cli-Args`: context.ts
24
+ * replaces `--token` and `--dir` values with `***` so a live bearer and an absolute
25
+ * home path never reach the server or a proxy log. Echoing it back into a "Run: …"
26
+ * remedy printed `npx promptdock@latest install a/b --dir ***` — a command that
27
+ * cannot be pasted, handed to the user at the one moment they need a command they
28
+ * can paste. The parsed flags hold the REAL values, so the remedy is built from
29
+ * those and the redaction stays where it belongs: on the wire.
30
+ */
31
+ export function rerunCommand(command, positionals, flags) {
32
+ const parts = [command, ...positionals];
33
+ for (const [name, value] of Object.entries(flags)) {
34
+ // `--help` would turn the remedy into a no-op; `--token` is why redaction exists
35
+ // in the first place and must never be re-emitted, even though no lifecycle
36
+ // command accepts one today (a future one would inherit this guard silently).
37
+ if (name === "help" || name === "token")
38
+ continue;
39
+ if (value === true)
40
+ parts.push(`--${name}`);
41
+ else if (typeof value === "string")
42
+ parts.push(`--${name}`, shellArg(value));
43
+ }
44
+ return `npx promptdock@latest ${parts.join(" ")}`;
45
+ }
46
+ /** Single-quote a value the user is meant to paste (paths carry spaces). */
47
+ function shellArg(value) {
48
+ return /^[\w@./:+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
49
+ }
50
+ export function newerSchemaError(dir, schema, rerun) {
51
+ return new CliError(`${dir} was installed by a newer promptdock CLI (receipt schema ${schema}). Run: ${rerun}`, EXIT.INTEGRITY, { footer: "receipt_schema" });
52
+ }
53
+ /**
54
+ * Turn per-item failures into the process's exit.
55
+ *
56
+ * WHY THIS EXISTS: both batch commands used to record every failure as
57
+ * `status: "skipped"` and return normally, so `promptdock update --all -y` in CI
58
+ * exited 0 having updated nothing — the job went green on the exact outcome it was
59
+ * added to catch. A silent no-op is worse than a red build.
60
+ *
61
+ * WHAT COUNTS: only a real failure. "Up to date", "an update is available"
62
+ * (--check), and a human answering No to a confirm are all correct outcomes and
63
+ * stay 0 — otherwise `--check` could never be used as a plain report.
64
+ *
65
+ * THE CODE: the FIRST failure's own exit code, so a single-item batch exits exactly
66
+ * as the same operation would have outside a batch, and a mixed batch still reports
67
+ * a cause rather than a generic "something failed".
68
+ */
69
+ function batchExitError(failures, allowPartial,
70
+ /** true when the per-item `! <ref>: <message>` lines already went to stderr (non-JSON) */
71
+ alreadyPrinted) {
72
+ if (allowPartial || failures.length === 0)
73
+ return null;
74
+ const first = failures[0].error;
75
+ // A heterogeneous batch: keep the first code, but only keep a docs footer when every
76
+ // failure points at the same page — one anchor cannot answer three different causes.
77
+ const footer = failures.every((f) => f.error.footer === first.footer) ? first.footer : undefined;
78
+ if (alreadyPrinted) {
79
+ // ⚠️ The `!` lines above already carry every ref and every message, so restating
80
+ // them here printed the identical sentence TWICE for one failure and 2N times for
81
+ // N — on the exact output a stuck user or a CI log reads. Returning the first
82
+ // failure verbatim is what preserved its hint and docs footer, so those ride this
83
+ // error instead; only the already-printed message is replaced.
84
+ return new CliError(failures.length === 1
85
+ ? "1 item failed (see above)."
86
+ : `${failures.length} items failed (see above).`, first.exitCode, {
87
+ footer,
88
+ hint: failures.length === 1 ? first.hint : "pass --allow-partial to exit 0 when some items fail",
89
+ });
90
+ }
91
+ // --json: the per-item lines were never printed (stdout must stay parseable), so
92
+ // "see above" would point at nothing and the only human-readable cause on stderr
93
+ // has to ride the thrown error.
94
+ if (failures.length === 1)
95
+ return first; // verbatim: same message, hint and footer
96
+ return new CliError(`${failures.length} items failed:\n` +
97
+ failures.map((f) => ` ${f.ref}: ${f.error.message}`).join("\n"), first.exitCode, { footer, hint: "pass --allow-partial to exit 0 when some items fail" });
98
+ }
99
+ /** A per-item error as a CliError (an unexpected throw is still a real failure). */
100
+ function asCliError(err, exitCode) {
101
+ return err instanceof CliError ? err : new CliError(String(err), exitCode);
21
102
  }
22
103
  /** Collect the receipts a lifecycle command operates on. */
23
104
  function collect(ctx, flags, refArg) {
@@ -52,39 +133,96 @@ export async function runUninstall(ctx, positionals, flags) {
52
133
  throw usageError("pass a skill ref, --all, or --dir <path>", "usage: promptdock uninstall <handle>/<slug>");
53
134
  }
54
135
  const c = colors(ctx.env, ctx.io.isTTY);
136
+ const json = flags.json === true;
137
+ const allowPartial = flags["allow-partial"] === true;
55
138
  const items = collect(ctx, flags, refArg);
139
+ const rerun = rerunCommand("uninstall", positionals, flags);
56
140
  const removed = [];
57
- for (const item of items) {
58
- if (item.read.kind === "newer_schema") {
59
- throw newerSchemaError(item.dir, item.read.schema, ctx.argsLine);
60
- }
61
- if (item.read.kind !== "ok") {
62
- throw new CliError(`${item.dir} has an unreadable receipt refusing to guess what to delete (remove the directory manually if you're sure)`, EXIT.INTEGRITY, { footer: "receipt_invalid" });
63
- }
64
- const receipt = item.read.receipt;
65
- // DX5 local-edit guard: human work never dies to a default.
66
- const modified = modifiedFiles(item.dir, receipt).filter((p) => existsSync(join(item.dir, p)));
67
- if (modified.length > 0 && flags.force !== true) {
68
- throw new CliError(`local changes in ${modified.length} file(s) under ${item.dir} (${modified.slice(0, 3).join(", ")}${modified.length > 3 ? ", …" : ""}) — copy your changes out first, then re-run with --force`, EXIT.INTEGRITY, { footer: "local_changes" });
69
- }
70
- if (flags.yes !== true) {
71
- if (!ctx.io.isTTY)
72
- throw usageError("confirmation needed in a non-interactive session", "re-run with -y");
73
- const go = await confirm(ctx.io, `Uninstall ${receipt.ref} from ${item.dir}?`, false, c);
74
- if (!go) {
75
- ctx.io.out("Skipped.");
76
- continue;
141
+ const results = [];
142
+ const failures = [];
143
+ try {
144
+ for (const item of items) {
145
+ // The ref is only knowable from a READABLE receipt; fall back to the directory so
146
+ // an unreadable one is still named in the report rather than appearing as "".
147
+ // ⚠️ A DIFFERENT TRUST CLASS from the server strings elsewhere in this change:
148
+ // `receipt.ref` is read back off `.promptdock.json` on disk, which anyone can
149
+ // edit, and `readReceipt` type-checks almost nothing. Untrusted all the same.
150
+ // `item.dir` is a path this CLI computed itself, so it needs nothing.
151
+ const label = item.read.kind === "ok"
152
+ ? terminalLine(item.read.receipt.ref, SERVER_LABEL_MAX)
153
+ : item.dir;
154
+ try {
155
+ if (item.read.kind === "newer_schema") {
156
+ throw newerSchemaError(item.dir, item.read.schema, rerun);
157
+ }
158
+ if (item.read.kind !== "ok") {
159
+ throw new CliError(`${item.dir} has an unreadable receipt — refusing to guess what to delete (remove the directory manually if you're sure)`, EXIT.INTEGRITY, { footer: "receipt_invalid" });
160
+ }
161
+ const receipt = item.read.receipt;
162
+ // DX5 local-edit guard: human work never dies to a default.
163
+ const modified = modifiedFiles(item.dir, receipt).filter((p) => existsSync(join(item.dir, p)));
164
+ if (modified.length > 0 && flags.force !== true) {
165
+ throw new CliError(`local changes in ${modified.length} file(s) under ${item.dir} (${modified.slice(0, 3).join(", ")}${modified.length > 3 ? ", …" : ""}) — copy your changes out first, then re-run with --force`, EXIT.INTEGRITY, { footer: "local_changes" });
166
+ }
167
+ if (flags.yes !== true) {
168
+ if (!ctx.io.isTTY)
169
+ throw usageError("confirmation needed in a non-interactive session", "re-run with -y");
170
+ // ⚠️ A CONFIRM PROMPT BECOMES THE PICKER'S TITLE, and ui.ts paints that title
171
+ // INSIDE an in-place repaint frame that issues cursor-up and erase-line against
172
+ // a counted number of painted rows. Both halves here are untrusted: `ref` is
173
+ // read off an on-disk receipt anyone can edit, and `dir` is a readdirSync name.
174
+ const go = await confirm(ctx.io, `Uninstall ${terminalLine(receipt.ref, SERVER_LABEL_MAX)} from ${terminalLine(item.dir, SERVER_PATH_MAX)}?`, false, c);
175
+ if (!go) {
176
+ // A human saying no is the command working, not failing: reported, exit 0.
177
+ if (!json)
178
+ ctx.io.out("Skipped.");
179
+ results.push({ ref: receipt.ref, dir: item.dir, status: "skipped", reason: "declined" });
180
+ continue;
181
+ }
182
+ }
183
+ removeReceiptOwned(item.dir, receipt);
184
+ removed.push({ ref: receipt.ref, dir: item.dir });
185
+ results.push({ ref: receipt.ref, dir: item.dir, status: "uninstalled" });
186
+ if (!json)
187
+ ctx.io.out(`${c.green("✓")} Uninstalled ${terminalLine(receipt.ref, SERVER_LABEL_MAX)} (${terminalLine(item.dir, SERVER_PATH_MAX)})`);
188
+ }
189
+ catch (err) {
190
+ if (isFatalForBatch(err)) {
191
+ // The batch stops here — no later item could recover either, and an interrupt
192
+ // is a verdict on the RUN. The row is still recorded FIRST so the --json
193
+ // report names the cause instead of handing the caller an empty `results`.
194
+ const fatal = asCliError(err, EXIT.INTEGRITY);
195
+ results.push({ ref: label, dir: item.dir, status: "skipped", reason: fatal.message, failed: true, exit: fatal.exitCode });
196
+ throw fatal;
197
+ }
198
+ const error = asCliError(err, EXIT.INTEGRITY);
199
+ // NOT a batch: throw exactly as before, so a one-skill uninstall keeps its
200
+ // message, hint and docs footer instead of being summarised at the end.
201
+ // Under --json it is recorded and rethrown after the report, so stdout stays
202
+ // parseable on every path.
203
+ if (items.length === 1 && !allowPartial && !json)
204
+ throw error;
205
+ failures.push({ ref: label, error });
206
+ results.push({ ref: label, dir: item.dir, status: "skipped", reason: error.message, failed: true, exit: error.exitCode });
207
+ if (!json)
208
+ ctx.io.err(`${c.yellow("!")} ${label}: ${error.message}`);
77
209
  }
78
210
  }
79
- removeReceiptOwned(item.dir, receipt);
80
- removed.push({ ref: receipt.ref, dir: item.dir });
81
- if (flags.json !== true)
82
- ctx.io.out(`${c.green("✓")} Uninstalled ${receipt.ref} (${item.dir})`);
83
211
  }
84
- if (flags.json === true)
85
- ctx.io.out(JSON.stringify({ uninstalled: removed }));
86
- else if (removed.length === 0)
212
+ finally {
213
+ // ⚠️ THE ONLY emission, and it has to sit in a `finally`: a fatal-for-batch
214
+ // error (a non-TTY confirm, a Ctrl-C) escapes the loop, so a post-loop
215
+ // `if (json)` line is skipped on exactly the paths where a caller most needs
216
+ // to know what already happened to their working tree. stdout stays parseable
217
+ // on every path, which is the entire reason a caller asked for JSON.
218
+ if (json)
219
+ ctx.io.out(JSON.stringify({ uninstalled: removed, results }));
220
+ }
221
+ if (!json && removed.length === 0 && failures.length === 0)
87
222
  ctx.io.out("Nothing uninstalled.");
223
+ const failed = batchExitError(failures, allowPartial, !json);
224
+ if (failed)
225
+ throw failed;
88
226
  }
89
227
  /**
90
228
  * Remove ONLY what the receipt lists (+ the receipt), then prune emptied dirs
@@ -143,115 +281,182 @@ function removeReceiptOwned(dir, receipt) {
143
281
  *
144
282
  * USAGE means the invocation itself is wrong (a confirmation needed in a non-interactive
145
283
  * session), so every subsequent item would fail identically. AUTH means the token is gone.
284
+ * INTERRUPT means the human pressed Ctrl-C: that is a gesture about the RUN, not about
285
+ * this item — and raw mode swallows SIGINT (it arrives as a keypress, never as a signal),
286
+ * so this loop is the ONLY thing that can honour it. Without it, `uninstall --all` caught
287
+ * the interrupt as an ordinary item failure and asked the user to press Ctrl-C once per
288
+ * remaining skill, then reported the abort as N failures — and under --allow-partial as
289
+ * exit 0. This is the single gate both commands' catch sites route through, so any prompt
290
+ * that later moves inside a try inherits the fix.
291
+ *
146
292
  * Everything else — an integrity refusal, an over-fuse package, a network blip on one
147
293
  * download — is this item's problem and the next item deserves its turn.
148
294
  */
149
295
  function isFatalForBatch(err) {
150
- return err instanceof CliError && (err.exitCode === EXIT.USAGE || err.exitCode === EXIT.AUTH);
296
+ return (err instanceof CliError &&
297
+ (err.exitCode === EXIT.USAGE ||
298
+ err.exitCode === EXIT.AUTH ||
299
+ err.exitCode === EXIT.INTERRUPT));
151
300
  }
152
301
  export async function runUpdate(ctx, positionals, flags) {
153
302
  const c = colors(ctx.env, ctx.io.isTTY);
154
303
  const checkOnly = flags.check === true;
155
304
  const json = flags.json === true;
305
+ const allowPartial = flags["allow-partial"] === true;
156
306
  const items = collect(ctx, flags, positionals[0]);
157
307
  if (items.length === 0) {
158
- ctx.io.out(json ? JSON.stringify({ updated: [], checked: [] }) : "No promptdock skills installed in this scope.");
308
+ // ⚠️ THE SAME SHAPE AS EVERY OTHER RUN. This branch used to print
309
+ // {updated: [], checked: []} — two keys the non-empty branch never emits — so the
310
+ // one case a CI script hits before anything is installed was the one case its
311
+ // parser could not read. Neither key was ever populated, so nothing can have
312
+ // depended on their contents.
313
+ ctx.io.out(json ? JSON.stringify({ results: [] }) : "No promptdock skills installed in this scope.");
159
314
  return;
160
315
  }
161
316
  const { api } = await ensureAuth(ctx);
317
+ const rerun = rerunCommand("update", positionals, flags);
162
318
  const report = [];
163
- for (const item of items) {
164
- if (item.read.kind === "newer_schema") {
165
- throw newerSchemaError(item.dir, item.read.schema, ctx.argsLine);
166
- }
167
- if (item.read.kind !== "ok")
168
- continue;
169
- const receipt = item.read.receipt;
170
- let resolved;
171
- try {
172
- resolved = await api.request("GET", `/api/v1/cli/skills/resolve?ref=${encodeURIComponent(receipt.ref)}`);
173
- assertInstallable(resolved, receipt.ref, ctx.version);
174
- }
175
- catch (err) {
176
- const reason = err instanceof CliError ? err.message : String(err);
177
- report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: null, status: "skipped", reason });
178
- if (!json)
179
- ctx.io.out(`${c.yellow("!")} ${receipt.ref}: ${reason}`);
180
- continue;
181
- }
182
- if (resolved.version_id === receipt.version_id) {
183
- report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: receipt.version, status: "up_to_date" });
184
- if (!json)
185
- ctx.io.out(`${c.dim("·")} ${receipt.ref} is up to date (v${receipt.version})`);
186
- continue;
187
- }
188
- const toVersion = Number(resolved.version) || 0;
189
- if (checkOnly) {
190
- report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "available" });
191
- if (!json)
192
- ctx.io.out(`${c.cyan("")} ${receipt.ref}: v${receipt.version} → v${toVersion} available`);
193
- continue;
194
- }
195
- // DX5 local-edit guard BEFORE any overwrite.
196
- const modified = modifiedFiles(item.dir, receipt);
197
- if (modified.length > 0 && flags.force !== true) {
198
- const reason = `local changes in ${modified.length} file(s)--force to overwrite`;
199
- report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "skipped", reason });
200
- if (!json)
201
- ctx.io.out(`${c.yellow("!")} ${receipt.ref}: ${reason}`);
202
- continue;
203
- }
204
- if (flags.yes !== true) {
205
- if (!ctx.io.isTTY)
206
- throw usageError("confirmation needed in a non-interactive session", "re-run with -y (or use --check)");
207
- const go = await confirm(ctx.io, `Update ${receipt.ref} v${receipt.version} → v${toVersion}?`, true, c);
208
- if (!go) {
209
- report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "skipped", reason: "declined" });
319
+ const failures = [];
320
+ /**
321
+ * Record a fatal-for-batch error and hand it back to be thrown.
322
+ *
323
+ * The batch stops here — no later item could recover either — but the row is
324
+ * recorded FIRST so the --json report names the cause instead of handing the caller
325
+ * an empty `results` after items 1..N-1 have already rewritten their directories.
326
+ * Returned rather than thrown so the direct `throw` at the non-TTY confirm below
327
+ * (which is outside `fail()` and outside every try) reuses the exact same path.
328
+ */
329
+ const recordFatal = (ref, dir, from, to, err) => {
330
+ const fatal = asCliError(err, EXIT.AUTH);
331
+ report.push({ ref, dir, from, to, status: "skipped", reason: fatal.message, failed: true, exit: fatal.exitCode });
332
+ return fatal;
333
+ };
334
+ /** Record a per-item failure; rethrow when this is not a batch at all. */
335
+ const fail = (ref, dir, from, to, err) => {
336
+ if (isFatalForBatch(err))
337
+ throw recordFatal(ref, dir, from, to, err);
338
+ const error = asCliError(err, EXIT.DENIED);
339
+ // NOT a batch: throw straight away so a one-skill update keeps the message, hint
340
+ // and docs footer it has always had. Under --json it is recorded instead and
341
+ // rethrown after the report stdout must stay parseable on every path, which is
342
+ // the entire reason a caller asked for JSON.
343
+ if (items.length === 1 && !allowPartial && !json)
344
+ throw error;
345
+ failures.push({ ref, error });
346
+ report.push({ ref, dir, from, to, status: "skipped", reason: error.message, failed: true, exit: error.exitCode });
347
+ if (!json)
348
+ ctx.io.err(`${c.yellow("!")} ${ref}: ${error.message}`);
349
+ };
350
+ try {
351
+ for (const item of items) {
352
+ if (item.read.kind !== "ok") {
353
+ // Previously: `throw` on a newer schema (killing the batch) and a SILENT
354
+ // `continue` on an unreadable one. Both are now this item's failure an
355
+ // update that could not read its own receipt updated nothing.
356
+ const err = item.read.kind === "newer_schema"
357
+ ? newerSchemaError(item.dir, item.read.schema, rerun)
358
+ : new CliError(`${item.dir} has an unreadable receipt — reinstall the skill to repair it`, EXIT.INTEGRITY, { footer: "receipt_invalid" });
359
+ fail(item.dir, item.dir, 0, null, err);
210
360
  continue;
211
361
  }
362
+ const receipt = item.read.receipt;
363
+ let resolved;
364
+ try {
365
+ resolved = await api.request("GET", `/api/v1/cli/skills/resolve?ref=${encodeURIComponent(receipt.ref)}`);
366
+ assertInstallable(resolved, receipt.ref, ctx.version);
367
+ }
368
+ catch (err) {
369
+ fail(receipt.ref, item.dir, receipt.version, null, err);
370
+ continue;
371
+ }
372
+ if (resolved.version_id === receipt.version_id) {
373
+ report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: receipt.version, status: "up_to_date" });
374
+ if (!json)
375
+ ctx.io.out(`${c.dim("·")} ${receipt.ref} is up to date (v${receipt.version})`);
376
+ continue;
377
+ }
378
+ const toVersion = Number(resolved.version) || 0;
379
+ if (checkOnly) {
380
+ report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "available" });
381
+ if (!json)
382
+ ctx.io.out(`${c.cyan("↑")} ${receipt.ref}: v${receipt.version} → v${toVersion} available`);
383
+ continue;
384
+ }
385
+ // DX5 local-edit guard BEFORE any overwrite. A block is a FAILURE, not a
386
+ // shrug: the skill stays behind and someone has to decide what to do about it.
387
+ const modified = modifiedFiles(item.dir, receipt);
388
+ if (modified.length > 0 && flags.force !== true) {
389
+ fail(receipt.ref, item.dir, receipt.version, toVersion, new CliError(`local changes in ${modified.length} file(s) — copy your changes out first, then re-run with --force`, EXIT.INTEGRITY, { footer: "local_changes" }));
390
+ continue;
391
+ }
392
+ if (flags.yes !== true) {
393
+ if (!ctx.io.isTTY) {
394
+ throw recordFatal(receipt.ref, item.dir, receipt.version, toVersion, usageError("confirmation needed in a non-interactive session", "re-run with -y (or use --check)"));
395
+ }
396
+ const go = await confirm(ctx.io,
397
+ // Same picker-frame sink as the uninstall confirm above.
398
+ `Update ${terminalLine(receipt.ref, SERVER_LABEL_MAX)} v${terminalLine(String(receipt.version), SERVER_LABEL_MAX)} → v${terminalLine(String(toVersion), SERVER_LABEL_MAX)}?`, true, c);
399
+ if (!go) {
400
+ report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "skipped", reason: "declined" });
401
+ continue;
402
+ }
403
+ }
404
+ if (typeof resolved.skill_id !== "string" || typeof resolved.version_id !== "string") {
405
+ // A 200 that cannot be acted on. Silently continuing here is how "updated
406
+ // nothing, exited 0" used to look from the outside.
407
+ fail(receipt.ref, item.dir, receipt.version, toVersion, new CliError("malformed resolve response — update the CLI and retry", EXIT.DENIED, {
408
+ footer: "verdict",
409
+ }));
410
+ continue;
411
+ }
412
+ /**
413
+ * ⚠️ THE INSTALL MUST BE INSIDE THE PER-ITEM CATCH TOO, or "one bad package skips and
414
+ * the rest continue" is false for the population that matters.
415
+ *
416
+ * The catch above wraps only resolve + `assertInstallable` — and `assertInstallable`
417
+ * is exactly what OLDER CLIs do not have. On those, the only thing that refuses an
418
+ * over-fuse package is `assertSafeManifest`, deep inside `performInstall`, i.e. out
419
+ * here beyond the guard. So the CliError propagated out of the loop and killed the
420
+ * whole run: one oversized skill in a directory of twenty aborted the other nineteen.
421
+ * That is the population the package-scoped `min_cli_version` design exists to serve,
422
+ * so the guarantee was false precisely where it was load-bearing.
423
+ *
424
+ * Genuinely fatal classes still propagate — a usage error or a newer-schema receipt
425
+ * is not something the next item can recover from either.
426
+ */
427
+ try {
428
+ const respRaw = await api.request("POST", `/api/v1/cli/skills/${resolved.skill_id}/install`, { version_id: resolved.version_id });
429
+ const resp = checkInstallResponse(respRaw);
430
+ await performInstall(ctx, api, {
431
+ resp,
432
+ targetDir: item.dir,
433
+ targetId: item.targetId,
434
+ refString: receipt.ref,
435
+ prior: receipt,
436
+ });
437
+ report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: resp.skill.version, status: "updated" });
438
+ if (!json)
439
+ ctx.io.out(`${c.green("✓")} ${receipt.ref}: v${receipt.version} → v${resp.skill.version}`);
440
+ }
441
+ catch (err) {
442
+ fail(receipt.ref, item.dir, receipt.version, toVersion, err);
443
+ }
212
444
  }
213
- if (typeof resolved.skill_id !== "string" || typeof resolved.version_id !== "string")
214
- continue;
215
- /**
216
- * ⚠️ THE INSTALL MUST BE INSIDE THE PER-ITEM CATCH TOO, or "one bad package skips and
217
- * the rest continue" is false for the population that matters.
218
- *
219
- * The catch above wraps only resolve + `assertInstallable` — and `assertInstallable`
220
- * is exactly what OLDER CLIs do not have. On those, the only thing that refuses an
221
- * over-fuse package is `assertSafeManifest`, deep inside `performInstall`, i.e. out
222
- * here beyond the guard. So the CliError propagated out of the loop and killed the
223
- * whole run: one oversized skill in a directory of twenty aborted the other nineteen.
224
- * That is the population the package-scoped `min_cli_version` design exists to serve,
225
- * so the guarantee was false precisely where it was load-bearing.
226
- *
227
- * Genuinely fatal classes still propagate — a usage error or a newer-schema receipt
228
- * is not something the next item can recover from either.
229
- */
230
- try {
231
- const respRaw = await api.request("POST", `/api/v1/cli/skills/${resolved.skill_id}/install`, { version_id: resolved.version_id });
232
- const resp = checkInstallResponse(respRaw);
233
- await performInstall(ctx, api, {
234
- resp,
235
- targetDir: item.dir,
236
- targetId: item.targetId,
237
- refString: receipt.ref,
238
- prior: receipt,
239
- });
240
- report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: resp.skill.version, status: "updated" });
241
- if (!json)
242
- ctx.io.out(`${c.green("✓")} ${receipt.ref}: v${receipt.version} → v${resp.skill.version}`);
243
- }
244
- catch (err) {
245
- if (isFatalForBatch(err))
246
- throw err;
247
- const reason = err instanceof CliError ? err.message : String(err);
248
- report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "skipped", reason });
249
- if (!json)
250
- ctx.io.out(`${c.yellow("!")} ${receipt.ref}: ${reason}`);
251
- }
252
445
  }
253
- if (json)
254
- ctx.io.out(JSON.stringify({ results: report }));
446
+ finally {
447
+ // ⚠️ THE ONLY emission, and it has to sit in a `finally`: a fatal-for-batch
448
+ // error escapes the loop (a revoked token 401ing on item N, the non-TTY
449
+ // confirm above), so a post-loop `if (json)` line is skipped on exactly the
450
+ // paths where a caller most needs the report — items 1..N-1 have already
451
+ // rewritten their directories. An empty stdout tells a pipeline nothing.
452
+ if (json)
453
+ ctx.io.out(JSON.stringify({ results: report }));
454
+ }
455
+ // Last, so the report is always printed first: a failing exit must never cost the
456
+ // caller the list of what DID succeed.
457
+ const failed = batchExitError(failures, allowPartial, !json);
458
+ if (failed)
459
+ throw failed;
255
460
  }
256
461
  /* ── list ───────────────────────────────────────────────────────────────────── */
257
462
  export async function runList(ctx, flags) {
@@ -284,10 +489,14 @@ export async function runList(ctx, flags) {
284
489
  if (i.read.kind === "ok") {
285
490
  const r = i.read.receipt;
286
491
  const when = typeof r.installed_at === "string" ? r.installed_at.slice(0, 10) : "?";
287
- ctx.io.out(`${r.ref} v${r.version} ${i.targetId} (${i.scope}) ${when} ${i.dir}`);
492
+ // The two-space gaps are column separators, so the FRAGMENTS are sanitized and
493
+ // the composed line is not — `terminalLine` would collapse them to one space.
494
+ const ref = terminalLine(r.ref, SERVER_LABEL_MAX);
495
+ const version = terminalLine(String(r.version), SERVER_LABEL_MAX);
496
+ ctx.io.out(`${ref} v${version} ${i.targetId} (${i.scope}) ${terminalLine(when, SERVER_LABEL_MAX)} ${terminalLine(i.dir, SERVER_PATH_MAX)}`);
288
497
  }
289
498
  else {
290
- ctx.io.out(`? ${i.dir} (${i.read.kind === "newer_schema" ? "newer receipt schema — run npx promptdock@latest" : "unreadable receipt"})`);
499
+ ctx.io.out(`? ${terminalLine(i.dir, SERVER_PATH_MAX)} (${i.read.kind === "newer_schema" ? "newer receipt schema — run npx promptdock@latest" : "unreadable receipt"})`);
291
500
  }
292
501
  }
293
502
  }
@@ -3,6 +3,7 @@
3
3
  // no keyboard — CI users should prefer PROMPTDOCK_TOKEN, but an explicit
4
4
  // invocation shouldn't refuse); the browser only auto-opens on a TTY.
5
5
  import { Api } from "../api.js";
6
+ import { SERVER_LABEL_MAX, terminalLine } from "../terminal-text.js";
6
7
  import { deviceFlowLogin } from "../auth.js";
7
8
  import { loadConfig, resolveApiBase, saveConfig } from "../config.js";
8
9
  import { usageError } from "../errors.js";
@@ -25,8 +26,11 @@ export async function runLogin(ctx, flags) {
25
26
  const api = new Api(ctx, baseUrl, token);
26
27
  const who = await api.request("GET", "/api/v1/cli/whoami");
27
28
  saveConfig(ctx.home, { ...config, token, api_base: baseUrl }, ctx.platform);
28
- const name = who.handle ? `@${who.handle}` : "your account";
29
- ctx.io.out(`${c.green("✓")} Logged in as ${name}${who.role ? ` (${who.role})` : ""}.`);
29
+ // Server-chosen strings on the line a user trusts most — the one that says the
30
+ // login worked. Sanitized for display; the stored token is unaffected.
31
+ const name = who.handle ? `@${terminalLine(who.handle, SERVER_LABEL_MAX)}` : "your account";
32
+ const role = terminalLine(who.role, SERVER_LABEL_MAX);
33
+ ctx.io.out(`${c.green("✓")} Logged in as ${name}${role ? ` (${role})` : ""}.`);
30
34
  if (ctx.env.PROMPTDOCK_TOKEN) {
31
35
  ctx.io.out(c.dim("note: PROMPTDOCK_TOKEN is set and overrides the stored login."));
32
36
  }
@@ -81,7 +85,7 @@ export async function runWhoami(ctx, flags) {
81
85
  ctx.io.out(JSON.stringify(who));
82
86
  return;
83
87
  }
84
- const name = who.handle ? `@${who.handle}` : "(no handle)";
88
+ const name = who.handle ? `@${terminalLine(who.handle, SERVER_LABEL_MAX)}` : "(no handle)";
85
89
  const expiry = who.expires_at ? new Date(who.expires_at).toISOString().slice(0, 10) : "unknown";
86
- ctx.io.out(`Signed in as ${name} · role: ${who.role} · token expires: ${expiry}`);
90
+ ctx.io.out(`Signed in as ${name} · role: ${terminalLine(who.role, SERVER_LABEL_MAX)} · token expires: ${expiry}`);
87
91
  }
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const DEFAULT_API_BASE = "https://www.promptdock.ai";
1
+ export declare const DEFAULT_API_BASE: string;
2
2
  export type CliConfig = {
3
3
  token?: string;
4
4
  api_base?: string;
package/dist/config.js CHANGED
@@ -5,15 +5,21 @@
5
5
  import { chmodSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { CliError, EXIT } from "./errors.js";
8
+ import { CANONICAL_ORIGIN } from "./generated/constants.js";
8
9
  // ⚠️ MUST be the host the API is actually SERVED on, not the brand apex.
9
10
  // `promptdock.ai` 301-redirects to `www.promptdock.ai`, and Node's fetch follows
10
11
  // that redirect ACROSS AN ORIGIN — which per the fetch spec (a) downgrades POST to
11
12
  // GET and drops the body, and (b) STRIPS the `Authorization` header. Both, at any
12
13
  // status code: a 308 preserves the method and still scrubs the bearer. So pointing
13
14
  // this at the apex breaks every call — login POSTs answered with 405, and every
14
- // authenticated GET arriving anonymous and answered 401. Keep this in lockstep with
15
- // SITE_URL in lib/site-url.ts; `base-url.test.ts` fails the build if they diverge.
16
- export const DEFAULT_API_BASE = "https://www.promptdock.ai";
15
+ // authenticated GET arriving anonymous and answered 401.
16
+ //
17
+ // ⚠️ DERIVED from `CANONICAL_ORIGIN` in the shared `skills-constants.json`, which
18
+ // `lib/site-url.ts` SITE_URL reads too — so the CLI and the app can no longer disagree
19
+ // about which host the product is served on. Until 2026-09-19 this was one of ~15
20
+ // independent literals and three of them had already drifted to the apex.
21
+ // `base-url.test.ts` pins both ends across the build boundary.
22
+ export const DEFAULT_API_BASE = CANONICAL_ORIGIN;
17
23
  /**
18
24
  * Origins a PREVIOUS release persisted into ~/.promptdock/config.json and which are
19
25
  * known-broken. A successful login writes `api_base`, and a stored value outranks
package/dist/context.d.ts CHANGED
@@ -59,6 +59,29 @@ export type CliContext = {
59
59
  };
60
60
  /** Read the package's own version (dist/ and src/ both sit one level under the root). */
61
61
  export declare function readOwnVersion(): string;
62
- /** Best-effort platform browser open (darwin `open`, win32 `start`, else xdg-open). */
62
+ /**
63
+ * The ONLY shape allowed to reach a platform opener, or null to refuse.
64
+ *
65
+ * ⚠️ THE URL IS UNTRUSTED. `verification_url` comes from the UNAUTHENTICATED
66
+ * `/api/v1/cli/auth/start` response through `Api.request`, which is `res.body as T` — a
67
+ * bare cast, as contract.ts says in its own header. The OS opener is a far more
68
+ * dangerous sink than the terminal: it is the one path in this CLI with an effect
69
+ * outside the process.
70
+ *
71
+ * Measured on darwin: `open` treats a non-URL argument as a FILE PATH (so a value like
72
+ * `/Applications/Calculator.app` launches it), reads a leading `-` as a FLAG, and
73
+ * dispatches any scheme to its registered handler — which is how `file:`, `smb:` and
74
+ * `ms-msdt:` become interesting.
75
+ */
76
+ export declare function openableUrl(raw: string): string | null;
77
+ /**
78
+ * The `[command, args]` a platform open would spawn, or null when it is refused.
79
+ *
80
+ * Split out from the spawn PURELY so the decision is testable: the whole matrix can be
81
+ * asserted with no child process, on any host OS. There was no test referencing
82
+ * `openUrlBestEffort` at all, which is how the win32 defect below survived.
83
+ */
84
+ export declare function openUrlCommand(url: string, platform: NodeJS.Platform): [string, string[]] | null;
85
+ /** Best-effort platform browser open (darwin `open`, win32 `explorer`, else xdg-open). */
63
86
  export declare function openUrlBestEffort(url: string, platform: NodeJS.Platform): void;
64
87
  export declare function realContext(argv: string[]): CliContext;