promptdock 1.0.1 → 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/LICENSE +21 -0
- package/README.md +58 -5
- package/dist/api.js +40 -6
- package/dist/auth.js +24 -3
- package/dist/commands/install.d.ts +7 -0
- package/dist/commands/install.js +68 -15
- package/dist/commands/lifecycle.js +51 -15
- package/dist/commands/login.js +31 -2
- package/dist/config.js +35 -2
- package/dist/context.d.ts +19 -0
- package/dist/context.js +100 -0
- package/dist/contract.d.ts +7 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +5 -0
- package/dist/generated/constants.d.ts +11 -1
- package/dist/generated/constants.js +6 -1
- package/dist/help.js +11 -7
- package/dist/installer.d.ts +12 -2
- package/dist/installer.js +88 -8
- package/dist/registry.d.ts +56 -0
- package/dist/registry.js +65 -0
- package/dist/select-keys.d.ts +64 -0
- package/dist/select-keys.js +113 -0
- package/dist/ui.d.ts +16 -6
- package/dist/ui.js +158 -7
- package/dist/verdicts.d.ts +1 -1
- package/dist/verdicts.js +43 -1
- package/package.json +28 -10
package/dist/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { emitKeypressEvents } from "node:readline";
|
|
4
5
|
import { readFileSync } from "node:fs";
|
|
5
6
|
/**
|
|
6
7
|
* argv joined for the `X-Promptdock-Cli-Args` header, with bearer tokens removed.
|
|
@@ -13,6 +14,12 @@ import { readFileSync } from "node:fs";
|
|
|
13
14
|
* still needs to show that `--token` was passed.
|
|
14
15
|
*/
|
|
15
16
|
export function redactArgs(argv) {
|
|
17
|
+
// ⚠️ COUPLING: this is a DENYLIST keyed on the flags that carry sensitive
|
|
18
|
+
// values. Any NEW value-carrying flag added to args.ts COMMAND_SPECS must be
|
|
19
|
+
// assessed here in the same change — a missed one leaks silently (the header
|
|
20
|
+
// rides every request and every proxy logs it). Current set: --token (the
|
|
21
|
+
// live bearer) and --dir (absolute local paths carry the OS username +
|
|
22
|
+
// client/project names — data the 426 remedy does not need; review find).
|
|
16
23
|
return argv
|
|
17
24
|
.map((a, i) => {
|
|
18
25
|
if (/^pdk_/.test(a))
|
|
@@ -22,10 +29,55 @@ export function redactArgs(argv) {
|
|
|
22
29
|
return "--token=***";
|
|
23
30
|
if (argv[i - 1] === "--token")
|
|
24
31
|
return "***";
|
|
32
|
+
if (/^--dir=/.test(a))
|
|
33
|
+
return "--dir=***";
|
|
34
|
+
if (argv[i - 1] === "--dir")
|
|
35
|
+
return "***";
|
|
25
36
|
return a;
|
|
26
37
|
})
|
|
27
38
|
.join(" ");
|
|
28
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Process-level terminal safety net. The picker's `finally` restores raw mode
|
|
42
|
+
* + cursor on every JS path (resolve/reject/throw, and ^C arrives as a
|
|
43
|
+
* keypress under raw mode) — but an EXTERNAL SIGTERM/SIGHUP or `kill` runs no
|
|
44
|
+
* finally: the process dies with the cursor hidden (`?25l` persists until
|
|
45
|
+
* `reset`; 4 review voices converged on this). One-shot handlers restore the
|
|
46
|
+
* terminal and re-exit with the conventional 128+signal code.
|
|
47
|
+
*/
|
|
48
|
+
let activeRawRelease = null;
|
|
49
|
+
let terminalSafetyInstalled = false;
|
|
50
|
+
function installTerminalSafety() {
|
|
51
|
+
if (terminalSafetyInstalled)
|
|
52
|
+
return;
|
|
53
|
+
terminalSafetyInstalled = true;
|
|
54
|
+
const restore = () => {
|
|
55
|
+
try {
|
|
56
|
+
activeRawRelease?.();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* stdin already torn down */
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
process.stdout.write("[?25h");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* stdout already torn down */
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
// 'exit' covers process.exit() and normal termination; fatal signals with
|
|
69
|
+
// default handlers do NOT fire it, hence the explicit signal hooks.
|
|
70
|
+
process.on("exit", restore);
|
|
71
|
+
for (const [sig, code] of [
|
|
72
|
+
["SIGTERM", 143],
|
|
73
|
+
["SIGHUP", 129],
|
|
74
|
+
]) {
|
|
75
|
+
process.on(sig, () => {
|
|
76
|
+
restore();
|
|
77
|
+
process.exit(code);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
29
81
|
/** Read the package's own version (dist/ and src/ both sit one level under the root). */
|
|
30
82
|
export function readOwnVersion() {
|
|
31
83
|
try {
|
|
@@ -70,6 +122,54 @@ export function realContext(argv) {
|
|
|
70
122
|
rl.close();
|
|
71
123
|
}
|
|
72
124
|
},
|
|
125
|
+
// Only offered when raw mode is genuinely available AND the terminal can
|
|
126
|
+
// render ANSI. `setRawMode` is absent whenever stdin is not a TTY (a
|
|
127
|
+
// pipe, a spawned child, CI); TERM=dumb (Emacs M-x shell, some IDE
|
|
128
|
+
// consoles) IS a pty but cannot render the escapes the picker paints —
|
|
129
|
+
// it gets the numbered reader, as the README promises (review find).
|
|
130
|
+
...(isTTY &&
|
|
131
|
+
typeof process.stdin.setRawMode === "function" &&
|
|
132
|
+
process.env.TERM !== "dumb"
|
|
133
|
+
? {
|
|
134
|
+
rawKeys: (onKey) => {
|
|
135
|
+
installTerminalSafety();
|
|
136
|
+
const stdin = process.stdin;
|
|
137
|
+
emitKeypressEvents(stdin);
|
|
138
|
+
const wasRaw = stdin.isRaw === true;
|
|
139
|
+
stdin.setRawMode(true);
|
|
140
|
+
stdin.resume();
|
|
141
|
+
const handler = (_s, key) => {
|
|
142
|
+
if (key)
|
|
143
|
+
onKey(key);
|
|
144
|
+
};
|
|
145
|
+
stdin.on("keypress", handler);
|
|
146
|
+
let released = false;
|
|
147
|
+
// Idempotent: the picker releases in a `finally`, and a second call
|
|
148
|
+
// during process teardown must not throw on an already-closed stdin.
|
|
149
|
+
const release = () => {
|
|
150
|
+
if (released)
|
|
151
|
+
return;
|
|
152
|
+
released = true;
|
|
153
|
+
if (activeRawRelease === release)
|
|
154
|
+
activeRawRelease = null;
|
|
155
|
+
stdin.off("keypress", handler);
|
|
156
|
+
try {
|
|
157
|
+
stdin.setRawMode(wasRaw);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* stdin already torn down */
|
|
161
|
+
}
|
|
162
|
+
stdin.pause();
|
|
163
|
+
};
|
|
164
|
+
activeRawRelease = release;
|
|
165
|
+
return release;
|
|
166
|
+
},
|
|
167
|
+
size: () => ({
|
|
168
|
+
rows: process.stdout.rows ?? 24,
|
|
169
|
+
columns: process.stdout.columns ?? 80,
|
|
170
|
+
}),
|
|
171
|
+
}
|
|
172
|
+
: {}),
|
|
73
173
|
},
|
|
74
174
|
env: process.env,
|
|
75
175
|
cwd: process.cwd(),
|
package/dist/contract.d.ts
CHANGED
|
@@ -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 */
|
package/dist/errors.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export declare const EXIT: {
|
|
|
12
12
|
readonly IO: 5;
|
|
13
13
|
/** couldn't reach the API */
|
|
14
14
|
readonly NETWORK: 6;
|
|
15
|
+
/** Ctrl-C during an interactive picker — 128+SIGINT, the shell convention.
|
|
16
|
+
* Raw mode swallows the real signal (it arrives as a keypress), so the CLI
|
|
17
|
+
* exits with the code a signal death would have produced. Esc (a deliberate
|
|
18
|
+
* in-UI cancel) stays USAGE=1. Scripts never reach pickers (TTY-only). */
|
|
19
|
+
readonly INTERRUPT: 130;
|
|
15
20
|
};
|
|
16
21
|
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|
|
17
22
|
/** DX3: every named error footer links its docs anchor. */
|
package/dist/errors.js
CHANGED
|
@@ -13,6 +13,11 @@ export const EXIT = {
|
|
|
13
13
|
IO: 5,
|
|
14
14
|
/** couldn't reach the API */
|
|
15
15
|
NETWORK: 6,
|
|
16
|
+
/** Ctrl-C during an interactive picker — 128+SIGINT, the shell convention.
|
|
17
|
+
* Raw mode swallows the real signal (it arrives as a keypress), so the CLI
|
|
18
|
+
* exits with the code a signal death would have produced. Esc (a deliberate
|
|
19
|
+
* in-UI cancel) stays USAGE=1. Scripts never reach pickers (TTY-only). */
|
|
20
|
+
INTERRUPT: 130,
|
|
16
21
|
};
|
|
17
22
|
/** DX3: every named error footer links its docs anchor. */
|
|
18
23
|
export function footerUrl(code) {
|
|
@@ -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
|
-
export declare const CLI_MIN_VERSION = "0.1
|
|
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
|
-
export const CLI_MIN_VERSION = "0.1
|
|
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";
|
package/dist/help.js
CHANGED
|
@@ -21,8 +21,8 @@ export function globalHelp(version) {
|
|
|
21
21
|
help this map
|
|
22
22
|
|
|
23
23
|
Install options
|
|
24
|
-
-g, --global install into the tool's per-user dir
|
|
25
|
-
--target <tool>
|
|
24
|
+
-g, --global install into the tool's per-user dir (skips the scope prompt)
|
|
25
|
+
--target <tool> pick the tool (${TARGETS.map((t) => t.id).join(", ")}); scope is still asked — add -g or -y to skip
|
|
26
26
|
--dir <path> install into an explicit directory
|
|
27
27
|
-y, --yes skip prompts (needs an unambiguous target; NEVER implies --force)
|
|
28
28
|
--force replace a non-empty foreign directory / overwrite local edits
|
|
@@ -62,11 +62,15 @@ session stays valid until revoked in Settings → Account → CLI sessions.`,
|
|
|
62
62
|
Shows the signed-in handle, role, and token expiry.`,
|
|
63
63
|
install: `promptdock install <handle>/<slug> [-g] [--target <tool>] [--dir <path>] [-y] [--force] [--dry-run] [--json]
|
|
64
64
|
|
|
65
|
-
Installs a skill.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
Installs a skill. Interactively it asks two things: which TOOL, then whether
|
|
66
|
+
to install for THIS PROJECT or for your USER ACCOUNT (each shown as its real
|
|
67
|
+
destination path). -g answers the second one up front; --dir answers both.
|
|
68
|
+
|
|
69
|
+
Non-interactive sessions (CI) must pass --target or --dir plus -y, and
|
|
70
|
+
authenticate via PROMPTDOCK_TOKEN. -y skips the prompts ONLY — a non-empty
|
|
71
|
+
foreign directory still requires --force, and scope stays project-local
|
|
72
|
+
unless -g is passed. --dry-run resolves and prints the plan without
|
|
73
|
+
installing (and without spending any premium-unlock slot).`,
|
|
70
74
|
uninstall: `promptdock uninstall <handle>/<slug> | --all [-g] [--target <tool>] [--dir <path>] [--force] [-y] [--json]
|
|
71
75
|
|
|
72
76
|
Removes an installed skill using its .promptdock.json receipt — only files the
|
package/dist/installer.d.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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, {
|
package/dist/registry.d.ts
CHANGED
|
@@ -26,6 +26,62 @@ export declare function targetInstallDir(t: TargetDef, opts: {
|
|
|
26
26
|
global: boolean;
|
|
27
27
|
slug: string;
|
|
28
28
|
}): string;
|
|
29
|
+
/** Is this tool set up in each scope? Path knowledge stays in this module. */
|
|
30
|
+
export declare function detectScopes(t: TargetDef, opts: {
|
|
31
|
+
cwd: string;
|
|
32
|
+
home: string;
|
|
33
|
+
exists?: (p: string) => boolean;
|
|
34
|
+
}): {
|
|
35
|
+
local: boolean;
|
|
36
|
+
global: boolean;
|
|
37
|
+
};
|
|
38
|
+
export type ScopedTarget = TargetDef & {
|
|
39
|
+
detectedLocal: boolean;
|
|
40
|
+
detectedGlobal: boolean;
|
|
41
|
+
detected: boolean;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Scope-agnostic detection for the interactive picker, which asks for the TOOL
|
|
45
|
+
* first and the SCOPE second — so at list time there is no single scope to
|
|
46
|
+
* probe. `detected` (either scope) drives the D-UX11 float ordering; the
|
|
47
|
+
* per-scope flags let the row say WHERE it was found, which the old
|
|
48
|
+
* one-scope-only tag could not.
|
|
49
|
+
*/
|
|
50
|
+
export declare function detectTargetsAnyScope(opts: {
|
|
51
|
+
cwd: string;
|
|
52
|
+
home: string;
|
|
53
|
+
exists?: (p: string) => boolean;
|
|
54
|
+
}): ScopedTarget[];
|
|
55
|
+
/** Where a tool was found, for the picker row ("" when nowhere). */
|
|
56
|
+
export declare function detectionLabel(t: {
|
|
57
|
+
detectedLocal: boolean;
|
|
58
|
+
detectedGlobal: boolean;
|
|
59
|
+
}): string;
|
|
60
|
+
export type ScopeChoice = {
|
|
61
|
+
global: boolean;
|
|
62
|
+
label: string;
|
|
63
|
+
dir: string;
|
|
64
|
+
detected: boolean;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* The two concrete destinations for a chosen tool, in prompt order (project,
|
|
68
|
+
* then global), plus the preselect index. Pure so the prompt's content is
|
|
69
|
+
* unit-testable without a TTY.
|
|
70
|
+
*
|
|
71
|
+
* Preselect prefers the scope where the tool is ALREADY set up. When it is set
|
|
72
|
+
* up in both — or in neither — it stays project-local, which is exactly what
|
|
73
|
+
* `-y` and non-TTY runs default to, so the interactive and non-interactive
|
|
74
|
+
* paths can never disagree about the same repo.
|
|
75
|
+
*/
|
|
76
|
+
export declare function scopeChoices(t: TargetDef, opts: {
|
|
77
|
+
cwd: string;
|
|
78
|
+
home: string;
|
|
79
|
+
slug: string;
|
|
80
|
+
exists?: (p: string) => boolean;
|
|
81
|
+
}): {
|
|
82
|
+
choices: [ScopeChoice, ScopeChoice];
|
|
83
|
+
preselect: 0 | 1;
|
|
84
|
+
};
|
|
29
85
|
export type DetectedTarget = TargetDef & {
|
|
30
86
|
detected: boolean;
|
|
31
87
|
};
|
package/dist/registry.js
CHANGED
|
@@ -80,6 +80,71 @@ export function targetBaseDir(t, opts) {
|
|
|
80
80
|
export function targetInstallDir(t, opts) {
|
|
81
81
|
return join(targetBaseDir(t, opts), opts.slug);
|
|
82
82
|
}
|
|
83
|
+
/** Is this tool set up in each scope? Path knowledge stays in this module. */
|
|
84
|
+
export function detectScopes(t, opts) {
|
|
85
|
+
const exists = opts.exists ?? existsSync;
|
|
86
|
+
return {
|
|
87
|
+
local: exists(join(opts.cwd, t.detectLocal)),
|
|
88
|
+
global: exists(join(opts.home, t.detectGlobal)),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Scope-agnostic detection for the interactive picker, which asks for the TOOL
|
|
93
|
+
* first and the SCOPE second — so at list time there is no single scope to
|
|
94
|
+
* probe. `detected` (either scope) drives the D-UX11 float ordering; the
|
|
95
|
+
* per-scope flags let the row say WHERE it was found, which the old
|
|
96
|
+
* one-scope-only tag could not.
|
|
97
|
+
*/
|
|
98
|
+
export function detectTargetsAnyScope(opts) {
|
|
99
|
+
const all = TARGETS.map((t) => {
|
|
100
|
+
const found = detectScopes(t, opts);
|
|
101
|
+
return {
|
|
102
|
+
...t,
|
|
103
|
+
detectedLocal: found.local,
|
|
104
|
+
detectedGlobal: found.global,
|
|
105
|
+
detected: found.local || found.global,
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
return [...all.filter((t) => t.detected), ...all.filter((t) => !t.detected)];
|
|
109
|
+
}
|
|
110
|
+
/** Where a tool was found, for the picker row ("" when nowhere). */
|
|
111
|
+
export function detectionLabel(t) {
|
|
112
|
+
if (t.detectedLocal && t.detectedGlobal)
|
|
113
|
+
return "(detected: project + global)";
|
|
114
|
+
if (t.detectedLocal)
|
|
115
|
+
return "(detected: project)";
|
|
116
|
+
if (t.detectedGlobal)
|
|
117
|
+
return "(detected: global)";
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The two concrete destinations for a chosen tool, in prompt order (project,
|
|
122
|
+
* then global), plus the preselect index. Pure so the prompt's content is
|
|
123
|
+
* unit-testable without a TTY.
|
|
124
|
+
*
|
|
125
|
+
* Preselect prefers the scope where the tool is ALREADY set up. When it is set
|
|
126
|
+
* up in both — or in neither — it stays project-local, which is exactly what
|
|
127
|
+
* `-y` and non-TTY runs default to, so the interactive and non-interactive
|
|
128
|
+
* paths can never disagree about the same repo.
|
|
129
|
+
*/
|
|
130
|
+
export function scopeChoices(t, opts) {
|
|
131
|
+
const found = detectScopes(t, opts);
|
|
132
|
+
const choices = [
|
|
133
|
+
{
|
|
134
|
+
global: false,
|
|
135
|
+
label: "This directory",
|
|
136
|
+
dir: targetInstallDir(t, { ...opts, global: false }),
|
|
137
|
+
detected: found.local,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
global: true,
|
|
141
|
+
label: "Global",
|
|
142
|
+
dir: targetInstallDir(t, { ...opts, global: true }),
|
|
143
|
+
detected: found.global,
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
return { choices, preselect: !found.local && found.global ? 1 : 0 };
|
|
147
|
+
}
|
|
83
148
|
/**
|
|
84
149
|
* D-UX11 ordering: detected tools float to the top (registry order preserved
|
|
85
150
|
* within each group); the preselect is index 0 (first detected, else Claude).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key handling for the interactive picker, as a PURE reducer.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of ui.ts on purpose: the rendering half needs a real TTY (raw mode,
|
|
5
|
+
* cursor moves) and cannot be unit-tested, but the DECISIONS — what wraps, what
|
|
6
|
+
* commits, what cancels — are the part that regresses. Pure in, pure out, so
|
|
7
|
+
* every key path is pinned by a table test with no terminal involved.
|
|
8
|
+
*/
|
|
9
|
+
/** The subset of node:readline's keypress event this reducer reads. */
|
|
10
|
+
export type Key = {
|
|
11
|
+
name?: string;
|
|
12
|
+
sequence?: string;
|
|
13
|
+
ctrl?: boolean;
|
|
14
|
+
meta?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type SelectState = {
|
|
17
|
+
/** highlighted option */
|
|
18
|
+
index: number;
|
|
19
|
+
/** digits typed so far — the "type a number" path, empty when unused */
|
|
20
|
+
buffer: string;
|
|
21
|
+
};
|
|
22
|
+
export type SelectResult = {
|
|
23
|
+
kind: "state";
|
|
24
|
+
state: SelectState;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "commit";
|
|
27
|
+
index: number;
|
|
28
|
+
}
|
|
29
|
+
/** `signal` marks Ctrl-C (exit 130, the 128+SIGINT convention); Esc/Ctrl-D
|
|
30
|
+
* are deliberate in-UI cancels (exit 1). */
|
|
31
|
+
| {
|
|
32
|
+
kind: "cancel";
|
|
33
|
+
signal?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** a typed number that names no option — caller flashes a hint, keeps the prompt */
|
|
36
|
+
| {
|
|
37
|
+
kind: "reject";
|
|
38
|
+
state: SelectState;
|
|
39
|
+
message: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* One keypress → the next state, or a terminal outcome.
|
|
43
|
+
*
|
|
44
|
+
* Both input styles stay live at once, which is the whole point: arrows move the
|
|
45
|
+
* highlight, digits ALSO move it (so typing is visible before you commit), and
|
|
46
|
+
* Enter commits whichever the user last expressed. Space commits the highlight
|
|
47
|
+
* only — it is a "this one" gesture, and treating it as a number-confirm would
|
|
48
|
+
* make a half-typed "1" + Space silently pick option 1 when the user meant 12.
|
|
49
|
+
*
|
|
50
|
+
* Digits never auto-commit. `3` in a 4-item list is unambiguous and a fancier
|
|
51
|
+
* picker would fire immediately, but the user asked to KEEP number entry, and
|
|
52
|
+
* number entry means "type it, then press Enter" — auto-firing would make a
|
|
53
|
+
* two-digit list behave differently from a one-digit list.
|
|
54
|
+
*
|
|
55
|
+
* `hotkeys` (letter → option index) commit IMMEDIATELY — the confirm path
|
|
56
|
+
* passes {y:0, n:1}. Without this, `n`+Enter at a default-Yes "Install?"
|
|
57
|
+
* committed the DEFAULT: the muscle-memory answer this CLI's own [y/N]
|
|
58
|
+
* fallback trains was silently ignored and the UI executed the opposite of
|
|
59
|
+
* what the user typed (dual-review find, PTY-proven).
|
|
60
|
+
*/
|
|
61
|
+
export declare function reduceSelectKey(state: SelectState, key: Key, count: number, hotkeys?: Record<string, number>): SelectResult;
|
|
62
|
+
/** Footer hint. Mentions BOTH input styles — neither is discoverable otherwise.
|
|
63
|
+
* `hotkeyLabel` (e.g. "y/n") leads when the picker has letter hotkeys. */
|
|
64
|
+
export declare function selectHint(count: number, buffer: string, hotkeyLabel?: string): string;
|