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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-output hygiene - the ONE place this package decides what an untrusted
|
|
3
|
+
* string may write to a user's terminal.
|
|
4
|
+
*
|
|
5
|
+
* WARNING: THIS FILE EXISTS BECAUSE A DOC COMMENT IS NOT A CONTRACT. Its predecessor
|
|
6
|
+
* was a private `sanitizeForTerminal` inside api.ts whose comment stated a GENERAL
|
|
7
|
+
* rule ("strip control chars before a server-supplied string reaches the terminal")
|
|
8
|
+
* while the code was applied at exactly ONE call site - the redirect Location. Five
|
|
9
|
+
* sibling sites in the same function shipped raw for the life of the file, and the
|
|
10
|
+
* comment is what stopped anyone looking. So: the rule now lives in its own module,
|
|
11
|
+
* is exported, is unit-tested against a character table, and every guarantee below is
|
|
12
|
+
* one a test pins. Do not restate a guarantee here that no test holds.
|
|
13
|
+
*
|
|
14
|
+
* THE THREAT. A hostile or MITM'd API response (a 401 needs no valid token to
|
|
15
|
+
* produce, and a captive portal can mint any status) can put terminal control
|
|
16
|
+
* sequences into a string this CLI prints. What that buys an attacker, worst first:
|
|
17
|
+
* 1. NEWLINE injection - needs no escape at all, so it survives any strip-ESC
|
|
18
|
+
* filter. One newline in a server message forges a whole line of CLI output: a
|
|
19
|
+
* green "installed" tick, a fake footer URL, a fake prompt.
|
|
20
|
+
* 2. CSI erase/cursor-move (ESC, or the 8-bit C1 introducer U+009B) - repaints or
|
|
21
|
+
* ERASES legitimate output already on screen, including an install summary the
|
|
22
|
+
* user just approved.
|
|
23
|
+
* 3. OSC 52 - writes the system clipboard, so the developer's next paste is
|
|
24
|
+
* attacker-chosen. The only class here with an effect outside the terminal.
|
|
25
|
+
* 4. OSC 8 hyperlinks - displayed text is not the link target, aimed at the DX3
|
|
26
|
+
* footer URL a stuck user is told to click.
|
|
27
|
+
* 5. SGR conceal/reverse - hides text and forges this CLI's own red/green
|
|
28
|
+
* semantics.
|
|
29
|
+
* 6. Bidi overrides and isolates (Trojan Source) and zero-width characters - NO
|
|
30
|
+
* control byte and NO escape at all, so they defeat a strip-ESC filter AND every
|
|
31
|
+
* terminal-agnostic argument about which emulator honours which sequence.
|
|
32
|
+
*
|
|
33
|
+
* WHY C1 MATTERS EVEN THOUGH JSON HIDES ESC. Measured, not assumed: `JSON.stringify`
|
|
34
|
+
* escapes every C0 byte (ESC becomes the six inert characters backslash-u-0-0-1-b),
|
|
35
|
+
* but passes DEL, the whole C1 range - including U+009B, the 8-bit CSI introducer, as
|
|
36
|
+
* bytes c2 9b - and every bidi/zero-width character straight through. So the `--json`
|
|
37
|
+
* document a developer reads in their terminal is a partial sink, not a safe one.
|
|
38
|
+
*
|
|
39
|
+
* PORTED FROM `lib/seo/meta-text.ts`, which solved the same problem for HTML meta
|
|
40
|
+
* attributes and paid for two lessons this file inherits. It cannot be imported:
|
|
41
|
+
* packages/cli ships to npm on its own and must not reach into the app (the same
|
|
42
|
+
* constraint that makes test/footer-codes.test.ts a text-scanning parity test).
|
|
43
|
+
*
|
|
44
|
+
* WHERE TO APPLY IT, AND WHY THE ANSWER DIFFERS BY CHANNEL.
|
|
45
|
+
*
|
|
46
|
+
* For an ERROR, the right place is the trust BOUNDARY — `Api.envelope()` sanitizes
|
|
47
|
+
* `error.message` once and every CliError built from it is safe, including the ones in
|
|
48
|
+
* files that never touch `mapEnvelopeError`.
|
|
49
|
+
*
|
|
50
|
+
* For everything else it is the DISPLAY SITE, and that is not inconsistency. A server
|
|
51
|
+
* string like a manifest `path` is PRINTED and then USED: `path` names the file the
|
|
52
|
+
* installer writes and the sha256 it verifies against. Sanitizing it at the boundary
|
|
53
|
+
* would silently change the byte the integrity check runs on — the fix would become a
|
|
54
|
+
* supply-chain bug. The same is true of a `slug` that builds a URL, a `version_id` sent
|
|
55
|
+
* back to the server, and a `device_code`. So the rule is:
|
|
56
|
+
*
|
|
57
|
+
* sanitize the FRAGMENT, at the moment it is interpolated for a human to read,
|
|
58
|
+
* and never the value the program goes on to use.
|
|
59
|
+
*
|
|
60
|
+
* WARNING: SANITIZE THE FRAGMENT, NOT THE COMPOSED LINE. `terminalLine` collapses
|
|
61
|
+
* whitespace runs, so running it over an already-built line destroys deliberate
|
|
62
|
+
* alignment — install.ts's summary block pads its labels into columns
|
|
63
|
+
* (" version: v3"), and one pass over the finished string would flatten every one of
|
|
64
|
+
* them to a single space.
|
|
65
|
+
*
|
|
66
|
+
* WARNING: A `CliError` FRAGMENT STILL NEEDS `terminalLine`. The constructor backstop
|
|
67
|
+
* strips control characters, but it deliberately preserves newlines and applies no cap
|
|
68
|
+
* — so a hostile skill title inside an error message can still forge a second output
|
|
69
|
+
* line or run to a megabyte. The backstop makes an error repaint-safe; only the
|
|
70
|
+
* fragment treatment makes it BOUNDED.
|
|
71
|
+
*
|
|
72
|
+
* WARNING: EVERY INVISIBLE OR CONTROL CHARACTER IN THIS FILE IS A \uXXXX ESCAPE, AND
|
|
73
|
+
* MUST STAY ONE. A literal one is invisible in a diff, is the exact Trojan-Source
|
|
74
|
+
* shape semgrep's `contains-bidirectional-characters` rule fires on (docs/memory.md
|
|
75
|
+
* records that firing as a TRUE positive on a guard that embedded a raw U+202E), and
|
|
76
|
+
* cannot be pasted into a shell safely. Name characters by code point in prose.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* Trim to at most `max` CODE POINTS, marking the cut with an ellipsis.
|
|
80
|
+
*
|
|
81
|
+
* WARNING: Code points, not UTF-16 units. The predecessor iterated code points and
|
|
82
|
+
* then did `out.slice(0, 200)` on the result, so a string ending in an astral
|
|
83
|
+
* character was cut mid-pair: measured, it returned a lone high surrogate U+D83C, and
|
|
84
|
+
* `Buffer.from(out,"utf8").toString("utf8") !== out` - invalid UTF-8, emitted straight
|
|
85
|
+
* at the terminal. Emoji in a server error message is not hypothetical.
|
|
86
|
+
*
|
|
87
|
+
* The ellipsis is not decoration. A silently truncated remedy is a WRONG command the
|
|
88
|
+
* user pastes; a visibly truncated one is a command they can see is incomplete.
|
|
89
|
+
*
|
|
90
|
+
* THE CUT LANDS ON A GRAPHEME BOUNDARY ({@link graphemePrefix}). The budget is still
|
|
91
|
+
* counted in code points - the name and every caller's arithmetic stay true - but the
|
|
92
|
+
* cut backs off to the last whole grapheme that fits. WARNING: KEEPING THE JOINER
|
|
93
|
+
* CREATED A NEW WAY TO BREAK IT. Once {@link LONE_ZWJ} preserves a joiner inside an
|
|
94
|
+
* emoji, a plain code-point slice can cut that emoji in half and leave the joiner
|
|
95
|
+
* DANGLING in front of the ellipsis - the exact lone-joiner shape the rule exists to
|
|
96
|
+
* remove - or leave a couple where a family was. Grapheme segmentation handles every such
|
|
97
|
+
* case by construction (ZWJ sequences, skin tones, flag pairs, combining marks).
|
|
98
|
+
*
|
|
99
|
+
* The repaint invariant in ui.ts (`clampLine`: logical lines == physical rows) is
|
|
100
|
+
* untouched: a grapheme cut only ever lands EARLIER than the code-point cut would, so a
|
|
101
|
+
* clamped line can never get wider.
|
|
102
|
+
*/
|
|
103
|
+
export declare function clampCodePoints(s: string, max: number): string;
|
|
104
|
+
/**
|
|
105
|
+
* Collapse untrusted text to ONE safe, bounded line. Apply this at the trust
|
|
106
|
+
* BOUNDARY - the moment a server string enters this package - never at the terminal,
|
|
107
|
+
* where it is already mixed with the CLI's own escapes.
|
|
108
|
+
*
|
|
109
|
+
* Guarantees, each pinned by test/terminal-text.test.ts:
|
|
110
|
+
* - no C0, DEL or C1 byte survives (so no escape introducer, 8-bit forms included)
|
|
111
|
+
* - EVERY Unicode Bidi_Control code point is removed (all 12, swept by property in
|
|
112
|
+
* the test — not by example), plus the named zero-width and format characters
|
|
113
|
+
* listed on INVISIBLE. Other Default_Ignorable code points (variation selectors,
|
|
114
|
+
* Hangul fillers, Mongolian FVS) are NOT stripped and are not claimed to be:
|
|
115
|
+
* U+FE0F is load-bearing in legitimate emoji, so removing that whole property
|
|
116
|
+
* would corrupt real text to close a channel that cannot reorder anything.
|
|
117
|
+
* - U+200D is removed EXCEPT where it joins two emoji elements ({@link LONE_ZWJ}):
|
|
118
|
+
* every lone joiner goes, and every RGI emoji ZWJ sequence survives intact - the
|
|
119
|
+
* test sweeps an engine-validated corpus of 863, not a handful of examples.
|
|
120
|
+
* - the result contains no newline: an untrusted fragment is ONE line and cannot
|
|
121
|
+
* forge a second one. Line structure is the CLI's to compose, never the server's.
|
|
122
|
+
* - the result is at most `max` code points, and the clamp never splits a GRAPHEME -
|
|
123
|
+
* so never a surrogate pair, never an emoji sequence, and never a joiner left
|
|
124
|
+
* dangling in front of the ellipsis. (It cannot make an already ill-formed INPUT
|
|
125
|
+
* well-formed — a lone
|
|
126
|
+
* surrogate in the server's JSON stays one, though Node's UTF-8 encoder writes it
|
|
127
|
+
* out as U+FFFD, so no invalid byte reaches the terminal.)
|
|
128
|
+
*
|
|
129
|
+
* Control characters become a SPACE, never nothing: "line one\nline two" must not
|
|
130
|
+
* collapse to "line oneline two". The predecessor deleted them and did exactly that -
|
|
131
|
+
* measured. The whitespace pass then folds the runs it creates.
|
|
132
|
+
*/
|
|
133
|
+
export declare function terminalLine(raw: string | null | undefined, max: number): string;
|
|
134
|
+
/**
|
|
135
|
+
* Make already-composed text safe WITHOUT touching its line structure - the
|
|
136
|
+
* defence-in-depth backstop applied in the `CliError` constructor.
|
|
137
|
+
*
|
|
138
|
+
* Two deliberate differences from {@link terminalLine}, both load-bearing:
|
|
139
|
+
*
|
|
140
|
+
* 1. NEWLINE SURVIVES. Exactly two repo-authored CliError messages are multi-line and
|
|
141
|
+
* both are legitimate: api.ts's 426 joins message and remedy so the remedy keeps
|
|
142
|
+
* its own copy-pasteable line, and lifecycle.ts's batch roll-up is one indented
|
|
143
|
+
* line per failed item. A newline-stripping backstop would jam the remedy onto the
|
|
144
|
+
* sentence - and integration.test.ts's two separate `toContain`s would still pass
|
|
145
|
+
* while it did.
|
|
146
|
+
*
|
|
147
|
+
* 2. NO LENGTH CAP. The `--json` batch roll-up is legitimately around 435 characters
|
|
148
|
+
* with real temp paths; a 200-cap drops the second failure and batch-exit.test.ts
|
|
149
|
+
* asserts two. Bounding length is the boundary's job, where the untrusted fragment
|
|
150
|
+
* is still identifiable as untrusted.
|
|
151
|
+
*
|
|
152
|
+
* This is a BACKSTOP, not the primary control: it cannot tell an untrusted fragment
|
|
153
|
+
* from the CLI's own prose, so it can only strip what is never legitimate anywhere.
|
|
154
|
+
* A future call site that reads a new server field still owes it a `terminalLine`.
|
|
155
|
+
*
|
|
156
|
+
* THE JOINER RULE IS THE SAME HERE AS IN {@link terminalLine}, DELIBERATELY - it is NOT a
|
|
157
|
+
* third difference. Two reasons, and the first is sufficient on its own:
|
|
158
|
+
* - This backstop runs over COMPOSED messages that already contain line-sanitised
|
|
159
|
+
* fragments: verdicts.ts builds `${title} needs promptdock CLI ...` from a
|
|
160
|
+
* `terminalLine`'d skill title and hands it to `new CliError`, whose constructor
|
|
161
|
+
* calls this. A block pass that still deleted every U+200D would re-break, in every
|
|
162
|
+
* error message, the emoji title that line mode had just preserved - the fix would
|
|
163
|
+
* reach the install summary and silently miss every refusal.
|
|
164
|
+
* - "Strip only what is never legitimate anywhere" IS the contextual rule. A joiner
|
|
165
|
+
* inside an emoji sequence is legitimate text; a lone one never is.
|
|
166
|
+
*/
|
|
167
|
+
export declare function terminalBlock(raw: string): string;
|
|
168
|
+
/**
|
|
169
|
+
* A redirect `Location`, which is a URL - the original 200, and the one call site the
|
|
170
|
+
* predecessor actually covered. Also the right cap for the device-flow
|
|
171
|
+
* `verification_url`, which is the same kind of thing.
|
|
172
|
+
*/
|
|
173
|
+
export declare const LOCATION_MAX = 200;
|
|
174
|
+
/**
|
|
175
|
+
* A short identifying LABEL a server sends for display: a skill title or license, a
|
|
176
|
+
* handle, a slug, a role, a version string, a pairing code.
|
|
177
|
+
*
|
|
178
|
+
* 120 is the largest of those bounds, not a round number — `skills.title` is capped at
|
|
179
|
+
* 120 server-side, and everything else is smaller by construction (the slug regex
|
|
180
|
+
* `^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$` tops out at 64, the handle regex at 63, a role is a
|
|
181
|
+
* closed set, a `user_code` is the 9-character `XXXX-XXXX`). So a legitimate value is
|
|
182
|
+
* never clamped and the cap only ever bounds a hostile one.
|
|
183
|
+
*/
|
|
184
|
+
export declare const SERVER_LABEL_MAX = 120;
|
|
185
|
+
/**
|
|
186
|
+
* A manifest file path, printed in the download list and in the refusal
|
|
187
|
+
* `assertSafeManifest` throws.
|
|
188
|
+
*
|
|
189
|
+
* 256 EXACTLY MIRRORS `validateSkillPaths` in paths.ts, which rejects any path longer
|
|
190
|
+
* than that — so this cap can never truncate a path the CLI would go on to accept, and a
|
|
191
|
+
* path being clamped here is one that was already refused.
|
|
192
|
+
*/
|
|
193
|
+
export declare const SERVER_PATH_MAX = 256;
|
|
194
|
+
/**
|
|
195
|
+
* Server prose (`error.message`). Generous, because the point is to bound a hostile
|
|
196
|
+
* megabyte rather than to edit an honest sentence - today's longest real one is the
|
|
197
|
+
* 426's, at 67 characters.
|
|
198
|
+
*/
|
|
199
|
+
export declare const SERVER_MESSAGE_MAX = 500;
|
|
200
|
+
/**
|
|
201
|
+
* The 426 `details.remedy` - a copy-pasteable command, so the cap needs measured
|
|
202
|
+
* headroom rather than a round number.
|
|
203
|
+
*
|
|
204
|
+
* ARITHMETIC (server side, lib/cli-auth/protocol.ts `upgradeRemedy`): the prefix
|
|
205
|
+
* "Run: npx promptdock@latest " is 27 characters and the echoed args are already
|
|
206
|
+
* allowlisted and sliced to 120 there, so a legitimate remedy tops out at 147. 256
|
|
207
|
+
* leaves room for the prefix to grow without ever truncating an honest command.
|
|
208
|
+
*
|
|
209
|
+
* WARNING: AND THE CAP IS APPLIED TO THE REMEDY ALONE, BEFORE api.ts joins it to the
|
|
210
|
+
* message. Applied after the join it would be measured against about 215 characters
|
|
211
|
+
* worst case and cut the tail off the very command the remedy exists to provide.
|
|
212
|
+
*/
|
|
213
|
+
export declare const SERVER_REMEDY_MAX = 256;
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-output hygiene - the ONE place this package decides what an untrusted
|
|
3
|
+
* string may write to a user's terminal.
|
|
4
|
+
*
|
|
5
|
+
* WARNING: THIS FILE EXISTS BECAUSE A DOC COMMENT IS NOT A CONTRACT. Its predecessor
|
|
6
|
+
* was a private `sanitizeForTerminal` inside api.ts whose comment stated a GENERAL
|
|
7
|
+
* rule ("strip control chars before a server-supplied string reaches the terminal")
|
|
8
|
+
* while the code was applied at exactly ONE call site - the redirect Location. Five
|
|
9
|
+
* sibling sites in the same function shipped raw for the life of the file, and the
|
|
10
|
+
* comment is what stopped anyone looking. So: the rule now lives in its own module,
|
|
11
|
+
* is exported, is unit-tested against a character table, and every guarantee below is
|
|
12
|
+
* one a test pins. Do not restate a guarantee here that no test holds.
|
|
13
|
+
*
|
|
14
|
+
* THE THREAT. A hostile or MITM'd API response (a 401 needs no valid token to
|
|
15
|
+
* produce, and a captive portal can mint any status) can put terminal control
|
|
16
|
+
* sequences into a string this CLI prints. What that buys an attacker, worst first:
|
|
17
|
+
* 1. NEWLINE injection - needs no escape at all, so it survives any strip-ESC
|
|
18
|
+
* filter. One newline in a server message forges a whole line of CLI output: a
|
|
19
|
+
* green "installed" tick, a fake footer URL, a fake prompt.
|
|
20
|
+
* 2. CSI erase/cursor-move (ESC, or the 8-bit C1 introducer U+009B) - repaints or
|
|
21
|
+
* ERASES legitimate output already on screen, including an install summary the
|
|
22
|
+
* user just approved.
|
|
23
|
+
* 3. OSC 52 - writes the system clipboard, so the developer's next paste is
|
|
24
|
+
* attacker-chosen. The only class here with an effect outside the terminal.
|
|
25
|
+
* 4. OSC 8 hyperlinks - displayed text is not the link target, aimed at the DX3
|
|
26
|
+
* footer URL a stuck user is told to click.
|
|
27
|
+
* 5. SGR conceal/reverse - hides text and forges this CLI's own red/green
|
|
28
|
+
* semantics.
|
|
29
|
+
* 6. Bidi overrides and isolates (Trojan Source) and zero-width characters - NO
|
|
30
|
+
* control byte and NO escape at all, so they defeat a strip-ESC filter AND every
|
|
31
|
+
* terminal-agnostic argument about which emulator honours which sequence.
|
|
32
|
+
*
|
|
33
|
+
* WHY C1 MATTERS EVEN THOUGH JSON HIDES ESC. Measured, not assumed: `JSON.stringify`
|
|
34
|
+
* escapes every C0 byte (ESC becomes the six inert characters backslash-u-0-0-1-b),
|
|
35
|
+
* but passes DEL, the whole C1 range - including U+009B, the 8-bit CSI introducer, as
|
|
36
|
+
* bytes c2 9b - and every bidi/zero-width character straight through. So the `--json`
|
|
37
|
+
* document a developer reads in their terminal is a partial sink, not a safe one.
|
|
38
|
+
*
|
|
39
|
+
* PORTED FROM `lib/seo/meta-text.ts`, which solved the same problem for HTML meta
|
|
40
|
+
* attributes and paid for two lessons this file inherits. It cannot be imported:
|
|
41
|
+
* packages/cli ships to npm on its own and must not reach into the app (the same
|
|
42
|
+
* constraint that makes test/footer-codes.test.ts a text-scanning parity test).
|
|
43
|
+
*
|
|
44
|
+
* WHERE TO APPLY IT, AND WHY THE ANSWER DIFFERS BY CHANNEL.
|
|
45
|
+
*
|
|
46
|
+
* For an ERROR, the right place is the trust BOUNDARY — `Api.envelope()` sanitizes
|
|
47
|
+
* `error.message` once and every CliError built from it is safe, including the ones in
|
|
48
|
+
* files that never touch `mapEnvelopeError`.
|
|
49
|
+
*
|
|
50
|
+
* For everything else it is the DISPLAY SITE, and that is not inconsistency. A server
|
|
51
|
+
* string like a manifest `path` is PRINTED and then USED: `path` names the file the
|
|
52
|
+
* installer writes and the sha256 it verifies against. Sanitizing it at the boundary
|
|
53
|
+
* would silently change the byte the integrity check runs on — the fix would become a
|
|
54
|
+
* supply-chain bug. The same is true of a `slug` that builds a URL, a `version_id` sent
|
|
55
|
+
* back to the server, and a `device_code`. So the rule is:
|
|
56
|
+
*
|
|
57
|
+
* sanitize the FRAGMENT, at the moment it is interpolated for a human to read,
|
|
58
|
+
* and never the value the program goes on to use.
|
|
59
|
+
*
|
|
60
|
+
* WARNING: SANITIZE THE FRAGMENT, NOT THE COMPOSED LINE. `terminalLine` collapses
|
|
61
|
+
* whitespace runs, so running it over an already-built line destroys deliberate
|
|
62
|
+
* alignment — install.ts's summary block pads its labels into columns
|
|
63
|
+
* (" version: v3"), and one pass over the finished string would flatten every one of
|
|
64
|
+
* them to a single space.
|
|
65
|
+
*
|
|
66
|
+
* WARNING: A `CliError` FRAGMENT STILL NEEDS `terminalLine`. The constructor backstop
|
|
67
|
+
* strips control characters, but it deliberately preserves newlines and applies no cap
|
|
68
|
+
* — so a hostile skill title inside an error message can still forge a second output
|
|
69
|
+
* line or run to a megabyte. The backstop makes an error repaint-safe; only the
|
|
70
|
+
* fragment treatment makes it BOUNDED.
|
|
71
|
+
*
|
|
72
|
+
* WARNING: EVERY INVISIBLE OR CONTROL CHARACTER IN THIS FILE IS A \uXXXX ESCAPE, AND
|
|
73
|
+
* MUST STAY ONE. A literal one is invisible in a diff, is the exact Trojan-Source
|
|
74
|
+
* shape semgrep's `contains-bidirectional-characters` rule fires on (docs/memory.md
|
|
75
|
+
* records that firing as a TRUE positive on a guard that embedded a raw U+202E), and
|
|
76
|
+
* cannot be pasted into a shell safely. Name characters by code point in prose.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* Invisible and bidi-reordering code points, DELETED rather than spaced - unlike a
|
|
80
|
+
* control character, replacing one with a space inserts a gap nobody wrote.
|
|
81
|
+
*
|
|
82
|
+
* WARNING: THE RANGE STOPS AT U+202E, one short of U+202F: NARROW NO-BREAK SPACE is
|
|
83
|
+
* real whitespace and must become a space, not vanish. `lib/seo/meta-text.ts` records
|
|
84
|
+
* shipping a draft that covered only U+200B-U+200F and U+2060-U+206F, so RLO
|
|
85
|
+
* (U+202E) - the one that actually reverses rendered text - sailed through while the
|
|
86
|
+
* comment claimed otherwise. Caught there by a unit test, not by review.
|
|
87
|
+
*
|
|
88
|
+
* THE TWO CLASSES NOW HOLD THE SAME MEMBERS, and packages/cli/test/zwj-parity.test.ts
|
|
89
|
+
* pins that as text. This paragraph used to say this class was WIDER than meta-text's -
|
|
90
|
+
* that meta-text skipped the bidi ISOLATES (U+2066-U+2069) and left U+FEFF to its
|
|
91
|
+
* whitespace pass. Both were true when written and both went stale on 2026-09-10, when
|
|
92
|
+
* meta-text was widened to every Bidi_Control, U+00AD and U+FEFF. A comment describing a
|
|
93
|
+
* sibling file is a claim nothing checks, which is why the parity is now a test.
|
|
94
|
+
*
|
|
95
|
+
* U+FEFF is included for a reason that holds here regardless: the whitespace pass exists
|
|
96
|
+
* only in line mode, block mode below has none, so the class has to carry it.
|
|
97
|
+
*
|
|
98
|
+
* WARNING: U+200D ZERO WIDTH JOINER IS NOT IN THIS CLASS, AND IT USED TO BE. It sat inside
|
|
99
|
+
* U+200B-U+200F, so every emoji ZWJ sequence reaching the terminal was broken: measured
|
|
100
|
+
* through the equivalent web function, the superhero went 4 code points to 3 and
|
|
101
|
+
* rendered as two glyphs, the family 5 to 3 (three separate people), the rainbow flag
|
|
102
|
+
* 4 to 3 (a flag beside a rainbow). Skill titles, author names and server messages all
|
|
103
|
+
* reach the terminal through this module, and emoji in a title are common. It is now
|
|
104
|
+
* deleted CONTEXTUALLY, by {@link LONE_ZWJ}. NEVER collapse this class back to the
|
|
105
|
+
* U+200B-U+200F range: that one edit re-adds the joiner.
|
|
106
|
+
*/
|
|
107
|
+
const INVISIBLE = /[\u00ad\u061c\u200b-\u200c\u200e-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/gu;
|
|
108
|
+
/**
|
|
109
|
+
* A ZERO WIDTH JOINER that is not joining two emoji elements - DELETED. A joiner
|
|
110
|
+
* holding an emoji sequence together is KEPT.
|
|
111
|
+
*
|
|
112
|
+
* The rule is contextual rather than a removal from the class, because deleting a LONE
|
|
113
|
+
* joiner is still exactly right: outside an emoji sequence it is invisible padding, and
|
|
114
|
+
* "Pay" + U+200D + "Pal" becoming "PayPal" is the obfuscation this module exists to
|
|
115
|
+
* remove. A joiner survives only when its left neighbour ends an emoji element and its
|
|
116
|
+
* right neighbour starts one.
|
|
117
|
+
*
|
|
118
|
+
* WHAT MAY SIT BETWEEN THE PICTOGRAPH AND THE JOINER, and both are load-bearing. UTS #51
|
|
119
|
+
* defines a ZWJ element as a pictograph, a pictograph plus U+FE0F, or a pictograph plus
|
|
120
|
+
* a skin-tone modifier:
|
|
121
|
+
* - U+FE0F: the rainbow flag is U+1F3F3 U+FE0F U+200D U+1F308.
|
|
122
|
+
* - Emoji_Modifier, U+1F3FB-U+1F3FF. WARNING: THE WEB RULE THIS WAS PORTED FROM ALLOWED
|
|
123
|
+
* ONLY U+FE0F, AND IT BROKE EVERY SKIN-TONED SEQUENCE. The Fitzpatrick modifiers are
|
|
124
|
+
* Emoji_Modifier, not Extended_Pictographic (measured: false for all five). Against
|
|
125
|
+
* 863 ZWJ sequences validated by the engine's own RGI_Emoji data, that version broke
|
|
126
|
+
* 715 - every one carrying a skin tone. Found while porting it, fixed on both sides in
|
|
127
|
+
* the same change; this version breaks 0. The test file sweeps that corpus for
|
|
128
|
+
* exactly this reason.
|
|
129
|
+
*
|
|
130
|
+
* WARNING: THIS REGEX IS lib/seo/meta-text.ts's LONE_ZWJ, BYTE FOR BYTE, and
|
|
131
|
+
* test/zwj-parity.test.ts fails the build if the two ever differ. It was generated from
|
|
132
|
+
* that source rather than retyped - retyping is how a literal invisible creeps back in.
|
|
133
|
+
*
|
|
134
|
+
* Lookbehind and property escapes are u-flag features every Node >= 18 parses - but a
|
|
135
|
+
* property escape also needs ICU, which is why the regex is BUILT (see loneJoinerRule)
|
|
136
|
+
* rather than written as a literal.
|
|
137
|
+
*/
|
|
138
|
+
const LONE_ZWJ = loneJoinerRule();
|
|
139
|
+
/**
|
|
140
|
+
* Build {@link LONE_ZWJ} WITHOUT letting a runtime that lacks ICU take the module down.
|
|
141
|
+
*
|
|
142
|
+
* WARNING: THIS IS A `new RegExp` FROM A STRING, NOT A REGEX LITERAL, AND THAT IS THE
|
|
143
|
+
* WHOLE POINT. A Node built `--with-intl=none` has no Unicode property escapes: V8
|
|
144
|
+
* rejects every `\p{...}` as an invalid regular expression. In a LITERAL that rejection
|
|
145
|
+
* happens when the file is PARSED, so it is a SyntaxError no try can catch, and in the
|
|
146
|
+
* published CLI it killed every command at load - `--help` and `--version` included -
|
|
147
|
+
* with a raw stack trace instead of a CliError (review finding, reproduced on Node 18;
|
|
148
|
+
* the CLI declares `engines: node >= 18` and no ICU requirement). Constructed from a
|
|
149
|
+
* string, the same rejection is an ordinary exception, and the rule degrades to what it
|
|
150
|
+
* was before 2026-09-10: every joiner deleted. Emoji ZWJ sequences break on such a
|
|
151
|
+
* runtime, and nothing else does.
|
|
152
|
+
*
|
|
153
|
+
* packages/cli/test/terminal-text.test.ts fails the build on any `\p{` regex LITERAL
|
|
154
|
+
* in the CLI source, because this failure mode cannot be reproduced at runtime on a
|
|
155
|
+
* build that HAS ICU - a patched RegExp constructor never sees a literal.
|
|
156
|
+
*/
|
|
157
|
+
function loneJoinerRule() {
|
|
158
|
+
try {
|
|
159
|
+
return new RegExp("(?<!\\p{Extended_Pictographic}(?:\\uFE0F|\\p{Emoji_Modifier})?)\\u200D|\\u200D(?!\\p{Extended_Pictographic})", "gu");
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// No property escapes on this runtime (a V8 without ICU).
|
|
163
|
+
return /\u200D/gu;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Every escape INTRODUCER: C0 (where newline, carriage return and tab live -
|
|
168
|
+
* deliberately), DEL, and C1. Removing the introducer leaves any payload behind it as
|
|
169
|
+
* inert printable text, which is why this is a complete answer to threat classes 2-5
|
|
170
|
+
* above rather than a best-effort sequence matcher. There is no allowlist of "safe"
|
|
171
|
+
* sequences to maintain and no parser to keep in step with a terminal's.
|
|
172
|
+
*/
|
|
173
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
174
|
+
/** The same class MINUS U+000A. See {@link terminalBlock} for why newline survives. */
|
|
175
|
+
const CONTROL_KEEP_LF = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu;
|
|
176
|
+
/**
|
|
177
|
+
* Trim to at most `max` CODE POINTS, marking the cut with an ellipsis.
|
|
178
|
+
*
|
|
179
|
+
* WARNING: Code points, not UTF-16 units. The predecessor iterated code points and
|
|
180
|
+
* then did `out.slice(0, 200)` on the result, so a string ending in an astral
|
|
181
|
+
* character was cut mid-pair: measured, it returned a lone high surrogate U+D83C, and
|
|
182
|
+
* `Buffer.from(out,"utf8").toString("utf8") !== out` - invalid UTF-8, emitted straight
|
|
183
|
+
* at the terminal. Emoji in a server error message is not hypothetical.
|
|
184
|
+
*
|
|
185
|
+
* The ellipsis is not decoration. A silently truncated remedy is a WRONG command the
|
|
186
|
+
* user pastes; a visibly truncated one is a command they can see is incomplete.
|
|
187
|
+
*
|
|
188
|
+
* THE CUT LANDS ON A GRAPHEME BOUNDARY ({@link graphemePrefix}). The budget is still
|
|
189
|
+
* counted in code points - the name and every caller's arithmetic stay true - but the
|
|
190
|
+
* cut backs off to the last whole grapheme that fits. WARNING: KEEPING THE JOINER
|
|
191
|
+
* CREATED A NEW WAY TO BREAK IT. Once {@link LONE_ZWJ} preserves a joiner inside an
|
|
192
|
+
* emoji, a plain code-point slice can cut that emoji in half and leave the joiner
|
|
193
|
+
* DANGLING in front of the ellipsis - the exact lone-joiner shape the rule exists to
|
|
194
|
+
* remove - or leave a couple where a family was. Grapheme segmentation handles every such
|
|
195
|
+
* case by construction (ZWJ sequences, skin tones, flag pairs, combining marks).
|
|
196
|
+
*
|
|
197
|
+
* The repaint invariant in ui.ts (`clampLine`: logical lines == physical rows) is
|
|
198
|
+
* untouched: a grapheme cut only ever lands EARLIER than the code-point cut would, so a
|
|
199
|
+
* clamped line can never get wider.
|
|
200
|
+
*/
|
|
201
|
+
export function clampCodePoints(s, max) {
|
|
202
|
+
if (Array.from(s).length <= max)
|
|
203
|
+
return s;
|
|
204
|
+
// One code point of the budget belongs to the ellipsis.
|
|
205
|
+
return graphemePrefix(s, Math.max(0, max - 1)) + "…";
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The longest prefix of `s` that fits in `budget` CODE POINTS and ends on a grapheme
|
|
209
|
+
* boundary. A grapheme larger than the whole budget yields an empty prefix: half an
|
|
210
|
+
* emoji is worse than none, and the caller's ellipsis still says something was cut.
|
|
211
|
+
*
|
|
212
|
+
* `Intl.Segmenter` is read at CALL time, through `globalThis` so that a runtime with no
|
|
213
|
+
* `Intl` at all (a Node built without ICU) is covered too. A runtime without it - or one
|
|
214
|
+
* that throws constructing it - falls back to a
|
|
215
|
+
* code-point cut with any trailing joiner removed, so the no-dangling-joiner guarantee
|
|
216
|
+
* holds on BOTH paths and a test can exercise the fallback by removing the constructor.
|
|
217
|
+
* A published CLI runs on Node builds this repo never sees, so the fallback is not
|
|
218
|
+
* hypothetical, and a clamp that could throw would turn a long title into a crash.
|
|
219
|
+
*
|
|
220
|
+
* WARNING: lib/seo/meta-text.ts carries the same function for its own clamp.
|
|
221
|
+
*/
|
|
222
|
+
function graphemePrefix(s, budget) {
|
|
223
|
+
if (budget <= 0)
|
|
224
|
+
return "";
|
|
225
|
+
// `Intl` itself does not exist on a runtime built without ICU, so a bare `Intl.Segmenter`
|
|
226
|
+
// throws ReferenceError before the try below can help. Read it through globalThis.
|
|
227
|
+
const Segmenter = globalThis.Intl?.Segmenter;
|
|
228
|
+
if (typeof Segmenter === "function") {
|
|
229
|
+
try {
|
|
230
|
+
let out = "";
|
|
231
|
+
let used = 0;
|
|
232
|
+
for (const { segment } of new Segmenter(undefined, { granularity: "grapheme" }).segment(s)) {
|
|
233
|
+
const n = Array.from(segment).length;
|
|
234
|
+
if (used + n > budget)
|
|
235
|
+
break;
|
|
236
|
+
out += segment;
|
|
237
|
+
used += n;
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// A build without the break-iterator data. Fall through to the code-point cut.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return Array.from(s).slice(0, budget).join("").replace(/\u200D+$/u, "");
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Collapse untrusted text to ONE safe, bounded line. Apply this at the trust
|
|
249
|
+
* BOUNDARY - the moment a server string enters this package - never at the terminal,
|
|
250
|
+
* where it is already mixed with the CLI's own escapes.
|
|
251
|
+
*
|
|
252
|
+
* Guarantees, each pinned by test/terminal-text.test.ts:
|
|
253
|
+
* - no C0, DEL or C1 byte survives (so no escape introducer, 8-bit forms included)
|
|
254
|
+
* - EVERY Unicode Bidi_Control code point is removed (all 12, swept by property in
|
|
255
|
+
* the test — not by example), plus the named zero-width and format characters
|
|
256
|
+
* listed on INVISIBLE. Other Default_Ignorable code points (variation selectors,
|
|
257
|
+
* Hangul fillers, Mongolian FVS) are NOT stripped and are not claimed to be:
|
|
258
|
+
* U+FE0F is load-bearing in legitimate emoji, so removing that whole property
|
|
259
|
+
* would corrupt real text to close a channel that cannot reorder anything.
|
|
260
|
+
* - U+200D is removed EXCEPT where it joins two emoji elements ({@link LONE_ZWJ}):
|
|
261
|
+
* every lone joiner goes, and every RGI emoji ZWJ sequence survives intact - the
|
|
262
|
+
* test sweeps an engine-validated corpus of 863, not a handful of examples.
|
|
263
|
+
* - the result contains no newline: an untrusted fragment is ONE line and cannot
|
|
264
|
+
* forge a second one. Line structure is the CLI's to compose, never the server's.
|
|
265
|
+
* - the result is at most `max` code points, and the clamp never splits a GRAPHEME -
|
|
266
|
+
* so never a surrogate pair, never an emoji sequence, and never a joiner left
|
|
267
|
+
* dangling in front of the ellipsis. (It cannot make an already ill-formed INPUT
|
|
268
|
+
* well-formed — a lone
|
|
269
|
+
* surrogate in the server's JSON stays one, though Node's UTF-8 encoder writes it
|
|
270
|
+
* out as U+FFFD, so no invalid byte reaches the terminal.)
|
|
271
|
+
*
|
|
272
|
+
* Control characters become a SPACE, never nothing: "line one\nline two" must not
|
|
273
|
+
* collapse to "line oneline two". The predecessor deleted them and did exactly that -
|
|
274
|
+
* measured. The whitespace pass then folds the runs it creates.
|
|
275
|
+
*/
|
|
276
|
+
export function terminalLine(raw, max) {
|
|
277
|
+
if (!raw)
|
|
278
|
+
return "";
|
|
279
|
+
const flat = raw
|
|
280
|
+
.replace(LONE_ZWJ, "")
|
|
281
|
+
.replace(INVISIBLE, "")
|
|
282
|
+
.replace(CONTROL, " ")
|
|
283
|
+
.replace(/\s+/gu, " ")
|
|
284
|
+
.trim();
|
|
285
|
+
return clampCodePoints(flat, max);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Make already-composed text safe WITHOUT touching its line structure - the
|
|
289
|
+
* defence-in-depth backstop applied in the `CliError` constructor.
|
|
290
|
+
*
|
|
291
|
+
* Two deliberate differences from {@link terminalLine}, both load-bearing:
|
|
292
|
+
*
|
|
293
|
+
* 1. NEWLINE SURVIVES. Exactly two repo-authored CliError messages are multi-line and
|
|
294
|
+
* both are legitimate: api.ts's 426 joins message and remedy so the remedy keeps
|
|
295
|
+
* its own copy-pasteable line, and lifecycle.ts's batch roll-up is one indented
|
|
296
|
+
* line per failed item. A newline-stripping backstop would jam the remedy onto the
|
|
297
|
+
* sentence - and integration.test.ts's two separate `toContain`s would still pass
|
|
298
|
+
* while it did.
|
|
299
|
+
*
|
|
300
|
+
* 2. NO LENGTH CAP. The `--json` batch roll-up is legitimately around 435 characters
|
|
301
|
+
* with real temp paths; a 200-cap drops the second failure and batch-exit.test.ts
|
|
302
|
+
* asserts two. Bounding length is the boundary's job, where the untrusted fragment
|
|
303
|
+
* is still identifiable as untrusted.
|
|
304
|
+
*
|
|
305
|
+
* This is a BACKSTOP, not the primary control: it cannot tell an untrusted fragment
|
|
306
|
+
* from the CLI's own prose, so it can only strip what is never legitimate anywhere.
|
|
307
|
+
* A future call site that reads a new server field still owes it a `terminalLine`.
|
|
308
|
+
*
|
|
309
|
+
* THE JOINER RULE IS THE SAME HERE AS IN {@link terminalLine}, DELIBERATELY - it is NOT a
|
|
310
|
+
* third difference. Two reasons, and the first is sufficient on its own:
|
|
311
|
+
* - This backstop runs over COMPOSED messages that already contain line-sanitised
|
|
312
|
+
* fragments: verdicts.ts builds `${title} needs promptdock CLI ...` from a
|
|
313
|
+
* `terminalLine`'d skill title and hands it to `new CliError`, whose constructor
|
|
314
|
+
* calls this. A block pass that still deleted every U+200D would re-break, in every
|
|
315
|
+
* error message, the emoji title that line mode had just preserved - the fix would
|
|
316
|
+
* reach the install summary and silently miss every refusal.
|
|
317
|
+
* - "Strip only what is never legitimate anywhere" IS the contextual rule. A joiner
|
|
318
|
+
* inside an emoji sequence is legitimate text; a lone one never is.
|
|
319
|
+
*/
|
|
320
|
+
export function terminalBlock(raw) {
|
|
321
|
+
return raw.replace(LONE_ZWJ, "").replace(INVISIBLE, "").replace(CONTROL_KEEP_LF, " ");
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* A redirect `Location`, which is a URL - the original 200, and the one call site the
|
|
325
|
+
* predecessor actually covered. Also the right cap for the device-flow
|
|
326
|
+
* `verification_url`, which is the same kind of thing.
|
|
327
|
+
*/
|
|
328
|
+
export const LOCATION_MAX = 200;
|
|
329
|
+
/**
|
|
330
|
+
* A short identifying LABEL a server sends for display: a skill title or license, a
|
|
331
|
+
* handle, a slug, a role, a version string, a pairing code.
|
|
332
|
+
*
|
|
333
|
+
* 120 is the largest of those bounds, not a round number — `skills.title` is capped at
|
|
334
|
+
* 120 server-side, and everything else is smaller by construction (the slug regex
|
|
335
|
+
* `^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$` tops out at 64, the handle regex at 63, a role is a
|
|
336
|
+
* closed set, a `user_code` is the 9-character `XXXX-XXXX`). So a legitimate value is
|
|
337
|
+
* never clamped and the cap only ever bounds a hostile one.
|
|
338
|
+
*/
|
|
339
|
+
export const SERVER_LABEL_MAX = 120;
|
|
340
|
+
/**
|
|
341
|
+
* A manifest file path, printed in the download list and in the refusal
|
|
342
|
+
* `assertSafeManifest` throws.
|
|
343
|
+
*
|
|
344
|
+
* 256 EXACTLY MIRRORS `validateSkillPaths` in paths.ts, which rejects any path longer
|
|
345
|
+
* than that — so this cap can never truncate a path the CLI would go on to accept, and a
|
|
346
|
+
* path being clamped here is one that was already refused.
|
|
347
|
+
*/
|
|
348
|
+
export const SERVER_PATH_MAX = 256;
|
|
349
|
+
/**
|
|
350
|
+
* Server prose (`error.message`). Generous, because the point is to bound a hostile
|
|
351
|
+
* megabyte rather than to edit an honest sentence - today's longest real one is the
|
|
352
|
+
* 426's, at 67 characters.
|
|
353
|
+
*/
|
|
354
|
+
export const SERVER_MESSAGE_MAX = 500;
|
|
355
|
+
/**
|
|
356
|
+
* The 426 `details.remedy` - a copy-pasteable command, so the cap needs measured
|
|
357
|
+
* headroom rather than a round number.
|
|
358
|
+
*
|
|
359
|
+
* ARITHMETIC (server side, lib/cli-auth/protocol.ts `upgradeRemedy`): the prefix
|
|
360
|
+
* "Run: npx promptdock@latest " is 27 characters and the echoed args are already
|
|
361
|
+
* allowlisted and sliced to 120 there, so a legitimate remedy tops out at 147. 256
|
|
362
|
+
* leaves room for the prefix to grow without ever truncating an honest command.
|
|
363
|
+
*
|
|
364
|
+
* WARNING: AND THE CAP IS APPLIED TO THE REMEDY ALONE, BEFORE api.ts joins it to the
|
|
365
|
+
* message. Applied after the join it would be measured against about 215 characters
|
|
366
|
+
* worst case and cut the tail off the very command the remedy exists to provide.
|
|
367
|
+
*/
|
|
368
|
+
export const SERVER_REMEDY_MAX = 256;
|
package/dist/ui.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clampCodePoints } from "./terminal-text.js";
|
|
1
2
|
import { CliError, EXIT } from "./errors.js";
|
|
2
3
|
import { reduceSelectKey, selectHint } from "./select-keys.js";
|
|
3
4
|
/** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
|
|
@@ -78,10 +79,12 @@ export async function promptSelect(io, title, options, preselect, c, hotkeys, ho
|
|
|
78
79
|
* prompt's absolute paths wrap on a stock 80-col terminal). Clamping before
|
|
79
80
|
* colorization keeps logical lines == physical rows by construction. */
|
|
80
81
|
function clampLine(line, columns) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
// ⚠️ CODE POINTS, NOT UTF-16 UNITS. `.length`/`.slice` cut an astral character in
|
|
83
|
+
// half — measured, this returned a lone high surrogate, which is not valid UTF-8.
|
|
84
|
+
// The picker paints this straight into a repaint frame, and an emoji in a skill
|
|
85
|
+
// title is not hypothetical. Same defect `clampCodePoints` exists to prevent, so it
|
|
86
|
+
// is reused rather than re-derived.
|
|
87
|
+
return clampCodePoints(line, Math.max(8, columns - 1));
|
|
85
88
|
}
|
|
86
89
|
async function interactiveSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel) {
|
|
87
90
|
const count = options.length;
|
package/dist/verdicts.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ResolveResponse } from "./contract.js";
|
|
2
|
-
|
|
2
|
+
/** ⚠️ DERIVED from `CANONICAL_ORIGIN`. This is the CTA a user follows the moment the CLI
|
|
3
|
+
* tells them their plan is too low — the one link most likely to be clicked under
|
|
4
|
+
* friction, so it must not spend a redirect hop. */
|
|
5
|
+
export declare const UPGRADE_URL = "https://www.promptdock.ai/pricing";
|
|
3
6
|
/** Throws the DX3-copy CliError for a deny verdict; returns for ok/already_entitled. */
|
|
4
7
|
export declare function assertInstallable(resolve: ResolveResponse, refString: string, cliVersion: string): void;
|