promptdock 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,7 +128,27 @@ path is re-validated before any write; a failed check aborts **all-or-nothing**
128
128
  | 6 | network |
129
129
  | 130 | interrupted (`Ctrl-C` during an interactive prompt; `Esc` — a deliberate in-UI cancel — exits 1) |
130
130
 
131
- Every named error links `https://promptdock.ai/docs/cli/errors#<code>`.
131
+ Every named error links `https://promptdock.ai/docs/cli/errors#<code>`. That page
132
+ lists every code with what happened, why, and what to do.
133
+
134
+ ## Package compatibility
135
+
136
+ This CLI carries its own **install-safety limits** — how many files it will write and how
137
+ much it will download for one skill. They exist so a compromised server cannot fill your
138
+ disk or write outside the target directory, and they are deliberately far above anything
139
+ the platform accepts, so a normal skill never comes near them.
140
+
141
+ Occasionally a skill is published that is larger than the version of the CLI you have.
142
+ When that happens you get, for **that skill only**:
143
+
144
+ ```
145
+ "Big Skill" needs promptdock CLI 1.2.0 or newer — you are on 1.0.1.
146
+ Update: npx promptdock@latest install …
147
+ ```
148
+
149
+ It exits `3` (denied), not `4` (integrity) — the package is fine, your CLI is just older
150
+ than it. Your login, your other installs and `promptdock update` are unaffected;
151
+ `promptdock update` skips that one skill and continues with the rest.
132
152
 
133
153
  ## Notes
134
154
 
