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.
- package/README.md +111 -13
- package/dist/api.d.ts +44 -1
- package/dist/api.js +102 -21
- package/dist/args.js +2 -2
- package/dist/auth.js +7 -2
- package/dist/commands/install.js +47 -13
- package/dist/commands/lifecycle.d.ts +14 -0
- package/dist/commands/lifecycle.js +330 -121
- package/dist/commands/login.js +8 -4
- package/dist/config.d.ts +1 -1
- package/dist/config.js +9 -3
- package/dist/context.d.ts +24 -1
- package/dist/context.js +79 -7
- package/dist/errors.d.ts +87 -0
- package/dist/errors.js +107 -3
- package/dist/generated/constants.d.ts +3 -1
- package/dist/generated/constants.js +3 -1
- package/dist/help.js +47 -11
- package/dist/index.js +46 -7
- package/dist/installer.js +9 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +5 -1
- package/dist/terminal-text.d.ts +213 -0
- package/dist/terminal-text.js +368 -0
- package/dist/ui.js +7 -4
- package/dist/verdicts.d.ts +4 -1
- package/dist/verdicts.js +25 -9
- package/package.json +5 -4
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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
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.
|
|
15
|
-
//
|
|
16
|
-
|
|
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
|
-
/**
|
|
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;
|