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
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# promptdock
|
|
2
2
|
|
|
3
|
-
Install AI agent **skills** from [PromptDock](https://promptdock.ai) into Claude Code,
|
|
3
|
+
Install AI agent **skills** from [PromptDock](https://www.promptdock.ai) into Claude Code,
|
|
4
4
|
Codex, Gemini CLI, Cursor, GitHub Copilot, and more.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
@@ -9,13 +9,21 @@ npx promptdock@latest install <handle>/<slug>
|
|
|
9
9
|
|
|
10
10
|
## 60-second quickstart
|
|
11
11
|
|
|
12
|
+
Replace `<handle>/<slug>` with a real skill's ref — every skill page on
|
|
13
|
+
[promptdock.ai/skills](https://www.promptdock.ai/skills) shows its own install command,
|
|
14
|
+
ready to copy.
|
|
15
|
+
|
|
12
16
|
```bash
|
|
13
17
|
# 1) Install a skill into this project (the CLI walks you through login + target)
|
|
14
|
-
npx promptdock@latest install
|
|
18
|
+
npx promptdock@latest install <handle>/<slug>
|
|
15
19
|
|
|
16
20
|
# 2) Approve the login code in your browser when prompted (one time per machine)
|
|
21
|
+
# No account yet? The page it opens has a sign-up link: create the account,
|
|
22
|
+
# confirm the email it sends, then approve. The code lasts 15 minutes for
|
|
23
|
+
# exactly this, the terminal waits, and if it does run out the CLI issues a
|
|
24
|
+
# fresh one once.
|
|
17
25
|
|
|
18
|
-
# 3) Start a NEW session in Claude Code and type
|
|
26
|
+
# 3) Start a NEW session in Claude Code and type /<slug>
|
|
19
27
|
```
|
|
20
28
|
|
|
21
29
|
**Auth model, stated plainly: login is required.** A free PromptDock account
|
|
@@ -29,8 +37,8 @@ profile's ACL is the boundary).
|
|
|
29
37
|
|
|
30
38
|
| Command | What it does |
|
|
31
39
|
| --- | --- |
|
|
32
|
-
| `login [--token pdk_…]` | Browser hand-off login, or store a CI token from Settings → CLI sessions |
|
|
33
|
-
| `logout` |
|
|
40
|
+
| `login [--token pdk_…]` | Browser hand-off login, or store a CI token from Settings → Account → CLI sessions |
|
|
41
|
+
| `logout` | Revoke the session server-side, then remove the local token |
|
|
34
42
|
| `whoami [--json]` | Show the signed-in account + token expiry |
|
|
35
43
|
| `install <ref> [options]` | Install a skill (`ref` = `handle/slug`, `@handle/slug`, or a pasted URL) |
|
|
36
44
|
| `uninstall <ref \| --all>` | Remove an installed skill (receipt-driven — see below) |
|
|
@@ -41,6 +49,16 @@ Install options: `-g/--global` (per-user dir), `--target <tool>`, `--dir <path>`
|
|
|
41
49
|
`-y/--yes` (skip prompts — **never** implies `--force`), `--force`, `--dry-run`
|
|
42
50
|
(print the plan, install nothing), `--json`.
|
|
43
51
|
|
|
52
|
+
Update/uninstall options: `--check` (update only — report, apply nothing), `--all`,
|
|
53
|
+
`-g/--global`, `--force`, `--allow-partial` (see
|
|
54
|
+
[batch exit codes](#batch-exit-codes)), `--json`.
|
|
55
|
+
|
|
56
|
+
`logout` calls the server first: the token this machine holds stops working
|
|
57
|
+
immediately, and only then is the local copy deleted. If that call cannot get
|
|
58
|
+
through (offline), the local token is still removed and the CLI warns you on
|
|
59
|
+
stderr — that session stays valid until it expires, so revoke it under
|
|
60
|
+
**Settings → Account → CLI sessions**.
|
|
61
|
+
|
|
44
62
|
## Install targets
|
|
45
63
|
|
|
46
64
|
| Target | Project install | Global (`-g`) | Status |
|
|
@@ -83,7 +101,7 @@ Non-TTY sessions never open a browser or show a picker. The contract:
|
|
|
83
101
|
|
|
84
102
|
```bash
|
|
85
103
|
export PROMPTDOCK_TOKEN=pdk_… # Settings → Account → CLI sessions → Generate token
|
|
86
|
-
npx promptdock@latest install
|
|
104
|
+
npx promptdock@latest install <handle>/<slug> --target claude -y
|
|
87
105
|
```
|
|
88
106
|
|
|
89
107
|
- `PROMPTDOCK_TOKEN` — bearer token (overrides any stored login).
|
|
@@ -94,6 +112,71 @@ npx promptdock@latest install garry/seo-content-audit --target claude -y
|
|
|
94
112
|
- Without `--target`/`--dir` **and** `-y`, a non-interactive install exits `1`
|
|
95
113
|
with the exact flags to add. Without a token it exits `2`.
|
|
96
114
|
- `NO_COLOR` is honored.
|
|
115
|
+
- Installs are **latest-only** — there is no version pinning. A build that must be
|
|
116
|
+
byte-reproducible should vendor the installed files, not re-resolve them.
|
|
117
|
+
- Node's built-in `fetch` does **not** read `HTTPS_PROXY`/`HTTP_PROXY` by itself.
|
|
118
|
+
Recent Node versions opt in with `NODE_USE_ENV_PROXY=1`; if yours does not
|
|
119
|
+
support it, the CLI cannot use your proxy. When a request fails it names the
|
|
120
|
+
proxy variable it found, so the cause is not a mystery.
|
|
121
|
+
|
|
122
|
+
### Batch exit codes
|
|
123
|
+
|
|
124
|
+
`update` and `uninstall` can act on several skills at once (`--all`, or a scope
|
|
125
|
+
holding more than one). One bad item no longer aborts the rest — the others are
|
|
126
|
+
still processed — and the command then **exits with that item's code**:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npx promptdock@latest update --all -y # exits 4 if one skill has local edits
|
|
130
|
+
npx promptdock@latest update --all -y --allow-partial # same report, exits 0
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
What counts as a failure: unreachable server, a tier/cap refusal, a package this
|
|
134
|
+
CLI is too old for, an unreadable receipt, or local edits blocking an overwrite.
|
|
135
|
+
What does **not**: a skill already up to date, an available update under
|
|
136
|
+
`--check`, or a human answering "no" to a confirm.
|
|
137
|
+
|
|
138
|
+
⚠️ Before 1.2.1 every one of those was reported as `skipped` and the command
|
|
139
|
+
exited `0`, so a CI step could go green having updated nothing.
|
|
140
|
+
|
|
141
|
+
### `--json` output
|
|
142
|
+
|
|
143
|
+
Every shape below is printed to **stdout** as a single line; human progress text
|
|
144
|
+
and warnings go to stderr. Log in first — an interactive login hand-off prints to
|
|
145
|
+
stdout, so in CI set `PROMPTDOCK_TOKEN` and pass `-y`, which also stops the
|
|
146
|
+
confirms from painting a picker over your JSON.
|
|
147
|
+
|
|
148
|
+
| Command | Shape |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| `whoami --json` | `{ handle, role, expires_at }` |
|
|
151
|
+
| `list --json` | `[{ dir, target, scope, ref, version, version_id, installed_at, schema }]` — an unreadable receipt yields `{ dir, target, scope, receipt: "newer_schema" \| "invalid" }` instead |
|
|
152
|
+
| `install --json` | `{ installed: true, update, ref, version, version_id, target, path, files }` |
|
|
153
|
+
| `install --json` (nothing to do) | `{ installed: false, up_to_date: true, update: true, ref, version, version_id, target, path }` |
|
|
154
|
+
| `install --dry-run --json` | `{ dry_run: true, ref, skill_id, version_id, version, title, author, is_free, file_count, total_bytes, target, path, update }` |
|
|
155
|
+
| `update --json` | `{ results: [ … ] }` — always, including when nothing is installed (`{ results: [] }`) |
|
|
156
|
+
| `uninstall --json` | `{ uninstalled: [{ ref, dir }], results: [ … ] }` |
|
|
157
|
+
|
|
158
|
+
A `results` row is `{ ref, dir, status, reason?, failed?, exit?, from?, to? }`:
|
|
159
|
+
|
|
160
|
+
- `status` — `updated` · `up_to_date` · `available` (`--check`) · `uninstalled` ·
|
|
161
|
+
`skipped` (declined, or failed).
|
|
162
|
+
- `failed` — present and `true` **only** for a real failure. This is the field to
|
|
163
|
+
branch on; `status` keeps the vocabulary 1.2.0 shipped.
|
|
164
|
+
- `exit` — that failure's own exit code. The process exits with the first one.
|
|
165
|
+
- `from`/`to` — version numbers, on `update` rows only.
|
|
166
|
+
|
|
167
|
+
A **failure** that produced no report of its own is written to stdout as one document:
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{ "error": { "code": "existing_dir", "message": "…", "exit_code": 5, "doc_url": "https://www.promptdock.ai/docs/cli/errors#existing_dir" } }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`code` is the docs anchor (`"error"` when the failure has none, and `doc_url` then
|
|
174
|
+
points at the index). `update`/`uninstall` do **not** add this — their `results` report
|
|
175
|
+
already answers the same question per item — so stdout is always exactly one JSON
|
|
176
|
+
value, on every path.
|
|
177
|
+
|
|
178
|
+
New keys may be added in a patch release; existing keys and `status` values will
|
|
179
|
+
not change meaning.
|
|
97
180
|
|
|
98
181
|
## Receipts, update, uninstall
|
|
99
182
|
|
|
@@ -108,8 +191,9 @@ skill id, ref, version, and the sha256 of every installed file. It is the
|
|
|
108
191
|
any emptied directories. Files you added are never touched.
|
|
109
192
|
- If you **edited** installed files, update/uninstall refuse without `--force`
|
|
110
193
|
("copy your changes out first") — your local changes never die to a default.
|
|
111
|
-
- A receipt written by a newer CLI version is left alone with a
|
|
112
|
-
|
|
194
|
+
- A receipt written by a newer CLI version is left alone with a notice naming
|
|
195
|
+
the exact `npx promptdock@latest …` command to re-run — never a guessy
|
|
196
|
+
uninstall. It counts as a failed item (see [batch exit codes](#batch-exit-codes)).
|
|
113
197
|
|
|
114
198
|
Downloads are verified against the server manifest (per-file sha256) and every
|
|
115
199
|
path is re-validated before any write; a failed check aborts **all-or-nothing**
|
|
@@ -128,9 +212,19 @@ path is re-validated before any write; a failed check aborts **all-or-nothing**
|
|
|
128
212
|
| 6 | network |
|
|
129
213
|
| 130 | interrupted (`Ctrl-C` during an interactive prompt; `Esc` — a deliberate in-UI cancel — exits 1) |
|
|
130
214
|
|
|
131
|
-
Every named error links `https://promptdock.ai/docs/cli/errors#<code>`. That page
|
|
215
|
+
Every named error links `https://www.promptdock.ai/docs/cli/errors#<code>`. That page
|
|
132
216
|
lists every code with what happened, why, and what to do.
|
|
133
217
|
|
|
218
|
+
## Getting help
|
|
219
|
+
|
|
220
|
+
- **A named error, an exit code:** [promptdock.ai/docs/cli/errors](https://www.promptdock.ai/docs/cli/errors) —
|
|
221
|
+
every code, with what happened and what to do. The link the CLI prints goes straight
|
|
222
|
+
to the entry.
|
|
223
|
+
- **Anything else:** [promptdock.ai/contact](https://www.promptdock.ai/contact) names the
|
|
224
|
+
right mailbox for the question (account, billing, the CLI). Include the command you
|
|
225
|
+
ran, `promptdock --version`, and the error text.
|
|
226
|
+
- `npm bugs promptdock` opens the errors page from your terminal.
|
|
227
|
+
|
|
134
228
|
## Package compatibility
|
|
135
229
|
|
|
136
230
|
This CLI carries its own **install-safety limits** — how many files it will write and how
|
|
@@ -143,12 +237,16 @@ When that happens you get, for **that skill only**:
|
|
|
143
237
|
|
|
144
238
|
```
|
|
145
239
|
"Big Skill" needs promptdock CLI 1.2.0 or newer — you are on 1.0.1.
|
|
146
|
-
Update: npx promptdock@latest install
|
|
240
|
+
Update: npx promptdock@latest install <handle>/<slug> (or: npm i -g promptdock@latest)
|
|
147
241
|
```
|
|
148
242
|
|
|
243
|
+
(the real message names the skill you asked for, so the second line is a command
|
|
244
|
+
you can paste as-is)
|
|
245
|
+
|
|
149
246
|
It exits `3` (denied), not `4` (integrity) — the package is fine, your CLI is just older
|
|
150
|
-
than it. Your login
|
|
151
|
-
|
|
247
|
+
than it. Your login and your other installs are unaffected: `promptdock update`
|
|
248
|
+
updates everything else and then exits `3` for the one it could not do (see
|
|
249
|
+
[batch exit codes](#batch-exit-codes)).
|
|
152
250
|
|
|
153
251
|
## Notes
|
|
154
252
|
|
|
@@ -169,5 +267,5 @@ website, or the rest of the repository, which are proprietary.
|
|
|
169
267
|
|
|
170
268
|
It also does not cover **the skills you install**. Those are third-party content
|
|
171
269
|
licensed by their authors under the
|
|
172
|
-
[PromptDock Terms](https://promptdock.ai/terms) — installing a skill grants you
|
|
270
|
+
[PromptDock Terms](https://www.promptdock.ai/terms) — installing a skill grants you
|
|
173
271
|
access to use it, not the rights this MIT license grants over the CLI itself.
|
package/dist/api.d.ts
CHANGED
|
@@ -9,6 +9,29 @@ export type RawResponse = {
|
|
|
9
9
|
body: unknown;
|
|
10
10
|
headers: Headers;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* The server ErrorCodes that have their OWN anchor on /docs/cli/errors.
|
|
14
|
+
*
|
|
15
|
+
* `env.code` is a string lifted off an untrusted response body, and it used to become
|
|
16
|
+
* the docs anchor verbatim (`footer: env?.code ?? "api"`). Anything a proxy, a captive
|
|
17
|
+
* portal, a future server or a plain typo put there therefore became
|
|
18
|
+
* `…/docs/cli/errors#whatever` — a link printed to a stuck user that scrolls nowhere.
|
|
19
|
+
* The fallbacks are the point of the `??`, and they were unreachable for every string
|
|
20
|
+
* that was not literally absent.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ TWO CODES ARE DELIBERATELY ABSENT FROM THE APP'S VOCABULARY HERE, and it is not
|
|
23
|
+
* an oversight: `unauthenticated` / `invalid_token` are ALIASED to the `auth` entry in
|
|
24
|
+
* lib/cli/error-docs.ts, and `idempotency_key_required` is
|
|
25
|
+
* aliased to `api`. An alias has no anchor of its own, so passing one through would
|
|
26
|
+
* mint exactly the dead link this list exists to prevent — they fall back instead,
|
|
27
|
+
* which is where their doc entries already say they belong.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ COUPLING (guarded by test/footer-codes.test.ts, which reads both files as text):
|
|
30
|
+
* every entry must be an app ErrorCode (lib/errors.ts) AND carry its own `slug` in
|
|
31
|
+
* lib/cli/error-docs.ts. The CLI cannot import either — packages/cli ships to npm on
|
|
32
|
+
* its own — so a test is the only seam that can hold the two vocabularies together.
|
|
33
|
+
*/
|
|
34
|
+
export declare const DOCUMENTED_SERVER_CODES: ReadonlySet<string>;
|
|
12
35
|
export declare class Api {
|
|
13
36
|
private readonly ctx;
|
|
14
37
|
readonly baseUrl: string;
|
|
@@ -19,7 +42,27 @@ export declare class Api {
|
|
|
19
42
|
raw(method: string, path: string, body?: unknown): Promise<RawResponse>;
|
|
20
43
|
/** Envelope-aware request: 2xx → typed body; anything else → a mapped CliError. */
|
|
21
44
|
request<T>(method: string, path: string, body?: unknown): Promise<T>;
|
|
22
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Extract {error:{code,message,details}} defensively (untrusted input).
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ THIS IS THE TRUST BOUNDARY, and `message` is sanitized HERE rather than at the
|
|
49
|
+
* five sites that print it. Every one of those sites reads this function's output —
|
|
50
|
+
* and so does auth.ts's device-flow poll, which builds its own CliError and never
|
|
51
|
+
* touches `mapEnvelopeError` at all. Sanitizing per call site would have covered
|
|
52
|
+
* five of six and left the sixth to be found later, which is exactly how the
|
|
53
|
+
* original gap (one covered site, five raw) came about.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ `details` is deliberately NOT sanitized: it is an opaque bag whose values are
|
|
56
|
+
* read for meaning, not just printed (`min_version`, `retry_after_secs`), and
|
|
57
|
+
* blanket-sanitizing a `Record<string, unknown>` would coerce non-strings. Every
|
|
58
|
+
* read site that PRINTS a details value owes it a `terminalLine` — see the 426
|
|
59
|
+
* remedy in `mapEnvelopeError`, the only such site today.
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ `code` is not sanitized either, because it never reaches the terminal on its
|
|
62
|
+
* own: `documentedFooter` gates it against a frozen 12-slug allowlist before it can
|
|
63
|
+
* become a docs anchor. The allowlist is what makes that safe, so do not relax it
|
|
64
|
+
* into a passthrough.
|
|
65
|
+
*/
|
|
23
66
|
static envelope(body: unknown): ApiEnvelopeError | null;
|
|
24
67
|
private mapEnvelopeError;
|
|
25
68
|
}
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_API_BASE } from "./config.js";
|
|
2
2
|
import { CliError, EXIT, networkError } from "./errors.js";
|
|
3
|
+
import { LOCATION_MAX, SERVER_MESSAGE_MAX, SERVER_REMEDY_MAX, terminalLine, } from "./terminal-text.js";
|
|
3
4
|
/**
|
|
4
5
|
* The redirect target may only become an ACTIONABLE hint when it is trustworthy
|
|
5
6
|
* (review CRITICAL): a 3xx can come from a captive portal, hijacked DNS, or a
|
|
@@ -25,17 +26,56 @@ function trustedRedirectOrigin(location, baseUrl) {
|
|
|
25
26
|
return null; // relative/unparseable Location — never actionable
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The server ErrorCodes that have their OWN anchor on /docs/cli/errors.
|
|
31
|
+
*
|
|
32
|
+
* `env.code` is a string lifted off an untrusted response body, and it used to become
|
|
33
|
+
* the docs anchor verbatim (`footer: env?.code ?? "api"`). Anything a proxy, a captive
|
|
34
|
+
* portal, a future server or a plain typo put there therefore became
|
|
35
|
+
* `…/docs/cli/errors#whatever` — a link printed to a stuck user that scrolls nowhere.
|
|
36
|
+
* The fallbacks are the point of the `??`, and they were unreachable for every string
|
|
37
|
+
* that was not literally absent.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ TWO CODES ARE DELIBERATELY ABSENT FROM THE APP'S VOCABULARY HERE, and it is not
|
|
40
|
+
* an oversight: `unauthenticated` / `invalid_token` are ALIASED to the `auth` entry in
|
|
41
|
+
* lib/cli/error-docs.ts, and `idempotency_key_required` is
|
|
42
|
+
* aliased to `api`. An alias has no anchor of its own, so passing one through would
|
|
43
|
+
* mint exactly the dead link this list exists to prevent — they fall back instead,
|
|
44
|
+
* which is where their doc entries already say they belong.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ COUPLING (guarded by test/footer-codes.test.ts, which reads both files as text):
|
|
47
|
+
* every entry must be an app ErrorCode (lib/errors.ts) AND carry its own `slug` in
|
|
48
|
+
* lib/cli/error-docs.ts. The CLI cannot import either — packages/cli ships to npm on
|
|
49
|
+
* its own — so a test is the only seam that can hold the two vocabularies together.
|
|
50
|
+
*/
|
|
51
|
+
export const DOCUMENTED_SERVER_CODES = new Set([
|
|
52
|
+
// Web-only today: no /api/v1/cli route carries `refuseWhenClosing`, because installing a
|
|
53
|
+
// skill takes on no obligation to settle. Passed through anyway — the day one does, the
|
|
54
|
+
// entry is already reachable, and a code that cannot arrive costs nothing here
|
|
55
|
+
// (`documentedFooter` only ever maps a code a response actually carried).
|
|
56
|
+
"account_closing",
|
|
57
|
+
"account_disabled",
|
|
58
|
+
// Reachable from EVERY cli-token route: the spine refuses a suspended account before the
|
|
59
|
+
// handler runs and no /cli route is `allowDisabled`, so this is the one a stuck user hits
|
|
60
|
+
// on `whoami` as readily as on `install`.
|
|
61
|
+
"account_suspended",
|
|
62
|
+
"conflict",
|
|
63
|
+
"insufficient_tier",
|
|
64
|
+
"internal",
|
|
65
|
+
"mfa_required",
|
|
66
|
+
"not_found",
|
|
67
|
+
"not_owner",
|
|
68
|
+
"rate_limited",
|
|
69
|
+
// Web-only today (payout re-authentication); passed through so the entry is reachable if it ever arrives.
|
|
70
|
+
"reauth_required",
|
|
71
|
+
"service_unavailable",
|
|
72
|
+
"upgrade_required",
|
|
73
|
+
"validation_error",
|
|
74
|
+
"version_conflict",
|
|
75
|
+
]);
|
|
76
|
+
/** The server's code as a docs anchor, or undefined so the caller's fallback wins. */
|
|
77
|
+
function documentedFooter(code) {
|
|
78
|
+
return code !== undefined && DOCUMENTED_SERVER_CODES.has(code) ? code : undefined;
|
|
39
79
|
}
|
|
40
80
|
export class Api {
|
|
41
81
|
ctx;
|
|
@@ -82,7 +122,7 @@ export class Api {
|
|
|
82
122
|
// ctx.fetch directly and must keep following them — storage hands out redirects.
|
|
83
123
|
if (res.status >= 300 && res.status < 400) {
|
|
84
124
|
const to = res.headers.get("location") ?? "(no location header)";
|
|
85
|
-
throw new CliError(`${this.baseUrl} redirected to ${
|
|
125
|
+
throw new CliError(`${this.baseUrl} redirected to ${terminalLine(to, LOCATION_MAX)} — the API must be called on its canonical origin, because a redirect drops the request body and the login token.`, EXIT.NETWORK, {
|
|
86
126
|
// An UNTRUSTED target (cross-domain / non-https — a captive portal or
|
|
87
127
|
// MITM can mint a 3xx) gets the STATIC known-good hint, never its own
|
|
88
128
|
// origin echoed back as an instruction. DEFAULT_API_BASE is imported,
|
|
@@ -108,7 +148,27 @@ export class Api {
|
|
|
108
148
|
return res.body;
|
|
109
149
|
throw this.mapEnvelopeError(res);
|
|
110
150
|
}
|
|
111
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Extract {error:{code,message,details}} defensively (untrusted input).
|
|
153
|
+
*
|
|
154
|
+
* ⚠️ THIS IS THE TRUST BOUNDARY, and `message` is sanitized HERE rather than at the
|
|
155
|
+
* five sites that print it. Every one of those sites reads this function's output —
|
|
156
|
+
* and so does auth.ts's device-flow poll, which builds its own CliError and never
|
|
157
|
+
* touches `mapEnvelopeError` at all. Sanitizing per call site would have covered
|
|
158
|
+
* five of six and left the sixth to be found later, which is exactly how the
|
|
159
|
+
* original gap (one covered site, five raw) came about.
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ `details` is deliberately NOT sanitized: it is an opaque bag whose values are
|
|
162
|
+
* read for meaning, not just printed (`min_version`, `retry_after_secs`), and
|
|
163
|
+
* blanket-sanitizing a `Record<string, unknown>` would coerce non-strings. Every
|
|
164
|
+
* read site that PRINTS a details value owes it a `terminalLine` — see the 426
|
|
165
|
+
* remedy in `mapEnvelopeError`, the only such site today.
|
|
166
|
+
*
|
|
167
|
+
* ⚠️ `code` is not sanitized either, because it never reaches the terminal on its
|
|
168
|
+
* own: `documentedFooter` gates it against a frozen 12-slug allowlist before it can
|
|
169
|
+
* become a docs anchor. The allowlist is what makes that safe, so do not relax it
|
|
170
|
+
* into a passthrough.
|
|
171
|
+
*/
|
|
112
172
|
static envelope(body) {
|
|
113
173
|
if (typeof body !== "object" || body === null)
|
|
114
174
|
return null;
|
|
@@ -118,9 +178,14 @@ export class Api {
|
|
|
118
178
|
const e = err;
|
|
119
179
|
if (typeof e.code !== "string")
|
|
120
180
|
return null;
|
|
181
|
+
// An all-control message sanitizes to "", which would print a blank line where a
|
|
182
|
+
// reason belongs — so fall back to the code, and note that every call site below
|
|
183
|
+
// uses `||` (not `??`) so an empty result still reaches the CLI's own sentence.
|
|
184
|
+
const raw = typeof e.message === "string" ? e.message : e.code;
|
|
185
|
+
const message = terminalLine(raw, SERVER_MESSAGE_MAX) || terminalLine(e.code, SERVER_MESSAGE_MAX);
|
|
121
186
|
return {
|
|
122
187
|
code: e.code,
|
|
123
|
-
message
|
|
188
|
+
message,
|
|
124
189
|
details: typeof e.details === "object" && e.details !== null
|
|
125
190
|
? e.details
|
|
126
191
|
: undefined,
|
|
@@ -130,15 +195,31 @@ export class Api {
|
|
|
130
195
|
const env = Api.envelope(res.body);
|
|
131
196
|
// 426 version floor: print the SERVER's remedy verbatim (DX3/F8) — the
|
|
132
197
|
// server knows the canonical command better than a stale client does.
|
|
198
|
+
//
|
|
199
|
+
// ⚠️ "VERBATIM" MEANS "DO NOT REWRITE THE SERVER'S WORDS", NOT "DO NOT SANITIZE".
|
|
200
|
+
// The reason the comment above gives is that a stale client must not substitute
|
|
201
|
+
// its own command — an argument about WHICH command, not about which bytes. Our
|
|
202
|
+
// own server already strips escapes and newlines from this string before sending
|
|
203
|
+
// it (lib/cli-auth/protocol.ts `upgradeRemedy`, a strict allowlist), so sanitizing
|
|
204
|
+
// here changes nothing an honest server sends and only alters a hostile one's.
|
|
205
|
+
// Stripping control characters preserves copy-pasteability; it is truncation and
|
|
206
|
+
// newline-collapsing that would destroy it, which is why the two halves are
|
|
207
|
+
// sanitized SEPARATELY and joined afterwards.
|
|
208
|
+
//
|
|
209
|
+
// ⚠️ SANITIZE BEFORE THE JOIN, NEVER AFTER. The `\n` below is the CLIENT's, not
|
|
210
|
+
// the server's, so a single pass over the joined string would delete the deliberate
|
|
211
|
+
// line break AND measure the cap against ~215 characters worst case, cutting the
|
|
212
|
+
// tail off the very command the remedy exists to provide.
|
|
133
213
|
if (res.status === 426) {
|
|
134
|
-
const
|
|
135
|
-
const
|
|
214
|
+
const rawRemedy = typeof env?.details?.remedy === "string" ? env.details.remedy : null;
|
|
215
|
+
const remedy = rawRemedy ? terminalLine(rawRemedy, SERVER_REMEDY_MAX) : "";
|
|
216
|
+
const msg = env?.message || "This promptdock CLI version is no longer supported.";
|
|
136
217
|
return new CliError(remedy ? `${msg}\n${remedy}` : msg, EXIT.DENIED, {
|
|
137
218
|
footer: "upgrade_required",
|
|
138
219
|
});
|
|
139
220
|
}
|
|
140
221
|
if (res.status === 401) {
|
|
141
|
-
return new CliError(env?.message
|
|
222
|
+
return new CliError(env?.message || "Not authenticated.", EXIT.AUTH, {
|
|
142
223
|
footer: "auth",
|
|
143
224
|
hint: "run: npx promptdock@latest login",
|
|
144
225
|
});
|
|
@@ -147,12 +228,12 @@ export class Api {
|
|
|
147
228
|
const retryAfter = res.headers.get("retry-after");
|
|
148
229
|
const secs = retryAfter ? Number(retryAfter) : null;
|
|
149
230
|
const wait = secs && Number.isFinite(secs) ? ` Retry in ~${Math.ceil(secs / 60)} min.` : "";
|
|
150
|
-
return new CliError((env?.message
|
|
151
|
-
footer: env?.code ?? "rate_limited",
|
|
231
|
+
return new CliError((env?.message || "Rate limited.") + wait, EXIT.DENIED, {
|
|
232
|
+
footer: documentedFooter(env?.code) ?? "rate_limited",
|
|
152
233
|
});
|
|
153
234
|
}
|
|
154
|
-
return new CliError(env?.message
|
|
155
|
-
footer: env?.code ?? "api",
|
|
235
|
+
return new CliError(env?.message || `request failed (HTTP ${res.status})`, EXIT.DENIED, {
|
|
236
|
+
footer: documentedFooter(env?.code) ?? "api",
|
|
156
237
|
});
|
|
157
238
|
}
|
|
158
239
|
}
|
package/dist/args.js
CHANGED
|
@@ -16,11 +16,11 @@ export const COMMAND_SPECS = {
|
|
|
16
16
|
values: ["target", "dir"],
|
|
17
17
|
},
|
|
18
18
|
uninstall: {
|
|
19
|
-
booleans: ["help", "json", "global", "yes", "force", "all"],
|
|
19
|
+
booleans: ["help", "json", "global", "yes", "force", "all", "allow-partial"],
|
|
20
20
|
values: ["target", "dir"],
|
|
21
21
|
},
|
|
22
22
|
update: {
|
|
23
|
-
booleans: ["help", "json", "global", "yes", "force", "all", "check"],
|
|
23
|
+
booleans: ["help", "json", "global", "yes", "force", "all", "check", "allow-partial"],
|
|
24
24
|
values: ["target", "dir"],
|
|
25
25
|
},
|
|
26
26
|
list: { booleans: ["help", "json", "global", "all"], values: [] },
|
package/dist/auth.js
CHANGED
|
@@ -5,6 +5,7 @@ import { hostname } from "node:os";
|
|
|
5
5
|
import { Api } from "./api.js";
|
|
6
6
|
import { loadConfig, resolveApiBase, resolveToken, saveConfig } from "./config.js";
|
|
7
7
|
import { CliError, EXIT } from "./errors.js";
|
|
8
|
+
import { LOCATION_MAX, SERVER_LABEL_MAX, terminalLine } from "./terminal-text.js";
|
|
8
9
|
import { formatCountdown } from "./ui.js";
|
|
9
10
|
/**
|
|
10
11
|
* Run the browser hand-off and return the bearer token. NEVER logs the token.
|
|
@@ -19,12 +20,16 @@ export async function deviceFlowLogin(ctx, baseUrl, opts) {
|
|
|
19
20
|
device_label: safeDeviceLabel(),
|
|
20
21
|
long: opts.long,
|
|
21
22
|
});
|
|
23
|
+
// ⚠️ PRE-AUTH SINK. `/cli/auth/start` needs no token, so anything that can answer
|
|
24
|
+
// this request — a hostile origin, a captive portal, a MITM — chooses these two
|
|
25
|
+
// strings, and `api.request` returns `res.body as T` with no runtime validation.
|
|
26
|
+
// Sanitized for DISPLAY only; the raw `verification_url` is what gets opened.
|
|
22
27
|
ctx.io.out("");
|
|
23
28
|
ctx.io.out("Confirm this code in your browser:");
|
|
24
29
|
ctx.io.out("");
|
|
25
|
-
ctx.io.out(` ${start.user_code}`);
|
|
30
|
+
ctx.io.out(` ${terminalLine(start.user_code, SERVER_LABEL_MAX)}`);
|
|
26
31
|
ctx.io.out("");
|
|
27
|
-
ctx.io.out(`Open: ${start.verification_url}`);
|
|
32
|
+
ctx.io.out(`Open: ${terminalLine(start.verification_url, LOCATION_MAX)}`);
|
|
28
33
|
if (ctx.io.isTTY) {
|
|
29
34
|
ctx.openUrl(start.verification_url);
|
|
30
35
|
ctx.io.out("(if the page didn't open, paste the URL into your browser)");
|
package/dist/commands/install.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
import { existsSync, readdirSync } from "node:fs";
|
|
6
6
|
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
7
7
|
import { ensureAuth } from "../auth.js";
|
|
8
|
+
import { newerSchemaError, rerunCommand } from "./lifecycle.js";
|
|
8
9
|
import { CliError, EXIT, usageError } from "../errors.js";
|
|
10
|
+
import { SERVER_LABEL_MAX, SERVER_PATH_MAX, terminalLine } from "../terminal-text.js";
|
|
9
11
|
import { checkInstallResponse, performInstall } from "../installer.js";
|
|
10
12
|
import { formatRef, parseSkillRef } from "../ref.js";
|
|
11
13
|
import { detectionLabel, detectTargets, detectTargetsAnyScope, nextStepLine, scopeChoices, targetById, targetInstallDir, TARGETS, } from "../registry.js";
|
|
@@ -38,7 +40,7 @@ export async function runInstall(ctx, positionals, flags) {
|
|
|
38
40
|
// ── receipt awareness (DX3/DX5) ────────────────────────────────────────────
|
|
39
41
|
const existing = readReceipt(picked.dir);
|
|
40
42
|
if (existing.kind === "newer_schema") {
|
|
41
|
-
throw
|
|
43
|
+
throw newerSchemaError(picked.dir, existing.schema, rerunCommand("install", positionals, flags));
|
|
42
44
|
}
|
|
43
45
|
const matching = existing.kind === "ok" && existing.receipt.skill_id === resolve.skill_id
|
|
44
46
|
? existing.receipt
|
|
@@ -51,25 +53,50 @@ export async function runInstall(ctx, positionals, flags) {
|
|
|
51
53
|
}
|
|
52
54
|
if (isUpdate && matching.version_id === resolve.version_id && flags.force !== true && !dryRun) {
|
|
53
55
|
ctx.io.out(json
|
|
54
|
-
? JSON.stringify({
|
|
55
|
-
|
|
56
|
+
? JSON.stringify({
|
|
57
|
+
installed: false,
|
|
58
|
+
up_to_date: true,
|
|
59
|
+
update: true,
|
|
60
|
+
ref: refString,
|
|
61
|
+
version: matching.version,
|
|
62
|
+
version_id: matching.version_id,
|
|
63
|
+
target: picked.id,
|
|
64
|
+
path: picked.dir,
|
|
65
|
+
})
|
|
66
|
+
: `"${terminalLine(resolve.title, SERVER_LABEL_MAX) || refString}" is already up to date (v${matching.version}) at ${picked.dir} — use --force to reinstall.`);
|
|
56
67
|
return;
|
|
57
68
|
}
|
|
69
|
+
// ⚠️ SANITIZE THE FRAGMENTS, NOT THE LINES. The summary pads its labels into
|
|
70
|
+
// columns (" version: v3"), and `terminalLine` collapses whitespace runs — one
|
|
71
|
+
// pass over a finished line would flatten every one of them.
|
|
72
|
+
const safeTitle = terminalLine(resolve.title, SERVER_LABEL_MAX) || refString;
|
|
73
|
+
const safeAuthor = terminalLine(resolve.author, SERVER_LABEL_MAX) || ref.handle;
|
|
74
|
+
const safeLicense = terminalLine(resolve.license, SERVER_LABEL_MAX);
|
|
75
|
+
// ⚠️ `version` IS TYPED `number` AND IS NOT ONE. It arrives through the same bare
|
|
76
|
+
// `res.body as T` cast as everything else, and `checkInstallResponse` validates
|
|
77
|
+
// `skill.version_id` and `skill.slug` but NOT `skill.version` — so a hostile string
|
|
78
|
+
// used to render raw right beside the title this block sanitizes.
|
|
79
|
+
const ver = (v) => terminalLine(String(v), SERVER_LABEL_MAX);
|
|
80
|
+
// A filesystem path, not a server string — but `readdirSync` entry names may contain
|
|
81
|
+
// anything except `/` and NUL, and this is the ONE line that tells the user where
|
|
82
|
+
// files are about to be written. A crafted directory name could erase it, which
|
|
83
|
+
// defeats confirm-before-write itself.
|
|
84
|
+
const safeDir = terminalLine(picked.dir, SERVER_PATH_MAX);
|
|
58
85
|
// ── summary block (D-UX11 confirm-before-write) ────────────────────────────
|
|
59
86
|
const summary = [];
|
|
60
87
|
if (isUpdate) {
|
|
61
|
-
summary.push(`Update ${c.bold(`"${
|
|
62
|
-
summary.push(` version: v${matching.version} → v${resolve.version}`);
|
|
88
|
+
summary.push(`Update ${c.bold(`"${safeTitle}"`)} by @${safeAuthor}`);
|
|
89
|
+
summary.push(` version: v${ver(matching.version)} → v${ver(resolve.version)}`);
|
|
63
90
|
}
|
|
64
91
|
else {
|
|
65
|
-
summary.push(`Install ${c.bold(`"${
|
|
66
|
-
summary.push(` version: v${resolve.version}`);
|
|
92
|
+
summary.push(`Install ${c.bold(`"${safeTitle}"`)} by @${safeAuthor}`);
|
|
93
|
+
summary.push(` version: v${ver(resolve.version)}`);
|
|
67
94
|
}
|
|
68
95
|
summary.push(` files: ${resolve.file_count ?? "?"} files · ${formatBytes(Number(resolve.total_bytes) || 0)}`);
|
|
69
|
-
if (
|
|
70
|
-
summary.push(` license: ${
|
|
96
|
+
if (safeLicense)
|
|
97
|
+
summary.push(` license: ${safeLicense}`);
|
|
71
98
|
summary.push(` access: ${resolve.is_free === false ? "premium" : "free"}`);
|
|
72
|
-
summary.push(` to: ${
|
|
99
|
+
summary.push(` to: ${safeDir}`);
|
|
73
100
|
if (dryRun) {
|
|
74
101
|
if (json) {
|
|
75
102
|
ctx.io.out(JSON.stringify({
|
|
@@ -114,8 +141,15 @@ export async function runInstall(ctx, positionals, flags) {
|
|
|
114
141
|
if (!json) {
|
|
115
142
|
ctx.io.out(`Downloading ${resp.manifest.length} files…`);
|
|
116
143
|
const shown = resp.manifest.slice(0, 8);
|
|
117
|
-
|
|
118
|
-
|
|
144
|
+
// ⚠️ DISPLAY ONLY. `e.path` names the file `performInstall` writes and the sha256
|
|
145
|
+
// it verifies against, so the value itself must stay byte-identical — sanitizing
|
|
146
|
+
// it at the source would change what the integrity check runs on. Note these are
|
|
147
|
+
// printed BEFORE `assertSafeManifest` validates them (checkInstallResponse →
|
|
148
|
+
// print → performInstall → validate), so a path that is about to be REFUSED is
|
|
149
|
+
// rendered here; that ordering is what makes sanitizing this line necessary.
|
|
150
|
+
for (const e of shown) {
|
|
151
|
+
ctx.io.out(` ${terminalLine(e.path, SERVER_PATH_MAX)} ${c.dim(`(${formatBytes(e.bytes)})`)}`);
|
|
152
|
+
}
|
|
119
153
|
if (resp.manifest.length > shown.length) {
|
|
120
154
|
ctx.io.out(c.dim(` +${resp.manifest.length - shown.length} more`));
|
|
121
155
|
}
|
|
@@ -141,7 +175,7 @@ export async function runInstall(ctx, positionals, flags) {
|
|
|
141
175
|
return;
|
|
142
176
|
}
|
|
143
177
|
ctx.io.out("");
|
|
144
|
-
ctx.io.out(`${c.green("✓")} ${isUpdate ? "Updated" : "Installed"} "${resp.skill.title}" v${resp.skill.version} → ${picked.dir}`);
|
|
178
|
+
ctx.io.out(`${c.green("✓")} ${isUpdate ? "Updated" : "Installed"} "${terminalLine(resp.skill.title, SERVER_LABEL_MAX)}" v${terminalLine(String(resp.skill.version), SERVER_LABEL_MAX)} → ${terminalLine(picked.dir, SERVER_PATH_MAX)}`);
|
|
145
179
|
ctx.io.out(nextStepLine(picked.id, resp.skill.slug));
|
|
146
180
|
}
|
|
147
181
|
/**
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { CliContext } from "../context.js";
|
|
2
|
+
import { CliError } from "../errors.js";
|
|
2
3
|
type Flags = Record<string, string | boolean>;
|
|
4
|
+
/**
|
|
5
|
+
* A re-run line built from the PARSED invocation — never from `ctx.argsLine`.
|
|
6
|
+
*
|
|
7
|
+
* `argsLine` is the REDACTED copy that rides `X-Promptdock-Cli-Args`: context.ts
|
|
8
|
+
* replaces `--token` and `--dir` values with `***` so a live bearer and an absolute
|
|
9
|
+
* home path never reach the server or a proxy log. Echoing it back into a "Run: …"
|
|
10
|
+
* remedy printed `npx promptdock@latest install a/b --dir ***` — a command that
|
|
11
|
+
* cannot be pasted, handed to the user at the one moment they need a command they
|
|
12
|
+
* can paste. The parsed flags hold the REAL values, so the remedy is built from
|
|
13
|
+
* those and the redaction stays where it belongs: on the wire.
|
|
14
|
+
*/
|
|
15
|
+
export declare function rerunCommand(command: string, positionals: string[], flags: Flags): string;
|
|
16
|
+
export declare function newerSchemaError(dir: string, schema: number, rerun: string): CliError;
|
|
3
17
|
export declare function runUninstall(ctx: CliContext, positionals: string[], flags: Flags): Promise<void>;
|
|
4
18
|
export declare function runUpdate(ctx: CliContext, positionals: string[], flags: Flags): Promise<void>;
|
|
5
19
|
export declare function runList(ctx: CliContext, flags: Flags): Promise<void>;
|