promptdock 1.2.1 → 1.2.2

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.
@@ -7,6 +7,8 @@ import { ensureAuth } from "../auth.js";
7
7
  import { CliError, EXIT, mapFsError, usageError } from "../errors.js";
8
8
  import { SERVER_LABEL_MAX, SERVER_PATH_MAX, terminalLine } from "../terminal-text.js";
9
9
  import { checkInstallResponse, performInstall } from "../installer.js";
10
+ import { sweepAndReport, sweepRoots } from "../leftovers.js";
11
+ import { changeRows, changesJson, defaultPathKey, forceLines, keptLine, localChangesRefusal, receiptSideChanges, unlistedEntries, } from "../local-changes.js";
10
12
  import { formatRef, parseSkillRef } from "../ref.js";
11
13
  import { modifiedFiles, readReceipt, scanReceipts, RECEIPT_NAME, } from "../receipts.js";
12
14
  import { colors, confirm } from "../ui.js";
@@ -50,6 +52,15 @@ function shellArg(value) {
50
52
  export function newerSchemaError(dir, schema, rerun) {
51
53
  return new CliError(`${dir} was installed by a newer promptdock CLI (receipt schema ${schema}). Run: ${rerun}`, EXIT.INTEGRITY, { footer: "receipt_schema" });
52
54
  }
55
+ /** The failure half of a result row: the old `failed`/`exit` plus 1.2.2's `code`/`changes`. */
56
+ function failedRow(error) {
57
+ return {
58
+ failed: true,
59
+ exit: error.exitCode,
60
+ code: error.code,
61
+ ...(error.changes ? { changes: error.changes } : {}),
62
+ };
63
+ }
53
64
  /**
54
65
  * Turn per-item failures into the process's exit.
55
66
  *
@@ -85,7 +96,13 @@ alreadyPrinted) {
85
96
  ? "1 item failed (see above)."
86
97
  : `${failures.length} items failed (see above).`, first.exitCode, {
87
98
  footer,
88
- hint: failures.length === 1 ? first.hint : "pass --allow-partial to exit 0 when some items fail",
99
+ hint: failures.length === 1
100
+ ? first.hint
101
+ : // Every local-changes refusal carries the same two ways out (1.2.2), so a batch
102
+ // of them keeps that hint rather than losing it to the generic one.
103
+ failures.every((f) => f.error.changes) && first.hint
104
+ ? `${first.hint}\npass --allow-partial to exit 0 when some items fail`
105
+ : "pass --allow-partial to exit 0 when some items fail",
89
106
  });
90
107
  }
91
108
  // --json: the per-item lines were never printed (stdout must stay parseable), so
@@ -100,6 +117,21 @@ alreadyPrinted) {
100
117
  function asCliError(err, exitCode) {
101
118
  return err instanceof CliError ? err : new CliError(String(err), exitCode);
102
119
  }
120
+ /** The temporary-folder roots a lifecycle command sweeps before it reads a receipt. */
121
+ function rootsFor(ctx, flags) {
122
+ const dir = typeof flags.dir === "string"
123
+ ? isAbsolute(flags.dir)
124
+ ? flags.dir
125
+ : resolvePath(ctx.cwd, flags.dir)
126
+ : undefined;
127
+ return sweepRoots({
128
+ cwd: ctx.cwd,
129
+ home: ctx.home,
130
+ scope: scopeOf(flags),
131
+ targetId: typeof flags.target === "string" ? flags.target : undefined,
132
+ dir,
133
+ });
134
+ }
103
135
  /** Collect the receipts a lifecycle command operates on. */
104
136
  function collect(ctx, flags, refArg) {
105
137
  if (typeof flags.dir === "string") {
@@ -135,6 +167,7 @@ export async function runUninstall(ctx, positionals, flags) {
135
167
  const c = colors(ctx.env, ctx.io.isTTY);
136
168
  const json = flags.json === true;
137
169
  const allowPartial = flags["allow-partial"] === true;
170
+ sweepAndReport(ctx, rootsFor(ctx, flags));
138
171
  const items = collect(ctx, flags, refArg);
139
172
  const rerun = rerunCommand("uninstall", positionals, flags);
140
173
  const removed = [];
@@ -159,10 +192,19 @@ export async function runUninstall(ctx, positionals, flags) {
159
192
  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
193
  }
161
194
  const receipt = item.read.receipt;
162
- // DX5 local-edit guard: human work never dies to a default.
195
+ // DX5 local-edit guard: human work never dies to a default. A missing file is
196
+ // not a change (nothing is lost by not deleting it), and line endings alone never
197
+ // count (I8).
163
198
  const modified = modifiedFiles(item.dir, receipt).filter((p) => existsSync(join(item.dir, p)));
164
199
  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" });
200
+ throw localChangesRefusal({
201
+ verb: "uninstall",
202
+ title: terminalLine(receipt.ref, SERVER_LABEL_MAX),
203
+ from: terminalLine(String(receipt.version), SERVER_LABEL_MAX),
204
+ to: null,
205
+ dir: item.dir,
206
+ changes: modified.map((path) => ({ path, reason: "modified" })),
207
+ });
166
208
  }
167
209
  if (flags.yes !== true) {
168
210
  if (!ctx.io.isTTY)
@@ -192,7 +234,7 @@ export async function runUninstall(ctx, positionals, flags) {
192
234
  // is a verdict on the RUN. The row is still recorded FIRST so the --json
193
235
  // report names the cause instead of handing the caller an empty `results`.
194
236
  const fatal = asCliError(err, EXIT.INTEGRITY);
195
- results.push({ ref: label, dir: item.dir, status: "skipped", reason: fatal.message, failed: true, exit: fatal.exitCode });
237
+ results.push({ ref: label, dir: item.dir, status: "skipped", reason: fatal.message, ...failedRow(fatal) });
196
238
  throw fatal;
197
239
  }
198
240
  const error = asCliError(err, EXIT.INTEGRITY);
@@ -203,7 +245,7 @@ export async function runUninstall(ctx, positionals, flags) {
203
245
  if (items.length === 1 && !allowPartial && !json)
204
246
  throw error;
205
247
  failures.push({ ref: label, error });
206
- results.push({ ref: label, dir: item.dir, status: "skipped", reason: error.message, failed: true, exit: error.exitCode });
248
+ results.push({ ref: label, dir: item.dir, status: "skipped", reason: error.message, ...failedRow(error) });
207
249
  if (!json)
208
250
  ctx.io.err(`${c.yellow("!")} ${label}: ${error.message}`);
209
251
  }
@@ -303,6 +345,9 @@ export async function runUpdate(ctx, positionals, flags) {
303
345
  const checkOnly = flags.check === true;
304
346
  const json = flags.json === true;
305
347
  const allowPartial = flags["allow-partial"] === true;
348
+ // `--check` is a report and writes nothing, so it leaves leftovers to the next command.
349
+ if (!checkOnly)
350
+ sweepAndReport(ctx, rootsFor(ctx, flags));
306
351
  const items = collect(ctx, flags, positionals[0]);
307
352
  if (items.length === 0) {
308
353
  // ⚠️ THE SAME SHAPE AS EVERY OTHER RUN. This branch used to print
@@ -328,7 +373,7 @@ export async function runUpdate(ctx, positionals, flags) {
328
373
  */
329
374
  const recordFatal = (ref, dir, from, to, err) => {
330
375
  const fatal = asCliError(err, EXIT.AUTH);
331
- report.push({ ref, dir, from, to, status: "skipped", reason: fatal.message, failed: true, exit: fatal.exitCode });
376
+ report.push({ ref, dir, from, to, status: "skipped", reason: fatal.message, ...failedRow(fatal) });
332
377
  return fatal;
333
378
  };
334
379
  /** Record a per-item failure; rethrow when this is not a batch at all. */
@@ -343,7 +388,7 @@ export async function runUpdate(ctx, positionals, flags) {
343
388
  if (items.length === 1 && !allowPartial && !json)
344
389
  throw error;
345
390
  failures.push({ ref, error });
346
- report.push({ ref, dir, from, to, status: "skipped", reason: error.message, failed: true, exit: error.exitCode });
391
+ report.push({ ref, dir, from, to, status: "skipped", reason: error.message, ...failedRow(error) });
347
392
  if (!json)
348
393
  ctx.io.err(`${c.yellow("!")} ${ref}: ${error.message}`);
349
394
  };
@@ -376,17 +421,41 @@ export async function runUpdate(ctx, positionals, flags) {
376
421
  continue;
377
422
  }
378
423
  const toVersion = Number(resolved.version) || 0;
424
+ const title = terminalLine(resolved.title, SERVER_LABEL_MAX) || terminalLine(receipt.ref, SERVER_LABEL_MAX);
425
+ // I1/DX-A1: the changes the receipt can see, before the confirm prompt and before
426
+ // the metered install request. Reads only, so `--check` reports them too.
427
+ const localChanges = receiptSideChanges(item.dir, receipt, unlistedEntries(item.dir, receipt, defaultPathKey(ctx.platform)));
379
428
  if (checkOnly) {
380
- report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "available" });
381
- if (!json)
429
+ report.push({
430
+ ref: receipt.ref,
431
+ dir: item.dir,
432
+ from: receipt.version,
433
+ to: toVersion,
434
+ status: "available",
435
+ ...(localChanges.length > 0 ? { changes: changesJson(localChanges) } : {}),
436
+ });
437
+ if (!json) {
382
438
  ctx.io.out(`${c.cyan("↑")} ${receipt.ref}: v${receipt.version} → v${toVersion} available`);
439
+ if (localChanges.length > 0 && flags.force !== true) {
440
+ const k = localChanges.length;
441
+ ctx.io.out(` ${k} local ${k === 1 ? "change" : "changes"} would block this update:`);
442
+ for (const row of changeRows(localChanges, String(toVersion)))
443
+ ctx.io.out(` ${row}`);
444
+ }
445
+ }
383
446
  continue;
384
447
  }
385
448
  // DX5 local-edit guard BEFORE any overwrite. A block is a FAILURE, not a
386
449
  // 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" }));
450
+ if (localChanges.length > 0 && flags.force !== true) {
451
+ fail(receipt.ref, item.dir, receipt.version, toVersion, localChangesRefusal({
452
+ verb: "update",
453
+ title,
454
+ from: terminalLine(String(receipt.version), SERVER_LABEL_MAX),
455
+ to: terminalLine(String(toVersion), SERVER_LABEL_MAX),
456
+ dir: item.dir,
457
+ changes: localChanges,
458
+ }));
390
459
  continue;
391
460
  }
392
461
  if (flags.yes !== true) {
@@ -424,19 +493,47 @@ export async function runUpdate(ctx, positionals, flags) {
424
493
  * Genuinely fatal classes still propagate — a usage error or a newer-schema receipt
425
494
  * is not something the next item can recover from either.
426
495
  */
496
+ const skillId = resolved.skill_id;
497
+ const versionId = resolved.version_id;
498
+ // The same POST serves I9's one re-sign (`record_skill_install` is idempotent per
499
+ // member and skill, so it records nothing new).
500
+ const postInstall = async () => checkInstallResponse(await api.request("POST", `/api/v1/cli/skills/${skillId}/install`, {
501
+ version_id: versionId,
502
+ }));
427
503
  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, {
504
+ const resp = await postInstall();
505
+ const outcome = await performInstall(ctx, api, {
431
506
  resp,
432
507
  targetDir: item.dir,
433
508
  targetId: item.targetId,
434
509
  refString: receipt.ref,
435
- prior: receipt,
510
+ existing: { kind: "update", receipt },
511
+ force: flags.force === true,
512
+ verb: "update",
513
+ title,
514
+ resign: postInstall,
436
515
  });
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}`);
516
+ report.push({
517
+ ref: receipt.ref,
518
+ dir: item.dir,
519
+ from: receipt.version,
520
+ to: outcome.receipt.version,
521
+ status: "updated",
522
+ kept_files: outcome.kept,
523
+ ...(flags.force === true
524
+ ? { replaced: outcome.replaced.map(({ path, reason }) => ({ path, reason })) }
525
+ : {}),
526
+ });
527
+ for (const w of outcome.warnings)
528
+ ctx.io.err(`${c.yellow("!")} ${w}`);
529
+ if (!json) {
530
+ ctx.io.out(`${c.green("✓")} ${receipt.ref}: v${receipt.version} → v${outcome.receipt.version}`);
531
+ const kept = keptLine(outcome.kept);
532
+ if (kept)
533
+ ctx.io.out(` ${kept}`);
534
+ for (const line of forceLines(outcome.replaced, []))
535
+ ctx.io.out(` ${line}`);
536
+ }
440
537
  }
441
538
  catch (err) {
442
539
  fail(receipt.ref, item.dir, receipt.version, toVersion, err);
@@ -461,6 +558,7 @@ export async function runUpdate(ctx, positionals, flags) {
461
558
  /* ── list ───────────────────────────────────────────────────────────────────── */
462
559
  export async function runList(ctx, flags) {
463
560
  const scope = scopeOf(flags);
561
+ sweepAndReport(ctx, sweepRoots({ cwd: ctx.cwd, home: ctx.home, scope }));
464
562
  const items = scanReceipts({ cwd: ctx.cwd, home: ctx.home, scope });
465
563
  if (flags.json === true) {
466
564
  ctx.io.out(JSON.stringify(items.map((i) => ({
package/dist/context.d.ts CHANGED
@@ -56,7 +56,16 @@ export type CliContext = {
56
56
  sleep: (ms: number) => Promise<void>;
57
57
  /** best-effort browser open; NEVER throws (failure → the caller printed the URL) */
58
58
  openUrl: (url: string) => void;
59
+ /**
60
+ * Run `cleanup` if the process is interrupted (Ctrl-C, SIGTERM, SIGHUP) or exits
61
+ * before the returned release is called (I5). The install engine registers the removal
62
+ * of its temporary folder here for the length of the download, so a Ctrl-C never
63
+ * leaves a half-downloaded skill behind. OPTIONAL: test contexts omit it, and the
64
+ * engine then simply has nothing to register with.
65
+ */
66
+ trap?: (cleanup: () => void) => () => void;
59
67
  };
68
+ export declare function trapInterrupts(cleanup: () => void, err: (s: string) => void): () => void;
60
69
  /** Read the package's own version (dist/ and src/ both sit one level under the root). */
61
70
  export declare function readOwnVersion(): string;
62
71
  /**
package/dist/context.js CHANGED
@@ -78,6 +78,61 @@ function installTerminalSafety() {
78
78
  });
79
79
  }
80
80
  }
81
+ /**
82
+ * The real `trap` (I5, DX-A2). Signal listeners exist only while something is
83
+ * registered, so Ctrl-C keeps its default meaning everywhere else (a prompt in raw mode
84
+ * never sees SIGINT at all: it arrives as a keypress).
85
+ *
86
+ * ⚠️ Cleanups run SYNCHRONOUSLY: an `exit` listener cannot await, and a signal handler
87
+ * that awaited would let the process die mid-cleanup. The engine's cleanup is one
88
+ * `rmSync`, which is the point.
89
+ */
90
+ const trapped = new Set();
91
+ let trapListeners = null;
92
+ function runTrapped() {
93
+ for (const fn of [...trapped]) {
94
+ trapped.delete(fn);
95
+ try {
96
+ fn();
97
+ }
98
+ catch {
99
+ /* best-effort during teardown */
100
+ }
101
+ }
102
+ }
103
+ export function trapInterrupts(cleanup, err) {
104
+ trapped.add(cleanup);
105
+ if (!trapListeners) {
106
+ const signals = [
107
+ ["SIGINT", 130],
108
+ ["SIGTERM", 143],
109
+ ["SIGHUP", 129],
110
+ ];
111
+ trapListeners = signals.map(([sig, code]) => {
112
+ const fn = () => {
113
+ runTrapped();
114
+ err("Interrupted. Nothing was installed and the temporary files were removed.");
115
+ process.exit(code);
116
+ };
117
+ process.on(sig, fn);
118
+ return { sig, fn };
119
+ });
120
+ process.on("exit", runTrapped);
121
+ }
122
+ let released = false;
123
+ return () => {
124
+ if (released)
125
+ return;
126
+ released = true;
127
+ trapped.delete(cleanup);
128
+ if (trapped.size === 0 && trapListeners) {
129
+ for (const { sig, fn } of trapListeners)
130
+ process.off(sig, fn);
131
+ process.off("exit", runTrapped);
132
+ trapListeners = null;
133
+ }
134
+ };
135
+ }
81
136
  /** Read the package's own version (dist/ and src/ both sit one level under the root). */
82
137
  export function readOwnVersion() {
83
138
  try {
@@ -253,5 +308,6 @@ export function realContext(argv) {
253
308
  now: () => Date.now(),
254
309
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
255
310
  openUrl: (url) => openUrlBestEffort(url, process.platform),
311
+ trap: (cleanup) => trapInterrupts(cleanup, (s) => process.stderr.write(s + "\n")),
256
312
  };
257
313
  }
package/dist/errors.d.ts CHANGED
@@ -6,7 +6,9 @@ export declare const EXIT: {
6
6
  readonly AUTH: 2;
7
7
  /** server gate: tier, cap, denial, rate limit, version floor, not found */
8
8
  readonly DENIED: 3;
9
- /** sha256 mismatch, unsafe manifest path, receipt schema problems */
9
+ /** integrity or a local-safety conflict: checksum, unsafe path, receipt schema or
10
+ * local changes (an edited file, a file where the new version ships one, a special
11
+ * file) that an install or update would otherwise destroy */
10
12
  readonly INTEGRITY: 4;
11
13
  /** filesystem: permissions, disk, existing dir */
12
14
  readonly IO: 5;
@@ -49,6 +51,15 @@ export declare class CliError extends Error {
49
51
  * picker's Cancelled/Interrupted) report `"error"` and point at the index page.
50
52
  */
51
53
  readonly code: string;
54
+ /**
55
+ * The local changes a `local_changes` refusal lists (1.2.2). Rides `--json` as
56
+ * `error.changes` and a batch row's `changes`, so a script can name the files without
57
+ * parsing the human rows. Paths come from the member's own disk, never the server.
58
+ */
59
+ readonly changes?: {
60
+ path: string;
61
+ reason: string;
62
+ }[];
52
63
  /**
53
64
  * ⚠️ `message` and `hint` are passed through {@link terminalBlock} — the
54
65
  * defence-in-depth BACKSTOP for terminal-control injection, not the primary control.
@@ -72,6 +83,10 @@ export declare class CliError extends Error {
72
83
  constructor(message: string, exitCode: ExitCode, opts?: {
73
84
  footer?: string;
74
85
  hint?: string;
86
+ changes?: {
87
+ path: string;
88
+ reason: string;
89
+ }[];
75
90
  });
76
91
  }
77
92
  /**
@@ -87,6 +102,11 @@ export type CliFailure = {
87
102
  exit_code: ExitCode;
88
103
  /** always present — the anchor when there is one, else the errors index */
89
104
  doc_url: string;
105
+ /** a `local_changes` refusal only (1.2.2): each path and why it blocked */
106
+ changes?: {
107
+ path: string;
108
+ reason: string;
109
+ }[];
90
110
  };
91
111
  };
92
112
  /**
package/dist/errors.js CHANGED
@@ -9,7 +9,9 @@ export const EXIT = {
9
9
  AUTH: 2,
10
10
  /** server gate: tier, cap, denial, rate limit, version floor, not found */
11
11
  DENIED: 3,
12
- /** sha256 mismatch, unsafe manifest path, receipt schema problems */
12
+ /** integrity or a local-safety conflict: checksum, unsafe path, receipt schema or
13
+ * local changes (an edited file, a file where the new version ships one, a special
14
+ * file) that an install or update would otherwise destroy */
13
15
  INTEGRITY: 4,
14
16
  /** filesystem: permissions, disk, existing dir */
15
17
  IO: 5,
@@ -53,6 +55,12 @@ export class CliError extends Error {
53
55
  * picker's Cancelled/Interrupted) report `"error"` and point at the index page.
54
56
  */
55
57
  code;
58
+ /**
59
+ * The local changes a `local_changes` refusal lists (1.2.2). Rides `--json` as
60
+ * `error.changes` and a batch row's `changes`, so a script can name the files without
61
+ * parsing the human rows. Paths come from the member's own disk, never the server.
62
+ */
63
+ changes;
56
64
  /**
57
65
  * ⚠️ `message` and `hint` are passed through {@link terminalBlock} — the
58
66
  * defence-in-depth BACKSTOP for terminal-control injection, not the primary control.
@@ -80,6 +88,8 @@ export class CliError extends Error {
80
88
  this.footer = opts?.footer;
81
89
  this.hint = opts?.hint === undefined ? undefined : terminalBlock(opts.hint);
82
90
  this.code = opts?.footer ?? "error";
91
+ if (opts?.changes)
92
+ this.changes = opts.changes;
83
93
  }
84
94
  }
85
95
  /**
@@ -105,6 +115,7 @@ export function toFailureJson(err) {
105
115
  message: err.message,
106
116
  exit_code: err.exitCode,
107
117
  doc_url: err.footer ? footerUrl(err.footer) : CLI_ERRORS_URL,
118
+ ...(err.changes ? { changes: err.changes } : {}),
108
119
  },
109
120
  };
110
121
  }
package/dist/help.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export declare function globalHelp(version: string): string;
2
+ /** DX-A6: `upgrade` is the npm word, and people reach for it to update the CLI itself. */
3
+ export declare const UPGRADE_NOTICE = "`promptdock upgrade` updates your installed skills; it does not update the promptdock CLI. To update the CLI: npm i -g promptdock@latest (or use npx promptdock@latest).";
2
4
  export declare const COMMAND_HELP: Record<string, string>;
package/dist/help.js CHANGED
@@ -34,7 +34,7 @@ export function globalHelp(version) {
34
34
  install <ref> [options] install a skill (ref = handle/slug, @handle/slug, or a pasted URL)
35
35
  uninstall <ref|--all> remove an installed skill (receipt-driven; never touches unmanaged files)
36
36
  update [ref] [--all] update installed skills to the latest approved version
37
- upgrade alias of update
37
+ upgrade alias of update (updates skills, not the CLI: npm i -g promptdock@latest)
38
38
  list [--json] show installed skills (receipts)
39
39
  help this map
40
40
 
@@ -43,7 +43,8 @@ export function globalHelp(version) {
43
43
  --target <tool> pick the tool (${TARGETS.map((t) => t.id).join(", ")}); scope is still asked — add -g or -y to skip
44
44
  --dir <path> install into an explicit directory
45
45
  -y, --yes skip prompts (needs an unambiguous target; NEVER implies --force)
46
- --force replace a non-empty foreign directory / overwrite local edits
46
+ --force replace files you edited and files at paths the new version ships,
47
+ keeping every other file you added; or replace another skill's folder
47
48
  --dry-run resolve + print what would happen; installs nothing
48
49
  --json machine-readable output
49
50
 
@@ -62,13 +63,18 @@ ${targets}
62
63
  NO_COLOR disable colored output
63
64
 
64
65
  Exit codes
65
- 0 ok · 1 usage · 2 auth · 3 denied/gated · 4 integrity · 5 filesystem
66
- 6 network · 130 interrupted (Ctrl-C in a prompt; Esc cancels with 1)
66
+ 0 ok · 1 usage · 2 auth · 3 denied/gated · 5 filesystem · 6 network
67
+ 4 integrity or a local-safety conflict: checksum, unsafe path, receipt schema
68
+ or local changes (an edited file, a file where the new version ships one,
69
+ a special file) that an install or update would otherwise destroy
70
+ 130 interrupted (Ctrl-C; Esc in a prompt cancels with 1)
67
71
 
68
72
  Docs: ${SITE}/docs/cli
69
73
  Errors: ${SITE}/docs/cli/errors
70
74
  Support: ${SITE}${CANONICAL_CONTACT_PATH}`;
71
75
  }
76
+ /** DX-A6: `upgrade` is the npm word, and people reach for it to update the CLI itself. */
77
+ export const UPGRADE_NOTICE = "`promptdock upgrade` updates your installed skills; it does not update the promptdock CLI. To update the CLI: npm i -g promptdock@latest (or use npx promptdock@latest).";
72
78
  export const COMMAND_HELP = {
73
79
  login: `promptdock login [--token pdk_…]
74
80
 
@@ -96,7 +102,15 @@ Non-interactive sessions (CI) must pass --target or --dir plus -y, and
96
102
  authenticate via PROMPTDOCK_TOKEN. -y skips the prompts ONLY — a non-empty
97
103
  foreign directory still requires --force, and scope stays project-local
98
104
  unless -g is passed. --dry-run resolves and prints the plan without
99
- installing (and without spending any premium-unlock slot).`,
105
+ installing (and without spending any premium-unlock slot).
106
+
107
+ Installing over a copy you changed: files you added are kept. A file you
108
+ edited, or a file of yours where the new version ships one, stops the install
109
+ (exit 4) and nothing is changed. --force replaces those files and keeps every
110
+ other file you added. With no receipt in a non-empty folder (what an uninstall
111
+ leaves when you had added files), --force keeps your files that do not collide
112
+ with the new version's; another skill's folder is replaced, and the CLI lists
113
+ what it removed.`,
100
114
  uninstall: `promptdock uninstall <handle>/<slug> | --all [-g] [--target <tool>] [--dir <path>] [--force] [-y] [--allow-partial] [--json]
101
115
 
102
116
  Removes an installed skill using its .promptdock.json receipt — only files the
@@ -109,9 +123,14 @@ with that item's code at the end. --allow-partial exits 0 instead. Declining
109
123
  a confirm is not a failure.`,
110
124
  update: `promptdock update [<handle>/<slug>] [--all] [--check] [-g] [-y] [--force] [--allow-partial] [--json]
111
125
 
126
+ ${UPGRADE_NOTICE}
127
+
112
128
  Updates installed skills to the latest approved version. Bare \`update\` covers
113
129
  the project-local scope; -g the global scope; --all both. --check reports
114
- without applying. Locally-modified files block an update without --force.
130
+ without applying, including local changes that would block. Files you added
131
+ are kept; a file you edited, or a file of yours where the new version ships
132
+ one, blocks the update without --force (which replaces those files and keeps
133
+ every other file you added).
115
134
 
116
135
  An item that FAILS (unreachable, gated, blocked by local edits) no longer
117
136
  stops the batch — the rest still update — but the command exits non-zero at
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { parseArgs } from "./args.js";
5
5
  import { realContext } from "./context.js";
6
6
  import { CliError, EXIT, footerUrl, toFailureJson, wantsJsonOutput } from "./errors.js";
7
- import { COMMAND_HELP, globalHelp } from "./help.js";
7
+ import { COMMAND_HELP, globalHelp, UPGRADE_NOTICE } from "./help.js";
8
8
  import { colors } from "./ui.js";
9
9
  import { runInstall } from "./commands/install.js";
10
10
  import { runLogin, runLogout, runWhoami } from "./commands/login.js";
@@ -75,6 +75,9 @@ async function main() {
75
75
  await runUninstall(ctx, positionals, flags);
76
76
  return EXIT.OK;
77
77
  case "update":
78
+ // DX-A6: on stderr, so `upgrade --json` keeps stdout to one document.
79
+ if (argv[0] === "upgrade")
80
+ ctx.io.err(UPGRADE_NOTICE);
78
81
  await runUpdate(ctx, positionals, flags);
79
82
  return EXIT.OK;
80
83
  case "list":
@@ -1,7 +1,15 @@
1
1
  import type { Api } from "./api.js";
2
2
  import type { CliContext } from "./context.js";
3
3
  import type { InstallResponse, ManifestEntry } from "./contract.js";
4
+ import { type ChangeReason } from "./local-changes.js";
4
5
  import { type Receipt } from "./receipts.js";
6
+ /** An install's temporary folders, by kind. The number in the name is the operation's start. */
7
+ export declare const STAGING_PREFIX = ".promptdock-staging-";
8
+ export declare const OLD_PREFIX = ".promptdock-old-";
9
+ /** A previous copy kept because the new one did not verify: never removed automatically. */
10
+ export declare const KEPT_PREFIX = ".promptdock-kept-";
11
+ /** `.promptdock-<kind>-<start ms>-<random>`: the age a later command reads (ENG-A4). */
12
+ export declare function tempName(kind: "staging" | "old" | "kept", startedAt: number): string;
5
13
  /** Narrow the untrusted install response; throws INTEGRITY on a bad shape. */
6
14
  export declare function checkInstallResponse(resp: unknown): InstallResponse;
7
15
  /**
@@ -19,35 +27,100 @@ export declare function checkInstallResponse(resp: unknown): InstallResponse;
19
27
  * function is the untrusted-server backstop behind it and must never be removed.
20
28
  */
21
29
  export declare function assertSafeManifest(manifest: ManifestEntry[]): void;
30
+ /**
31
+ * Where an install's temporary folders live (DX-A2, I5): the folder ABOVE the skills
32
+ * folder, so a killed install never leaves a folder holding a SKILL.md where the agent
33
+ * scans for skills. Falls back to the skills folder itself when the folder above is
34
+ * missing, not writable, or on another filesystem (the swap must stay a rename, and a
35
+ * rename across filesystems fails with EXDEV). The skills folder must exist.
36
+ */
37
+ export declare function tempRootFor(targetDir: string): string;
22
38
  export type StagedInstall = {
23
39
  tempDir: string;
24
40
  files: {
25
41
  path: string;
26
42
  sha256: string;
27
43
  }[];
44
+ /** the response the files came from (a re-signed one after I9's refresh) */
45
+ resp: InstallResponse;
28
46
  };
29
47
  /**
30
- * Download every manifest file into `<parent>/.promptdock-staging-*` (SIBLING of
31
- * the target so the final rename never crosses filesystems), verifying sha256 +
32
- * byte length against the manifest. Throws (and discards the temp dir) on ANY
48
+ * Download every manifest file into `tempDir` (which must not exist yet), verifying
49
+ * sha256 + byte length against the manifest. Throws (and removes `tempDir`) on ANY
33
50
  * mismatch — the sha in the manifest is the transport-integrity anchor.
51
+ *
52
+ * I6: a dropped connection is exit 6 (`download`), never an "unexpected error".
53
+ * I9: signed URLs expire after a few minutes while files download one at a time, so a
54
+ * 400 or 403 from a signed URL re-signs ONCE through `resign` (the install endpoint:
55
+ * `record_skill_install` is idempotent per member and skill, so no second install is
56
+ * recorded) and retries; a second expiry is the plain "links expired" refusal.
34
57
  */
35
- export declare function downloadAndStage(ctx: CliContext, api: Api, resp: InstallResponse, targetDir: string): Promise<StagedInstall>;
36
- /**
37
- * Atomic-ish commit: fresh target → one rename; existing target → staged
38
- * replace (target → .promptdock-old-*, temp → target, drop old; on failure the
39
- * old dir is restored). Same-parent renames only.
40
- */
41
- export declare function commitStaged(tempDir: string, targetDir: string): void;
58
+ export declare function downloadAndStage(ctx: CliContext, resp0: InstallResponse, tempDir: string, opts?: {
59
+ resign?: () => Promise<InstallResponse>;
60
+ }): Promise<StagedInstall>;
61
+ /** What sits at the target before this install, which decides what may be carried. */
62
+ export type ExistingTarget =
63
+ /** nothing, or an empty folder */
64
+ {
65
+ kind: "none";
66
+ }
67
+ /** this skill's own earlier install: carry the member's files, refuse on changes */
68
+ | {
69
+ kind: "update";
70
+ receipt: Receipt;
71
+ }
72
+ /** a non-empty folder with no readable receipt (what an uninstall that kept files leaves) */
73
+ | {
74
+ kind: "unmanaged";
75
+ }
76
+ /** another skill's install: replaced under --force, as before 1.2.2 */
77
+ | {
78
+ kind: "foreign";
79
+ receipt: Receipt;
80
+ };
81
+ export type ReplacedPath = {
82
+ path: string;
83
+ reason: ChangeReason;
84
+ shipped: boolean;
85
+ };
86
+ export type InstallOutcome = {
87
+ receipt: Receipt;
88
+ /** member files and links carried into the new copy */
89
+ kept: string[];
90
+ /** what --force replaced or removed in this skill's own folder */
91
+ replaced: ReplacedPath[];
92
+ /** what --force removed from another skill's folder */
93
+ removed: string[];
94
+ /** non-fatal notices for stderr (a previous copy that could not be removed, …) */
95
+ warnings: string[];
96
+ };
42
97
  /**
43
- * The full phase-2: download → verify → commit → receipt → best-effort complete.
44
- * `refString` is the canonical handle/slug; `prior` carries a matching update's
45
- * receipt so unknown fields survive the rewrite (DX F16).
98
+ * The full phase 2: refuse on manifest conflicts → download → verify → check again →
99
+ * carry → receipt into the new copy → swap → verify → drop the old copy → best-effort
100
+ * complete. `refString` is the canonical handle/slug.
46
101
  */
47
102
  export declare function performInstall(ctx: CliContext, api: Api, opts: {
48
103
  resp: InstallResponse;
49
104
  targetDir: string;
50
105
  targetId: string;
51
106
  refString: string;
52
- prior?: Receipt;
53
- }): Promise<Receipt>;
107
+ existing: ExistingTarget;
108
+ force: boolean;
109
+ /** for a refusal's first line */
110
+ verb: "install" | "update";
111
+ /** terminal-safe already */
112
+ title: string;
113
+ /** I9's one re-sign (the install POST again); omitted, an expired link fails as before */
114
+ resign?: () => Promise<InstallResponse>;
115
+ /** called once the manifest has passed the conflict check, just before the download */
116
+ onDownload?: (resp: InstallResponse) => void;
117
+ }): Promise<InstallOutcome>;
118
+ /**
119
+ * The swap. A fresh target is one rename. An existing target goes aside as
120
+ * `.promptdock-old-*` under `root`, beside a sidecar naming the target so a later
121
+ * command can put it back after a crash (ENG-A4); the staged copy is renamed in, and
122
+ * the old copy is returned for the caller to verify against and then delete (ENG-A36).
123
+ * A failed second rename renames the old copy back: the machine must never end up with
124
+ * NOTHING.
125
+ */
126
+ export declare function commitStaged(tempDir: string, targetDir: string, root: string, startedAt: number): string | null;