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.
- package/README.md +67 -21
- package/dist/auth.js +8 -2
- package/dist/commands/install.js +114 -41
- package/dist/commands/lifecycle.js +117 -19
- package/dist/context.d.ts +9 -0
- package/dist/context.js +56 -0
- package/dist/errors.d.ts +21 -1
- package/dist/errors.js +12 -1
- package/dist/help.d.ts +2 -0
- package/dist/help.js +25 -6
- package/dist/index.js +4 -1
- package/dist/installer.d.ts +88 -15
- package/dist/installer.js +356 -98
- package/dist/integrity.d.ts +13 -0
- package/dist/integrity.js +27 -0
- package/dist/leftovers.d.ts +32 -0
- package/dist/leftovers.js +175 -0
- package/dist/local-changes.d.ts +108 -0
- package/dist/local-changes.js +334 -0
- package/dist/receipts.d.ts +2 -1
- package/dist/receipts.js +10 -3
- package/dist/semver.d.ts +19 -0
- package/dist/semver.js +54 -0
- package/dist/terminal-text.d.ts +4 -3
- package/dist/terminal-text.js +4 -3
- package/dist/verdicts.d.ts +2 -0
- package/dist/verdicts.js +24 -9
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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({
|
|
381
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
429
|
-
const
|
|
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
|
-
|
|
510
|
+
existing: { kind: "update", receipt },
|
|
511
|
+
force: flags.force === true,
|
|
512
|
+
verb: "update",
|
|
513
|
+
title,
|
|
514
|
+
resign: postInstall,
|
|
436
515
|
});
|
|
437
|
-
report.push({
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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 ·
|
|
66
|
-
|
|
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
|
|
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":
|
package/dist/installer.d.ts
CHANGED
|
@@ -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
|
|
31
|
-
*
|
|
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,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*/
|
|
41
|
-
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
53
|
-
|
|
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;
|