@cruxy/cli 0.26.0 → 0.27.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/dist/cli/commands/mcp.js +106 -7
- package/dist/config/credentials.d.ts +9 -0
- package/dist/config/credentials.js +29 -1
- package/dist/config/manager.js +30 -3
- package/dist/config/schema.d.ts +182 -8
- package/dist/config/schema.js +43 -5
- package/dist/errors/constructors.d.ts +29 -0
- package/dist/errors/constructors.js +69 -0
- package/dist/errors/types.d.ts +15 -0
- package/dist/errors/types.js +18 -0
- package/dist/mcp/http-transport.d.ts +89 -0
- package/dist/mcp/http-transport.js +299 -0
- package/dist/mcp/index.d.ts +4 -2
- package/dist/mcp/index.js +3 -1
- package/dist/mcp/service.d.ts +19 -2
- package/dist/mcp/service.js +92 -20
- package/dist/mcp/trust-gate.d.ts +35 -11
- package/dist/mcp/trust-gate.js +87 -22
- package/dist/mcp/trust.d.ts +12 -2
- package/dist/mcp/trust.js +26 -2
- package/dist/mcp/types.d.ts +10 -0
- package/dist/mcp/url-guard.d.ts +48 -0
- package/dist/mcp/url-guard.js +62 -0
- package/dist/net/ip-guard.d.ts +55 -0
- package/dist/net/ip-guard.js +229 -0
- package/dist/render/index.d.ts +1 -0
- package/dist/render/index.js +1 -0
- package/dist/render/motion.d.ts +76 -0
- package/dist/render/motion.js +94 -0
- package/dist/render/tty-renderer.d.ts +17 -3
- package/dist/render/tty-renderer.js +58 -21
- package/dist/web/ssrf.d.ts +8 -22
- package/dist/web/ssrf.js +11 -183
- package/dist/web/types.d.ts +4 -2
- package/package.json +1 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { lookup } from "node:dns";
|
|
2
|
+
/** Default resolver: node's `dns.lookup` returning ALL addresses. */
|
|
3
|
+
export const defaultResolveHost = (host) => new Promise((resolve, reject) => {
|
|
4
|
+
lookup(host, { all: true }, (err, addresses) => {
|
|
5
|
+
if (err)
|
|
6
|
+
reject(err);
|
|
7
|
+
else
|
|
8
|
+
resolve(addresses.map((a) => a.address));
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
/** Thrown when a URL/host is refused pre-dispatch; carries a human reason. */
|
|
12
|
+
export class BlockedHostError extends Error {
|
|
13
|
+
}
|
|
14
|
+
/** Thrown when the host could not be resolved (a network failure, not a block). */
|
|
15
|
+
export class HostUnresolvedError extends Error {
|
|
16
|
+
}
|
|
17
|
+
/** Parse a dotted-quad IPv4 string into its four octets, or null. */
|
|
18
|
+
function parseIpv4(ip) {
|
|
19
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
|
|
20
|
+
if (!m)
|
|
21
|
+
return null;
|
|
22
|
+
const octets = m.slice(1, 5).map((s) => Number(s));
|
|
23
|
+
if (octets.some((o) => o > 255))
|
|
24
|
+
return null;
|
|
25
|
+
return octets;
|
|
26
|
+
}
|
|
27
|
+
/** True if an IPv4 address falls in a private/loopback/link-local/reserved range. */
|
|
28
|
+
function isBlockedIpv4(ip) {
|
|
29
|
+
const octets = parseIpv4(ip);
|
|
30
|
+
if (!octets)
|
|
31
|
+
return false;
|
|
32
|
+
const [a, b] = octets;
|
|
33
|
+
if (a === 0)
|
|
34
|
+
return true; // 0.0.0.0/8 "this network" / unspecified
|
|
35
|
+
if (a === 10)
|
|
36
|
+
return true; // 10.0.0.0/8 private
|
|
37
|
+
if (a === 127)
|
|
38
|
+
return true; // 127.0.0.0/8 loopback
|
|
39
|
+
if (a === 169 && b === 254)
|
|
40
|
+
return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
|
|
41
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
42
|
+
return true; // 172.16.0.0/12 private
|
|
43
|
+
if (a === 192 && b === 168)
|
|
44
|
+
return true; // 192.168.0.0/16 private
|
|
45
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
46
|
+
return true; // 100.64.0.0/10 CGNAT
|
|
47
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
48
|
+
return true; // 198.18.0.0/15 benchmarking
|
|
49
|
+
if (a === 255 && b === 255)
|
|
50
|
+
return true; // broadcast-ish
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Expand an IPv6 literal into its 8 sixteen-bit groups, or null if unparseable.
|
|
55
|
+
* Handles `::` compression and an embedded IPv4 tail (`::ffff:127.0.0.1`).
|
|
56
|
+
*/
|
|
57
|
+
function parseIpv6(input) {
|
|
58
|
+
let s = input;
|
|
59
|
+
const tail = [];
|
|
60
|
+
// Peel off a trailing dotted-quad (IPv4-mapped/-compatible forms).
|
|
61
|
+
const v4 = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(s);
|
|
62
|
+
if (v4) {
|
|
63
|
+
const o = parseIpv4(v4[1]);
|
|
64
|
+
if (!o)
|
|
65
|
+
return null;
|
|
66
|
+
tail.push((o[0] << 8) | o[1], (o[2] << 8) | o[3]);
|
|
67
|
+
s = s.slice(0, v4.index); // leaves a trailing ':' before the compression split
|
|
68
|
+
}
|
|
69
|
+
const halves = s.split("::");
|
|
70
|
+
if (halves.length > 2)
|
|
71
|
+
return null; // more than one "::" is illegal
|
|
72
|
+
const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
|
|
73
|
+
const rest = halves[1] ? halves[1].split(":").filter(Boolean) : [];
|
|
74
|
+
const toNums = (groups) => {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const g of groups) {
|
|
77
|
+
if (!/^[0-9a-f]{1,4}$/.test(g))
|
|
78
|
+
return null;
|
|
79
|
+
out.push(parseInt(g, 16));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
};
|
|
83
|
+
const headNums = toNums(head);
|
|
84
|
+
const restNums = toNums(rest);
|
|
85
|
+
if (!headNums || !restNums)
|
|
86
|
+
return null;
|
|
87
|
+
let groups;
|
|
88
|
+
if (halves.length === 2) {
|
|
89
|
+
const fill = 8 - (headNums.length + restNums.length + tail.length);
|
|
90
|
+
if (fill < 0)
|
|
91
|
+
return null;
|
|
92
|
+
groups = [
|
|
93
|
+
...headNums,
|
|
94
|
+
...Array(fill).fill(0),
|
|
95
|
+
...restNums,
|
|
96
|
+
...tail,
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
groups = [...headNums, ...tail];
|
|
101
|
+
}
|
|
102
|
+
return groups.length === 8 ? groups : null;
|
|
103
|
+
}
|
|
104
|
+
/** The dotted-quad embedded in an IPv6 tail's last two groups. */
|
|
105
|
+
function embeddedV4(g) {
|
|
106
|
+
return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
|
|
107
|
+
}
|
|
108
|
+
/** True if an expanded IPv6 address is in a range that must never be reached. */
|
|
109
|
+
function isBlockedIpv6(g) {
|
|
110
|
+
// IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d): check the v4 part.
|
|
111
|
+
const firstFiveZero = g.slice(0, 5).every((x) => x === 0);
|
|
112
|
+
const firstSixZero = firstFiveZero && g[5] === 0;
|
|
113
|
+
const embedded = embeddedV4(g);
|
|
114
|
+
if (firstFiveZero && g[5] === 0xffff)
|
|
115
|
+
return isBlockedIpv4(embedded); // ::ffff:x
|
|
116
|
+
if (firstSixZero &&
|
|
117
|
+
!(g[6] === 0 && g[7] === 0) &&
|
|
118
|
+
!(g[6] === 0 && g[7] === 1))
|
|
119
|
+
return isBlockedIpv4(embedded); // ::x.y.z.w (IPv4-compatible, deprecated)
|
|
120
|
+
if (g.every((x) => x === 0))
|
|
121
|
+
return true; // :: unspecified
|
|
122
|
+
if (firstSixZero && g[6] === 0 && g[7] === 1)
|
|
123
|
+
return true; // ::1 loopback
|
|
124
|
+
if ((g[0] & 0xffc0) === 0xfe80)
|
|
125
|
+
return true; // fe80::/10 link-local (fe80–febf)
|
|
126
|
+
if ((g[0] & 0xfe00) === 0xfc00)
|
|
127
|
+
return true; // fc00::/7 unique-local (fc00–fdff)
|
|
128
|
+
if ((g[0] & 0xff00) === 0xff00)
|
|
129
|
+
return true; // ff00::/8 multicast
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
/** Strip brackets, lowercase, and drop an IPv6 scope/zone id from an address. */
|
|
133
|
+
function normalizeAddress(addr) {
|
|
134
|
+
return addr
|
|
135
|
+
.trim()
|
|
136
|
+
.toLowerCase()
|
|
137
|
+
.replace(/^\[|\]$/g, "")
|
|
138
|
+
.split("%")[0];
|
|
139
|
+
}
|
|
140
|
+
/** True if an address (v4 or v6) is in a range that must never be reached. */
|
|
141
|
+
export function isBlockedAddress(addr) {
|
|
142
|
+
const ip = normalizeAddress(addr);
|
|
143
|
+
if (ip.includes(":")) {
|
|
144
|
+
const groups = parseIpv6(ip);
|
|
145
|
+
if (!groups)
|
|
146
|
+
return true; // fail closed: an unparseable colon-address is refused
|
|
147
|
+
return isBlockedIpv6(groups);
|
|
148
|
+
}
|
|
149
|
+
return isBlockedIpv4(ip);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* True if an address is a LOOPBACK address (127.0.0.0/8, ::1, or an IPv4-mapped
|
|
153
|
+
* loopback). This is the ONLY range the network MCP transport permits over plain
|
|
154
|
+
* `http` (loopback dev servers); everything else must be `https` to a public IP.
|
|
155
|
+
* An unparseable address is not loopback (fail closed — it won't get the http pass).
|
|
156
|
+
*/
|
|
157
|
+
export function isLoopbackAddress(addr) {
|
|
158
|
+
const ip = normalizeAddress(addr);
|
|
159
|
+
if (ip.includes(":")) {
|
|
160
|
+
const g = parseIpv6(ip);
|
|
161
|
+
if (!g)
|
|
162
|
+
return false;
|
|
163
|
+
const firstSixZero = g.slice(0, 6).every((x) => x === 0);
|
|
164
|
+
if (firstSixZero && g[6] === 0 && g[7] === 1)
|
|
165
|
+
return true; // ::1
|
|
166
|
+
// ::ffff:127.x and deprecated ::127.x embed a v4 loopback.
|
|
167
|
+
const firstFiveZero = g.slice(0, 5).every((x) => x === 0);
|
|
168
|
+
if (firstFiveZero && (g[5] === 0xffff || g[5] === 0)) {
|
|
169
|
+
const embedded = `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
|
|
170
|
+
const o = parseIpv4(embedded);
|
|
171
|
+
return o !== null && o[0] === 127;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
const o = parseIpv4(ip);
|
|
176
|
+
return o !== null && o[0] === 127;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolve `host` and require that EVERY resolved address is public — the SSRF
|
|
180
|
+
* gate for any URL a caller did not fully control. Throws {@link BlockedHostError}
|
|
181
|
+
* if any address is private/loopback/link-local/reserved (a security refusal), or
|
|
182
|
+
* {@link HostUnresolvedError} if the host cannot be resolved (a network failure).
|
|
183
|
+
* Returns the validated addresses so the caller can PIN the connection to them.
|
|
184
|
+
*/
|
|
185
|
+
export async function assertAllPublic(host, resolve) {
|
|
186
|
+
const cleaned = host.replace(/^\[|\]$/g, "");
|
|
187
|
+
let addresses;
|
|
188
|
+
try {
|
|
189
|
+
addresses = await resolve(cleaned);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
throw new HostUnresolvedError(`could not resolve host "${cleaned}": ${err.message}`);
|
|
193
|
+
}
|
|
194
|
+
if (addresses.length === 0) {
|
|
195
|
+
throw new HostUnresolvedError(`host "${cleaned}" resolved to no addresses`);
|
|
196
|
+
}
|
|
197
|
+
for (const addr of addresses) {
|
|
198
|
+
if (isBlockedAddress(addr)) {
|
|
199
|
+
throw new BlockedHostError(`host "${cleaned}" resolves to ${addr}, a private/loopback/link-local address`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return addresses;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* A `dns.lookup`-compatible function that ignores the hostname and always hands
|
|
206
|
+
* back one of the pre-validated `addresses`. This is what pins a connection to the
|
|
207
|
+
* address the SSRF check already approved, defeating DNS rebinding: the socket can
|
|
208
|
+
* only reach a validated IP, never a value re-resolved at connect time. The Host
|
|
209
|
+
* header / TLS SNI still carry the original hostname (only the dialed IP is pinned).
|
|
210
|
+
*/
|
|
211
|
+
export function pinnedLookup(addresses) {
|
|
212
|
+
const resolved = addresses.map((address) => ({
|
|
213
|
+
address,
|
|
214
|
+
family: address.includes(":") ? 6 : 4,
|
|
215
|
+
}));
|
|
216
|
+
return (_hostname, options, callback) => {
|
|
217
|
+
const all = typeof options === "object" && options !== null && "all" in options
|
|
218
|
+
? options.all
|
|
219
|
+
: false;
|
|
220
|
+
if (all)
|
|
221
|
+
callback(null, resolved);
|
|
222
|
+
else
|
|
223
|
+
callback(null, resolved[0].address, resolved[0].family);
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** Sort + dedupe an address set so equality checks are order-insensitive. */
|
|
227
|
+
export function normalizeAddressSet(addresses) {
|
|
228
|
+
return [...new Set(addresses.map(normalizeAddress))].sort();
|
|
229
|
+
}
|
package/dist/render/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { detectCapabilities, detectReducedMotion, resolveColumns, DEFAULT_COLUMN
|
|
|
4
4
|
export { attachResize, processResizeSignal, type ResizeSignal, } from "./resize.js";
|
|
5
5
|
export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, type KvRow, } from "./layout.js";
|
|
6
6
|
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
7
|
+
export { createFrameClock, inTransition, intervalFrameTimer, spinnerGlyph, FRAME_INTERVAL_MS, TRANSITION_TICKS, type FrameClock, type FrameTimer, } from "./motion.js";
|
|
7
8
|
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
8
9
|
export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
|
|
9
10
|
export { PlainRenderer } from "./plain-renderer.js";
|
package/dist/render/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { detectCapabilities, detectReducedMotion, resolveColumns, DEFAULT_COLUMN
|
|
|
7
7
|
export { attachResize, processResizeSignal, } from "./resize.js";
|
|
8
8
|
export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, } from "./layout.js";
|
|
9
9
|
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
10
|
+
export { createFrameClock, inTransition, intervalFrameTimer, spinnerGlyph, FRAME_INTERVAL_MS, TRANSITION_TICKS, } from "./motion.js";
|
|
10
11
|
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
11
12
|
export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
|
|
12
13
|
export { PlainRenderer } from "./plain-renderer.js";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ThemeGlyphs } from "../theme/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Motion (U.10) — one frame clock drives every live animation on the render
|
|
4
|
+
* seam, and a tiny set of pure frame→glyph/style primitives express it. The
|
|
5
|
+
* design is the same honest-signal discipline as U.4 state: motion encodes real
|
|
6
|
+
* state (work is progressing, the activity changed, a result landed) and never
|
|
7
|
+
* decorates. It is a cosmetic overlay on the transient live region (U.2) — never
|
|
8
|
+
* in the content path, never touching committed bytes.
|
|
9
|
+
*
|
|
10
|
+
* The single gate is `caps.spinner` (`cursor && !reducedMotion`, from U.11):
|
|
11
|
+
* when motion is reduced the clock is *disabled* — it schedules nothing and
|
|
12
|
+
* never ticks, so every motion collapses to its static end-state, drawn once.
|
|
13
|
+
* There is exactly one clock per renderer (refcounted like the U.12 resize
|
|
14
|
+
* signal), not a timer per surface.
|
|
15
|
+
*/
|
|
16
|
+
/** The one frame cadence: 10 fps, the pre-U.10 spinner interval. Bounded by construction. */
|
|
17
|
+
export declare const FRAME_INTERVAL_MS = 100;
|
|
18
|
+
/**
|
|
19
|
+
* How many frames a phase-transition emphasis lasts before it settles — ~300ms
|
|
20
|
+
* at {@link FRAME_INTERVAL_MS}. Long enough for the eye to catch "the activity
|
|
21
|
+
* changed", short enough to never read as decoration.
|
|
22
|
+
*/
|
|
23
|
+
export declare const TRANSITION_TICKS = 3;
|
|
24
|
+
/** Schedule/cancel a repeating tick; injectable so tests drive frames deterministically. */
|
|
25
|
+
export interface FrameTimer {
|
|
26
|
+
schedule(callback: () => void, intervalMs: number): {
|
|
27
|
+
unref?: () => void;
|
|
28
|
+
};
|
|
29
|
+
cancel(handle: {
|
|
30
|
+
unref?: () => void;
|
|
31
|
+
}): void;
|
|
32
|
+
}
|
|
33
|
+
/** The real timer: a single unref'd interval that never holds the process open. */
|
|
34
|
+
export declare const intervalFrameTimer: FrameTimer;
|
|
35
|
+
/**
|
|
36
|
+
* The one clock that drives all live animation. Subscribers get a monotonic
|
|
37
|
+
* frame number on each tick; the underlying timer runs ONLY while at least one
|
|
38
|
+
* subscriber is attached (refcounted), and is torn down on the last unsubscribe
|
|
39
|
+
* — the same discipline that keeps the U.12 resize listener from leaking.
|
|
40
|
+
*/
|
|
41
|
+
export interface FrameClock {
|
|
42
|
+
/**
|
|
43
|
+
* Attach a per-frame callback. The first subscriber starts the single timer;
|
|
44
|
+
* the returned function detaches and, when it was the last subscriber, stops
|
|
45
|
+
* the timer. A no-op subscription (returns a no-op unsubscribe) when the clock
|
|
46
|
+
* is disabled — reduced motion never schedules anything.
|
|
47
|
+
*/
|
|
48
|
+
subscribe(onFrame: (frame: number) => void): () => void;
|
|
49
|
+
/** The current monotonic frame counter. Stays 0 while disabled (no ticks). */
|
|
50
|
+
readonly frame: number;
|
|
51
|
+
/** Whether motion runs at all — false under reduced motion (`caps.spinner`). */
|
|
52
|
+
readonly enabled: boolean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the frame clock. `enabled` is `caps.spinner`: when false the clock is
|
|
56
|
+
* inert — {@link FrameClock.subscribe} schedules nothing, never ticks, and
|
|
57
|
+
* `frame` never advances, so callers draw their static end-state once and stop.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createFrameClock(enabled: boolean, timer?: FrameTimer, intervalMs?: number): FrameClock;
|
|
60
|
+
/**
|
|
61
|
+
* The spinner glyph for a frame (U.10 progress motion): the animated braille
|
|
62
|
+
* (or single-column ASCII) cycle when the clock runs, the static marker
|
|
63
|
+
* otherwise. `animated=false` (reduced motion / no cursor) yields the static
|
|
64
|
+
* glyph with no reference to `frame` — the honest still form of "working".
|
|
65
|
+
*/
|
|
66
|
+
export declare function spinnerGlyph(frame: number, glyph: ThemeGlyphs, animated: boolean): string;
|
|
67
|
+
/**
|
|
68
|
+
* Whether a phase-transition emphasis is still playing (U.10): true for the
|
|
69
|
+
* first {@link TRANSITION_TICKS} frames after the phase *identity* changed. The
|
|
70
|
+
* caller records the frame the identity began on and passes it here; when the
|
|
71
|
+
* clock is not advancing (reduced motion), `startFrame === frame` forever, so
|
|
72
|
+
* this is briefly true only if drawn on the very first frame — the renderer
|
|
73
|
+
* gates the whole emphasis behind `clock.enabled`, so a static draw always uses
|
|
74
|
+
* the settled end-state.
|
|
75
|
+
*/
|
|
76
|
+
export declare function inTransition(frame: number, startFrame: number): boolean;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion (U.10) — one frame clock drives every live animation on the render
|
|
3
|
+
* seam, and a tiny set of pure frame→glyph/style primitives express it. The
|
|
4
|
+
* design is the same honest-signal discipline as U.4 state: motion encodes real
|
|
5
|
+
* state (work is progressing, the activity changed, a result landed) and never
|
|
6
|
+
* decorates. It is a cosmetic overlay on the transient live region (U.2) — never
|
|
7
|
+
* in the content path, never touching committed bytes.
|
|
8
|
+
*
|
|
9
|
+
* The single gate is `caps.spinner` (`cursor && !reducedMotion`, from U.11):
|
|
10
|
+
* when motion is reduced the clock is *disabled* — it schedules nothing and
|
|
11
|
+
* never ticks, so every motion collapses to its static end-state, drawn once.
|
|
12
|
+
* There is exactly one clock per renderer (refcounted like the U.12 resize
|
|
13
|
+
* signal), not a timer per surface.
|
|
14
|
+
*/
|
|
15
|
+
/** The one frame cadence: 10 fps, the pre-U.10 spinner interval. Bounded by construction. */
|
|
16
|
+
export const FRAME_INTERVAL_MS = 100;
|
|
17
|
+
/**
|
|
18
|
+
* How many frames a phase-transition emphasis lasts before it settles — ~300ms
|
|
19
|
+
* at {@link FRAME_INTERVAL_MS}. Long enough for the eye to catch "the activity
|
|
20
|
+
* changed", short enough to never read as decoration.
|
|
21
|
+
*/
|
|
22
|
+
export const TRANSITION_TICKS = 3;
|
|
23
|
+
/** The real timer: a single unref'd interval that never holds the process open. */
|
|
24
|
+
export const intervalFrameTimer = {
|
|
25
|
+
schedule(callback, intervalMs) {
|
|
26
|
+
const handle = setInterval(callback, intervalMs);
|
|
27
|
+
handle.unref?.();
|
|
28
|
+
return handle;
|
|
29
|
+
},
|
|
30
|
+
cancel(handle) {
|
|
31
|
+
clearInterval(handle);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Build the frame clock. `enabled` is `caps.spinner`: when false the clock is
|
|
36
|
+
* inert — {@link FrameClock.subscribe} schedules nothing, never ticks, and
|
|
37
|
+
* `frame` never advances, so callers draw their static end-state once and stop.
|
|
38
|
+
*/
|
|
39
|
+
export function createFrameClock(enabled, timer = intervalFrameTimer, intervalMs = FRAME_INTERVAL_MS) {
|
|
40
|
+
const subscribers = new Set();
|
|
41
|
+
let handle = null;
|
|
42
|
+
let frame = 0;
|
|
43
|
+
const tick = () => {
|
|
44
|
+
frame++;
|
|
45
|
+
for (const fn of [...subscribers])
|
|
46
|
+
fn(frame);
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
get frame() {
|
|
50
|
+
return frame;
|
|
51
|
+
},
|
|
52
|
+
enabled,
|
|
53
|
+
subscribe(onFrame) {
|
|
54
|
+
// Reduced motion: register nothing, schedule nothing. The single absolute
|
|
55
|
+
// gate — no timer can ever start, so no frame can ever be emitted.
|
|
56
|
+
if (!enabled)
|
|
57
|
+
return () => { };
|
|
58
|
+
subscribers.add(onFrame);
|
|
59
|
+
if (handle === null)
|
|
60
|
+
handle = timer.schedule(tick, intervalMs);
|
|
61
|
+
return () => {
|
|
62
|
+
subscribers.delete(onFrame);
|
|
63
|
+
if (subscribers.size === 0 && handle !== null) {
|
|
64
|
+
timer.cancel(handle);
|
|
65
|
+
handle = null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The spinner glyph for a frame (U.10 progress motion): the animated braille
|
|
73
|
+
* (or single-column ASCII) cycle when the clock runs, the static marker
|
|
74
|
+
* otherwise. `animated=false` (reduced motion / no cursor) yields the static
|
|
75
|
+
* glyph with no reference to `frame` — the honest still form of "working".
|
|
76
|
+
*/
|
|
77
|
+
export function spinnerGlyph(frame, glyph, animated) {
|
|
78
|
+
if (!animated)
|
|
79
|
+
return glyph.spinnerStatic;
|
|
80
|
+
const frames = glyph.spinnerFrames;
|
|
81
|
+
return frames[frame % frames.length];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether a phase-transition emphasis is still playing (U.10): true for the
|
|
85
|
+
* first {@link TRANSITION_TICKS} frames after the phase *identity* changed. The
|
|
86
|
+
* caller records the frame the identity began on and passes it here; when the
|
|
87
|
+
* clock is not advancing (reduced motion), `startFrame === frame` forever, so
|
|
88
|
+
* this is briefly true only if drawn on the very first frame — the renderer
|
|
89
|
+
* gates the whole emphasis behind `clock.enabled`, so a static draw always uses
|
|
90
|
+
* the settled end-state.
|
|
91
|
+
*/
|
|
92
|
+
export function inTransition(frame, startFrame) {
|
|
93
|
+
return frame - startFrame < TRANSITION_TICKS;
|
|
94
|
+
}
|
|
@@ -25,6 +25,15 @@ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, Stre
|
|
|
25
25
|
* A committed write still only *hides* the drawn line (registers survive);
|
|
26
26
|
* the next state transition redraws. Nothing redraws per streamed delta, so
|
|
27
27
|
* the U.2 first-chunk-immediate guarantee is untouched.
|
|
28
|
+
*
|
|
29
|
+
* U.10 puts every live animation on ONE frame clock (see motion.ts) instead of
|
|
30
|
+
* a private interval: the spinner glyph and the phase-transition emphasis draw
|
|
31
|
+
* off `clock.frame`, subscribed only while the line is visible and torn down on
|
|
32
|
+
* hide/close (no leaked timer). Reduced motion disables the clock — the line is
|
|
33
|
+
* drawn once in its static end-state. The result reveal is a one-time entrance
|
|
34
|
+
* emphasis on a committed tool note (never a redraw of committed bytes), so it
|
|
35
|
+
* needs no clock. Motion is a cosmetic overlay on the live region only; it never
|
|
36
|
+
* gates a committed write, so the latency guarantee holds with motion active.
|
|
28
37
|
*/
|
|
29
38
|
export declare class TtyRenderer implements StreamRenderer {
|
|
30
39
|
readonly caps: RenderCapabilities;
|
|
@@ -45,8 +54,12 @@ export declare class TtyRenderer implements StreamRenderer {
|
|
|
45
54
|
private displaced;
|
|
46
55
|
/** Whether the live line is currently drawn on screen. */
|
|
47
56
|
private lineVisible;
|
|
48
|
-
|
|
49
|
-
private
|
|
57
|
+
/** The one frame clock driving live animation (U.10); disabled under reduced motion. */
|
|
58
|
+
private readonly clock;
|
|
59
|
+
/** Detach from the clock; null when the live line is hidden (no ticks wanted). */
|
|
60
|
+
private unsubscribeFrame;
|
|
61
|
+
/** Clock frame the current phase *identity* began on — drives the transition emphasis (U.10). */
|
|
62
|
+
private phaseStartFrame;
|
|
50
63
|
private closed;
|
|
51
64
|
/** Unsubscribe from the resize signal (U.12); null when the stream can't resize. */
|
|
52
65
|
private unsubscribeResize;
|
|
@@ -67,7 +80,8 @@ export declare class TtyRenderer implements StreamRenderer {
|
|
|
67
80
|
* zero-cost: no redraw-under per delta.)
|
|
68
81
|
*/
|
|
69
82
|
private hideLine;
|
|
70
|
-
|
|
83
|
+
/** Detach from the frame clock — the timer stops once this is the last subscriber. */
|
|
84
|
+
private stopFrames;
|
|
71
85
|
/**
|
|
72
86
|
* The current live-line text, composed from the registers. `null` means the
|
|
73
87
|
* line must be hidden: nothing to say, or an interactive prompt owns the
|
|
@@ -3,10 +3,10 @@ import { createStreamPrinter } from "../cli/stream-print.js";
|
|
|
3
3
|
import { renderActionPreview } from "./diff.js";
|
|
4
4
|
import { createStreamHighlighter, } from "./highlight.js";
|
|
5
5
|
import { fit } from "./layout.js";
|
|
6
|
+
import { createFrameClock, inTransition, spinnerGlyph, } from "./motion.js";
|
|
6
7
|
import { ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, phaseIdentity, } from "./state.js";
|
|
7
8
|
/** Erase the current line and return the cursor to column 0. */
|
|
8
9
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
9
|
-
const SPINNER_INTERVAL_MS = 100;
|
|
10
10
|
/**
|
|
11
11
|
* The interactive renderer: committed content is append-only; the one transient
|
|
12
12
|
* thing on screen is a single managed status line, redrawn in place.
|
|
@@ -31,6 +31,15 @@ const SPINNER_INTERVAL_MS = 100;
|
|
|
31
31
|
* A committed write still only *hides* the drawn line (registers survive);
|
|
32
32
|
* the next state transition redraws. Nothing redraws per streamed delta, so
|
|
33
33
|
* the U.2 first-chunk-immediate guarantee is untouched.
|
|
34
|
+
*
|
|
35
|
+
* U.10 puts every live animation on ONE frame clock (see motion.ts) instead of
|
|
36
|
+
* a private interval: the spinner glyph and the phase-transition emphasis draw
|
|
37
|
+
* off `clock.frame`, subscribed only while the line is visible and torn down on
|
|
38
|
+
* hide/close (no leaked timer). Reduced motion disables the clock — the line is
|
|
39
|
+
* drawn once in its static end-state. The result reveal is a one-time entrance
|
|
40
|
+
* emphasis on a committed tool note (never a redraw of committed bytes), so it
|
|
41
|
+
* needs no clock. Motion is a cosmetic overlay on the live region only; it never
|
|
42
|
+
* gates a committed write, so the latency guarantee holds with motion active.
|
|
34
43
|
*/
|
|
35
44
|
export class TtyRenderer {
|
|
36
45
|
caps;
|
|
@@ -51,8 +60,12 @@ export class TtyRenderer {
|
|
|
51
60
|
displaced = null;
|
|
52
61
|
/** Whether the live line is currently drawn on screen. */
|
|
53
62
|
lineVisible = false;
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
/** The one frame clock driving live animation (U.10); disabled under reduced motion. */
|
|
64
|
+
clock;
|
|
65
|
+
/** Detach from the clock; null when the live line is hidden (no ticks wanted). */
|
|
66
|
+
unsubscribeFrame = null;
|
|
67
|
+
/** Clock frame the current phase *identity* began on — drives the transition emphasis (U.10). */
|
|
68
|
+
phaseStartFrame = 0;
|
|
56
69
|
closed = false;
|
|
57
70
|
/** Unsubscribe from the resize signal (U.12); null when the stream can't resize. */
|
|
58
71
|
unsubscribeResize = null;
|
|
@@ -62,6 +75,10 @@ export class TtyRenderer {
|
|
|
62
75
|
this.theme = resolveTheme(caps);
|
|
63
76
|
this.highlighter = createStreamHighlighter(this.theme);
|
|
64
77
|
this.print = this.newPrinter();
|
|
78
|
+
// One clock for all live motion (U.10). Gated on `caps.spinner`
|
|
79
|
+
// (cursor && !reducedMotion), so reduced motion yields an inert clock that
|
|
80
|
+
// never schedules or ticks — the line is drawn once, statically.
|
|
81
|
+
this.clock = createFrameClock(caps.spinner);
|
|
65
82
|
// Resize reactivity (U.12): only the live line reflows at the new width —
|
|
66
83
|
// committed rows above are immutable and never rewritten.
|
|
67
84
|
this.unsubscribeResize = caps.onResize?.(() => this.reflowLive()) ?? null;
|
|
@@ -107,13 +124,14 @@ export class TtyRenderer {
|
|
|
107
124
|
if (!this.lineVisible)
|
|
108
125
|
return;
|
|
109
126
|
this.lineVisible = false;
|
|
110
|
-
this.
|
|
127
|
+
this.stopFrames();
|
|
111
128
|
this.out.write(CLEAR_LINE);
|
|
112
129
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
this.
|
|
130
|
+
/** Detach from the frame clock — the timer stops once this is the last subscriber. */
|
|
131
|
+
stopFrames() {
|
|
132
|
+
if (this.unsubscribeFrame !== null) {
|
|
133
|
+
this.unsubscribeFrame();
|
|
134
|
+
this.unsubscribeFrame = null;
|
|
117
135
|
}
|
|
118
136
|
}
|
|
119
137
|
/**
|
|
@@ -146,28 +164,36 @@ export class TtyRenderer {
|
|
|
146
164
|
return;
|
|
147
165
|
}
|
|
148
166
|
this.drawLine(line);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
// Subscribe to the one clock while the line is live; the clock owns the
|
|
168
|
+
// single (unref'd) timer and only runs while something is subscribed. An
|
|
169
|
+
// inert clock (reduced motion) subscribes to nothing — the static draw
|
|
170
|
+
// above is the whole story, no frames follow.
|
|
171
|
+
if (this.clock.enabled && this.unsubscribeFrame === null) {
|
|
172
|
+
this.unsubscribeFrame = this.clock.subscribe(() => {
|
|
152
173
|
const current = this.currentLine();
|
|
153
174
|
if (current !== null)
|
|
154
175
|
this.drawLine(current);
|
|
155
|
-
}
|
|
156
|
-
// Never hold the process open for a spinner.
|
|
157
|
-
this.timer.unref?.();
|
|
176
|
+
});
|
|
158
177
|
}
|
|
159
178
|
}
|
|
160
179
|
drawLine(text) {
|
|
161
180
|
this.lineVisible = true;
|
|
162
|
-
const
|
|
163
|
-
const glyph = this.
|
|
164
|
-
? frames[this.frame % frames.length]
|
|
165
|
-
: this.theme.glyph.spinnerStatic;
|
|
181
|
+
const animated = this.caps.spinner;
|
|
182
|
+
const glyph = spinnerGlyph(this.clock.frame, this.theme.glyph, animated);
|
|
166
183
|
// Reserve glyph + space; ANSI-aware fit so the live line can never
|
|
167
184
|
// soft-wrap (currentLine already fits — this is defense in depth) (U.12).
|
|
168
185
|
const room = Math.max(1, this.caps.width - 2);
|
|
169
186
|
const line = fit(text, room, this.theme.glyph.ellipsis);
|
|
170
|
-
|
|
187
|
+
// Phase-transition emphasis (U.10): a newly-begun activity brightens for a
|
|
188
|
+
// few frames, then settles to muted. Purely a color role, so NO_COLOR and
|
|
189
|
+
// the static (reduced-motion) draw both collapse to the settled end-state —
|
|
190
|
+
// and it only ever plays on a live phase whose identity just changed.
|
|
191
|
+
const emphasized = animated &&
|
|
192
|
+
this.rawStatus === null &&
|
|
193
|
+
this.phase !== null &&
|
|
194
|
+
inTransition(this.clock.frame, this.phaseStartFrame);
|
|
195
|
+
const body = emphasized ? this.theme.accent(line) : this.theme.muted(line);
|
|
196
|
+
this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${body}`);
|
|
171
197
|
}
|
|
172
198
|
beginTurn() {
|
|
173
199
|
this.highlighter = createStreamHighlighter(this.theme);
|
|
@@ -230,8 +256,13 @@ export class TtyRenderer {
|
|
|
230
256
|
}
|
|
231
257
|
const before = phaseIdentity(this.phase);
|
|
232
258
|
this.phase = phase;
|
|
233
|
-
if (phaseIdentity(phase) !== before)
|
|
259
|
+
if (phaseIdentity(phase) !== before) {
|
|
234
260
|
this.phaseStartedAt = Date.now();
|
|
261
|
+
// A new activity: restart the transition emphasis from the current frame
|
|
262
|
+
// (U.10). Only a genuine identity change resets it — a thinking phase that
|
|
263
|
+
// merely gains token counts keeps drawing settled, no re-flourish.
|
|
264
|
+
this.phaseStartFrame = this.clock.frame;
|
|
265
|
+
}
|
|
235
266
|
if (phase === null) {
|
|
236
267
|
// A cleared phase means "nothing is happening" — hide rather than
|
|
237
268
|
// redraw a bare progress prefix between turns.
|
|
@@ -263,7 +294,13 @@ export class TtyRenderer {
|
|
|
263
294
|
const elapsed = started === null ? 0 : Date.now() - started.at;
|
|
264
295
|
const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
|
|
265
296
|
const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
|
|
266
|
-
|
|
297
|
+
// Result reveal (U.10): a freshly-landed result gets a one-time entrance
|
|
298
|
+
// emphasis on its mark when motion is active — written once into committed
|
|
299
|
+
// history and never redrawn (immutable). It gates on the same motion axis,
|
|
300
|
+
// so reduced motion shows the plain mark; under NO_COLOR `strong` is the
|
|
301
|
+
// identity, so the committed bytes are byte-for-byte unchanged either way.
|
|
302
|
+
const shownMark = this.caps.spinner ? this.theme.strong(mark) : mark;
|
|
303
|
+
this.note(`${shownMark} ${event.label}${suffix}`);
|
|
267
304
|
}
|
|
268
305
|
promptResolved() {
|
|
269
306
|
if (this.closed)
|
package/dist/web/ssrf.d.ts
CHANGED
|
@@ -17,34 +17,20 @@ import type { HostResolver } from "./types.js";
|
|
|
17
17
|
*
|
|
18
18
|
* The check runs BEFORE any request is dispatched, and again on every redirect hop
|
|
19
19
|
* (see fetch.ts). A block is a security refusal, distinct from a network failure.
|
|
20
|
+
*
|
|
21
|
+
* The IP range math, the pin shim, and the error types now live in the shared
|
|
22
|
+
* {@link ../net/ip-guard ip-guard} module (JC-A) — the ONE owner of "is this IP
|
|
23
|
+
* allowed"; this file keeps only the web-specific policy (scheme + the
|
|
24
|
+
* `allowPrivateHosts` escape hatch) and the web one-shot dispatcher.
|
|
20
25
|
*/
|
|
21
|
-
|
|
22
|
-
export declare const defaultResolveHost: HostResolver;
|
|
23
|
-
/** Thrown when a URL is refused pre-dispatch; carries a human reason. */
|
|
24
|
-
export declare class BlockedHostError extends Error {
|
|
25
|
-
}
|
|
26
|
-
/** Thrown when the host could not be resolved (a network failure, not a block). */
|
|
27
|
-
export declare class HostUnresolvedError extends Error {
|
|
28
|
-
}
|
|
29
|
-
/** True if an address (v4 or v6) is in a range `web_fetch` must never reach. */
|
|
30
|
-
export declare function isBlockedAddress(addr: string): boolean;
|
|
31
|
-
/**
|
|
32
|
-
* A `dns.lookup`-compatible function that ignores the hostname and always hands
|
|
33
|
-
* back one of the pre-validated `addresses`. This is what pins a connection to the
|
|
34
|
-
* address the SSRF check already approved, defeating DNS rebinding: the socket can
|
|
35
|
-
* only reach a validated IP, never a value re-resolved at connect time.
|
|
36
|
-
*/
|
|
37
|
-
export declare function pinnedLookup(addresses: string[]): (_hostname: string, options: unknown, callback: (err: NodeJS.ErrnoException | null, address: string | {
|
|
38
|
-
address: string;
|
|
39
|
-
family: number;
|
|
40
|
-
}[], family?: number) => void) => void;
|
|
26
|
+
export { BlockedHostError, HostUnresolvedError, isBlockedAddress, defaultResolveHost, pinnedLookup, } from "../net/ip-guard.js";
|
|
41
27
|
/** An undici dispatcher whose connections are pinned to `addresses`. */
|
|
42
28
|
export declare function createPinnedDispatcher(addresses: string[]): Dispatcher;
|
|
43
29
|
/**
|
|
44
30
|
* Assert that `url` may be fetched and return the validated addresses to pin the
|
|
45
31
|
* connection to. Throws {@link BlockedHostError} for a bad scheme or a host
|
|
46
|
-
* resolving into a blocked range, or
|
|
47
|
-
*
|
|
32
|
+
* resolving into a blocked range, or `HostUnresolvedError` if the host cannot be
|
|
33
|
+
* resolved.
|
|
48
34
|
*
|
|
49
35
|
* The returned list is the exact set of addresses the caller must restrict the
|
|
50
36
|
* connection to (via {@link createPinnedDispatcher}). An empty list means "do not
|