@@ -27,7 +27,7 @@ export async function runInstall(ctx, positionals, flags) {
27
27
  const c = colors(ctx.env, ctx.io.isTTY);
28
28
  const { api } = await ensureAuth(ctx);
29
29
  const resolve = await api.request("GET", `/api/v1/cli/skills/resolve?ref=${encodeURIComponent(refString)}`);
30
- assertInstallable(resolve, refString);
30
+ assertInstallable(resolve, refString, ctx.version);
31
31
  if (typeof resolve.version_id !== "string" || typeof resolve.skill_id !== "string") {
32
32
  throw new CliError("malformed resolve response — update the CLI and retry", EXIT.DENIED, {
33
33
  footer: "verdict",
@@ -138,6 +138,17 @@ function removeReceiptOwned(dir, receipt) {
138
138
  }
139
139
  }
140
140
  /* ── update ─────────────────────────────────────────────────────────────────── */
141
+ /**
142
+ * Errors no later item can recover from either — these still abort the batch.
143
+ *
144
+ * USAGE means the invocation itself is wrong (a confirmation needed in a non-interactive
145
+ * session), so every subsequent item would fail identically. AUTH means the token is gone.
146
+ * Everything else — an integrity refusal, an over-fuse package, a network blip on one
147
+ * download — is this item's problem and the next item deserves its turn.
148
+ */
149
+ function isFatalForBatch(err) {
150
+ return err instanceof CliError && (err.exitCode === EXIT.USAGE || err.exitCode === EXIT.AUTH);
151
+ }
141
152
  export async function runUpdate(ctx, positionals, flags) {
142
153
  const c = colors(ctx.env, ctx.io.isTTY);
143
154
  const checkOnly = flags.check === true;
@@ -159,7 +170,7 @@ export async function runUpdate(ctx, positionals, flags) {
159
170
  let resolved;
160
171
  try {
161
172
  resolved = await api.request("GET", `/api/v1/cli/skills/resolve?ref=${encodeURIComponent(receipt.ref)}`);
162
- assertInstallable(resolved, receipt.ref);
173
+ assertInstallable(resolved, receipt.ref, ctx.version);
163
174
  }
164
175
  catch (err) {
165
176
  const reason = err instanceof CliError ? err.message : String(err);
@@ -201,18 +212,43 @@ export async function runUpdate(ctx, positionals, flags) {
201
212
  }
202
213
  if (typeof resolved.skill_id !== "string" || typeof resolved.version_id !== "string")
203
214
  continue;
204
- const respRaw = await api.request("POST", `/api/v1/cli/skills/${resolved.skill_id}/install`, { version_id: resolved.version_id });
205
- const resp = checkInstallResponse(respRaw);
206
- await performInstall(ctx, api, {
207
- resp,
208
- targetDir: item.dir,
209
- targetId: item.targetId,
210
- refString: receipt.ref,
211
- prior: receipt,
212
- });
213
- report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: resp.skill.version, status: "updated" });
214
- if (!json)
215
- ctx.io.out(`${c.green("✓")} ${receipt.ref}: v${receipt.version} → v${resp.skill.version}`);
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
+ }
216
252
  }
217
253
  if (json)
218
254
  ctx.io.out(JSON.stringify({ results: report }));
@@ -31,6 +31,13 @@ export type ResolveResponse = {
31
31
  is_free?: boolean;
32
32
  file_count?: number;
33
33
  total_bytes?: number;
34
+ /**
35
+ * The oldest CLI generation whose install-safety fuses admit this package, or
36
+ * null/absent when every published CLI can install it. Lets an old client refuse
37
+ * ONE oversized package with an upgrade prompt instead of the server bumping a
38
+ * global version floor that would 426 every request from every old client.
39
+ */
40
+ min_cli_version?: string | null;
34
41
  description?: string;
35
42
  license?: string;
36
43
  /** insufficient_tier */
@@ -1,9 +1,19 @@
1
1
  export declare const MAX_SKILL_FILES = 25;
2
2
  export declare const MAX_SKILL_TOTAL_BYTES = 5242880;
3
3
  export declare const MAX_SKILL_FILE_BYTES = 1048576;
4
+ export declare const CLI_DOS_MAX_SKILL_FILES = 200;
5
+ export declare const CLI_DOS_MAX_SKILL_TOTAL_BYTES = 52428800;
6
+ export declare const CLI_DOS_MAX_SKILL_FILE_BYTES = 5242880;
7
+ export declare const CLI_FUSE_HISTORY: {
8
+ cli_version: string;
9
+ max_files: number;
10
+ max_file_bytes: number;
11
+ max_total_bytes: number;
12
+ }[];
4
13
  export declare const MAX_SKILL_IMAGES = 5;
5
14
  export declare const SKILL_REVEAL_DAILY_CAP = 15;
6
15
  export declare const CLI_MIN_VERSION = "1.0.1";
16
+ export declare const PUBLISHED_CLI_VERSION = "1.1.0";
7
17
  export declare const SKILL_SLUG_RE_SOURCE = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$";
8
18
  export declare const SKILL_HANDLE_RE_SOURCE = "^[a-z0-9][a-z0-9_-]{0,62}$";
9
19
  export declare const CANONICAL_INSTALL_COMMAND = "npx promptdock@latest install";
@@ -3,9 +3,14 @@
3
3
  export const MAX_SKILL_FILES = 25;
4
4
  export const MAX_SKILL_TOTAL_BYTES = 5242880;
5
5
  export const MAX_SKILL_FILE_BYTES = 1048576;
6
+ export const CLI_DOS_MAX_SKILL_FILES = 200;
7
+ export const CLI_DOS_MAX_SKILL_TOTAL_BYTES = 52428800;
8
+ export const CLI_DOS_MAX_SKILL_FILE_BYTES = 5242880;
9
+ export const CLI_FUSE_HISTORY = [{ "cli_version": "0.0.0", "max_files": 25, "max_file_bytes": 1048576, "max_total_bytes": 5242880 }, { "cli_version": "1.2.0", "max_files": 200, "max_file_bytes": 5242880, "max_total_bytes": 52428800 }];
6
10
  export const MAX_SKILL_IMAGES = 5;
7
11
  export const SKILL_REVEAL_DAILY_CAP = 15;
8
12
  export const CLI_MIN_VERSION = "1.0.1";
13
+ export const PUBLISHED_CLI_VERSION = "1.1.0";
9
14
  export const SKILL_SLUG_RE_SOURCE = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$";
10
15
  export const SKILL_HANDLE_RE_SOURCE = "^[a-z0-9][a-z0-9_-]{0,62}$";
11
16
  export const CANONICAL_INSTALL_COMMAND = "npx promptdock@latest install";
@@ -5,8 +5,18 @@ import { type Receipt } from "./receipts.js";
5
5
  /** Narrow the untrusted install response; throws INTEGRITY on a bad shape. */
6
6
  export declare function checkInstallResponse(resp: unknown): InstallResponse;
7
7
  /**
8
- * E8 client-side re-validation + the shared D7 bounds. A server compromise must
9
- * not become an arbitrary file write OR a disk-filling download.
8
+ * E8 client-side re-validation against this CLI's own INSTALL-SAFETY FUSES.
9
+ *
10
+ * ⚠️ These bounds are SELF-PROTECTION, not a mirror of the platform's policy: "a
11
+ * server compromise must not become an arbitrary file write OR a disk-filling
12
+ * download." They are sized from memory/disk/runtime budgets and are deliberately far
13
+ * above any policy the platform would set, so a legitimate policy raise never needs a
14
+ * CLI release. Reaching one of them means the server sent something this CLI considers
15
+ * impossible — hence EXIT.INTEGRITY.
16
+ *
17
+ * The FRIENDLY, package-scoped refusal ("this skill needs a newer CLI") lives in
18
+ * verdicts.ts::assertInstallable and fires BEFORE the metered install POST. This
19
+ * function is the untrusted-server backstop behind it and must never be removed.
10
20
  */
11
21
  export declare function assertSafeManifest(manifest: ManifestEntry[]): void;
12
22
  export type StagedInstall = {
package/dist/installer.js CHANGED
@@ -9,7 +9,7 @@ import { randomBytes } from "node:crypto";
9
9
  import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { CliError, EXIT, mapFsError, networkError } from "./errors.js";
12
- import { MAX_SKILL_FILES, MAX_SKILL_FILE_BYTES, MAX_SKILL_TOTAL_BYTES, } from "./generated/constants.js";
12
+ import { CLI_DOS_MAX_SKILL_FILES as MAX_SKILL_FILES, CLI_DOS_MAX_SKILL_FILE_BYTES as MAX_SKILL_FILE_BYTES, CLI_DOS_MAX_SKILL_TOTAL_BYTES as MAX_SKILL_TOTAL_BYTES, } from "./generated/constants.js";
13
13
  import { sha256Hex, sha256Matches } from "./integrity.js";
14
14
  import { validateSkillPaths } from "./paths.js";
15
15
  import { writeReceipt, RECEIPT_SCHEMA } from "./receipts.js";
@@ -37,8 +37,62 @@ export function checkInstallResponse(resp) {
37
37
  return r;
38
38
  }
39
39
  /**
40
- * E8 client-side re-validation + the shared D7 bounds. A server compromise must
41
- * not become an arbitrary file write OR a disk-filling download.
40
+ * Read a response body, aborting the moment it exceeds `limit`.
41
+ *
42
+ * `res.arrayBuffer()` has no ceiling: it reads whatever the server sends. Streaming and
43
+ * cancelling means a hostile or broken server cannot make this process hold more than
44
+ * one file's worth of bytes, which is the property the install-safety fuse is claiming.
45
+ *
46
+ * Falls back to `arrayBuffer()` only when the runtime gives us no readable stream (older
47
+ * Node fetch shims) — with the post-hoc size check still applied, so the bound holds
48
+ * either way.
49
+ */
50
+ async function readBounded(res, limit, path) {
51
+ const tooBig = () => new CliError(`${INTEGRITY_ABORT} ("${path}" oversized)`, EXIT.INTEGRITY, { footer: "integrity" });
52
+ const body = res.body;
53
+ if (!body || typeof body.getReader !== "function") {
54
+ const buf = Buffer.from(await res.arrayBuffer());
55
+ if (buf.byteLength > limit)
56
+ throw tooBig();
57
+ return buf;
58
+ }
59
+ const reader = body.getReader();
60
+ const chunks = [];
61
+ let total = 0;
62
+ try {
63
+ for (;;) {
64
+ const { done, value } = await reader.read();
65
+ if (done)
66
+ break;
67
+ if (!value)
68
+ continue;
69
+ total += value.byteLength;
70
+ if (total > limit) {
71
+ // Stop pulling bytes immediately — this is the whole point.
72
+ await reader.cancel().catch(() => { });
73
+ throw tooBig();
74
+ }
75
+ chunks.push(value);
76
+ }
77
+ }
78
+ finally {
79
+ reader.releaseLock?.();
80
+ }
81
+ return Buffer.concat(chunks.map((c) => Buffer.from(c)), total);
82
+ }
83
+ /**
84
+ * E8 client-side re-validation against this CLI's own INSTALL-SAFETY FUSES.
85
+ *
86
+ * ⚠️ These bounds are SELF-PROTECTION, not a mirror of the platform's policy: "a
87
+ * server compromise must not become an arbitrary file write OR a disk-filling
88
+ * download." They are sized from memory/disk/runtime budgets and are deliberately far
89
+ * above any policy the platform would set, so a legitimate policy raise never needs a
90
+ * CLI release. Reaching one of them means the server sent something this CLI considers
91
+ * impossible — hence EXIT.INTEGRITY.
92
+ *
93
+ * The FRIENDLY, package-scoped refusal ("this skill needs a newer CLI") lives in
94
+ * verdicts.ts::assertInstallable and fires BEFORE the metered install POST. This
95
+ * function is the untrusted-server backstop behind it and must never be removed.
42
96
  */
43
97
  export function assertSafeManifest(manifest) {
44
98
  const issues = validateSkillPaths(manifest.map((e) => e.path));
@@ -79,6 +133,18 @@ export async function downloadAndStage(ctx, api, resp, targetDir) {
79
133
  }
80
134
  const tempDir = join(parent, `.promptdock-staging-${randomBytes(6).toString("hex")}`);
81
135
  const files = [];
136
+ /**
137
+ * Bytes actually RECEIVED, not bytes the server said it would send.
138
+ *
139
+ * ⚠️ `assertSafeManifest` enforces the aggregate fuse against `entry.bytes` — a field
140
+ * the untrusted server supplies and that the response check does not even require. So
141
+ * before this counter the only bound the wire really carried was PER FILE, and a
142
+ * hostile server could declare 1 KB per entry and stream the per-file maximum for every
143
+ * one of them: at the raised fuses that is 200 × 5 MB ≈ 1 GB written to disk, which is
144
+ * precisely the "disk-filling download" this module's own docstring says it prevents.
145
+ * The manifest check is a fast pre-flight; THIS is the enforcement.
146
+ */
147
+ let received = 0;
82
148
  try {
83
149
  mkdirSync(tempDir, { recursive: true });
84
150
  for (const entry of resp.manifest) {
@@ -99,11 +165,25 @@ export async function downloadAndStage(ctx, api, resp, targetDir) {
99
165
  // A lapsed signed URL is a free re-request (audit row 13) — say so.
100
166
  throw new CliError(`download failed for "${entry.path}" (HTTP ${res.status}) — re-run the install (the download link may have expired; retrying is free)`, EXIT.NETWORK, { footer: "download" });
101
167
  }
102
- const buf = Buffer.from(await res.arrayBuffer());
103
- if (buf.byteLength > MAX_SKILL_FILE_BYTES) {
104
- throw new CliError(`${INTEGRITY_ABORT} ("${entry.path}" oversized)`, EXIT.INTEGRITY, {
105
- footer: "integrity",
106
- });
168
+ // ⚠️ CHECK BEFORE BUFFERING, then check again after.
169
+ //
170
+ // The fuse exists so a compromised server cannot fill your disk, and
171
+ // `await res.arrayBuffer()` reads the ENTIRE body into memory first — so a check
172
+ // that only runs afterwards has already let the download happen. A hostile server
173
+ // could stream gigabytes and the guard would fire, too late, on a machine that had
174
+ // already paid for it.
175
+ //
176
+ // So: refuse on a declared `content-length` over the fuse without reading a byte,
177
+ // and refuse again on the real length (content-length is server-supplied and may
178
+ // be absent or lie, which is exactly why the second check stays).
179
+ const declared = Number(res.headers.get("content-length"));
180
+ if (Number.isFinite(declared) && declared > MAX_SKILL_FILE_BYTES) {
181
+ throw new CliError(`${INTEGRITY_ABORT} ("${entry.path}" declares ${declared} bytes, over the limit)`, EXIT.INTEGRITY, { footer: "integrity" });
182
+ }
183
+ const buf = await readBounded(res, MAX_SKILL_FILE_BYTES, entry.path);
184
+ received += buf.byteLength;
185
+ if (received > MAX_SKILL_TOTAL_BYTES) {
186
+ throw new CliError(`${INTEGRITY_ABORT} (the package sent more than the ${MAX_SKILL_TOTAL_BYTES}-byte install limit)`, EXIT.INTEGRITY, { footer: "integrity" });
107
187
  }
108
188
  if (!sha256Matches(buf, entry.sha256)) {
109
189
  throw new CliError(`${INTEGRITY_ABORT} (sha256 mismatch on "${entry.path}")`, EXIT.INTEGRITY, {
@@ -1,4 +1,4 @@
1
1
  import type { ResolveResponse } from "./contract.js";
2
2
  export declare const UPGRADE_URL = "https://promptdock.ai/pricing";
3
3
  /** Throws the DX3-copy CliError for a deny verdict; returns for ok/already_entitled. */
4
- export declare function assertInstallable(resolve: ResolveResponse, refString: string): void;
4
+ export declare function assertInstallable(resolve: ResolveResponse, refString: string, cliVersion: string): void;
package/dist/verdicts.js CHANGED
@@ -1,11 +1,53 @@
1
1
  import { CliError, EXIT } from "./errors.js";
2
2
  import { retryHours } from "./ui.js";
3
+ import { CANONICAL_INSTALL_COMMAND, CLI_DOS_MAX_SKILL_FILES, CLI_DOS_MAX_SKILL_TOTAL_BYTES, } from "./generated/constants.js";
3
4
  export const UPGRADE_URL = "https://promptdock.ai/pricing";
5
+ /** Numeric semver compare; mirrors compareCliVersions in lib/validation/skills.ts. */
6
+ function cmpVersion(a, b) {
7
+ const pa = a.split(".").map((n) => Number(n) || 0);
8
+ const pb = b.split(".").map((n) => Number(n) || 0);
9
+ return (pa[0] - pb[0]) || (pa[1] - pb[1]) || (pa[2] - pb[2]);
10
+ }
11
+ /**
12
+ * PACKAGE-SCOPED compatibility, checked BEFORE the metered install POST.
13
+ *
14
+ * Two reasons this lives here and not only in `assertSafeManifest`:
15
+ * 1. `POST /cli/skills/{id}/install` mints an entitlement ticket under the shared
16
+ * 15/day premium cap. Failing after it burns one of the user's daily slots on a
17
+ * provably-doomed install.
18
+ * 2. A package this CLI is too old to install is a POLICY refusal (EXIT.DENIED — the
19
+ * code whose own doc string reads "tier, cap, denial, rate limit, version floor"),
20
+ * not a supply-chain signal. `assertSafeManifest` throws EXIT.INTEGRITY because
21
+ * there it means the server sent something impossible; here it means "update me".
22
+ *
23
+ * `assertSafeManifest` stays as the untrusted-server backstop — this check makes it
24
+ * unreachable in practice, never redundant.
25
+ */
26
+ function assertPackageCompatible(resolve, cliVersion) {
27
+ const upgrade = `Update: ${CANONICAL_INSTALL_COMMAND} … (or: npm i -g promptdock@latest)`;
28
+ const title = resolve.title ? `"${resolve.title}"` : "This skill";
29
+ const min = resolve.min_cli_version;
30
+ if (typeof min === "string" && min.length > 0 && cmpVersion(cliVersion, min) < 0) {
31
+ throw new CliError(`${title} needs promptdock CLI ${min} or newer — you are on ${cliVersion}.`, EXIT.DENIED, { footer: "upgrade_required", hint: upgrade });
32
+ }
33
+ // Belt and braces for a server too old to send min_cli_version: compare the shape
34
+ // the resolve response already reports against this CLI's own fuses.
35
+ const files = Number(resolve.file_count);
36
+ if (Number.isFinite(files) && files > CLI_DOS_MAX_SKILL_FILES) {
37
+ throw new CliError(`${title} has ${files} files — this CLI installs at most ${CLI_DOS_MAX_SKILL_FILES}.`, EXIT.DENIED, { footer: "upgrade_required", hint: upgrade });
38
+ }
39
+ const total = Number(resolve.total_bytes);
40
+ if (Number.isFinite(total) && total > CLI_DOS_MAX_SKILL_TOTAL_BYTES) {
41
+ const mb = (n) => `${(n / (1024 * 1024)).toFixed(1)}MB`;
42
+ throw new CliError(`${title} is ${mb(total)} — this CLI installs at most ${mb(CLI_DOS_MAX_SKILL_TOTAL_BYTES)}.`, EXIT.DENIED, { footer: "upgrade_required", hint: upgrade });
43
+ }
44
+ }
4
45
  /** Throws the DX3-copy CliError for a deny verdict; returns for ok/already_entitled. */
5
- export function assertInstallable(resolve, refString) {
46
+ export function assertInstallable(resolve, refString, cliVersion) {
6
47
  switch (resolve.verdict) {
7
48
  case "ok":
8
49
  case "already_entitled":
50
+ assertPackageCompatible(resolve, cliVersion);
9
51
  return;
10
52
  case "not_found":
11
53
  throw new CliError(`skill not found: ${refString} — check the ref (the format is handle/slug; a pasted promptdock.ai skill URL also works)`, EXIT.DENIED, { footer: "not_found" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptdock",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Install AI agent skills from PromptDock — npx promptdock@latest install <handle>/<slug>",
5
5
  "keywords": [
6
6
  "promptdock",
@@ -42,5 +42,5 @@
42
42
  "typescript": "^5",
43
43
  "vitest": "^4.1.8"
44
44
  },
45
- "gitHead": "a2f9bd83dfba6c7f73a116687fede2e7b2f3c8cb"
45
+ "gitHead": "53545d74fc0baeec63684c96b49134e93625d63a"
46
46
  }