viewable-one 0.0.0-stage → 0.12.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/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
package/lib/picker.mjs
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Arrow-key checkbox picker (brief 31, ruling 2026-09-12: "when selecting a
|
|
2
|
+
// model I prefer not to type anything: go up and down with arrows to select
|
|
3
|
+
// the right one or even multiple ones"). Step 1 of `glytch init` used to be a
|
|
4
|
+
// numbered list and a comma-separated answer; this is the checkbox list that
|
|
5
|
+
// replaces it for a real terminal.
|
|
6
|
+
//
|
|
7
|
+
// A PURE FUNCTION OF ITS STREAMS. No global terminal state, no dependency:
|
|
8
|
+
// `input`/`output` are injected so a test can drive it with a fake stream
|
|
9
|
+
// that emits key sequences and read back exactly what was painted, and so
|
|
10
|
+
// the non-TTY path in agent-selection.mjs never has to know this module
|
|
11
|
+
// exists. Paint goes through cli-output.mjs's helpers only (`bold`, `ok`,
|
|
12
|
+
// `warn`, `dim`), so `color: false` (NO_COLOR, a pipe, TERM=dumb) renders a
|
|
13
|
+
// plain `>` and `[x]` with no escapes at all - only the cursor-hide/show and
|
|
14
|
+
// redraw sequences remain, because those are structural, not decoration.
|
|
15
|
+
//
|
|
16
|
+
// RENDERS IN PLACE: hide the cursor once, then on every keypress move the
|
|
17
|
+
// cursor back up over the block just drawn and clear each line before
|
|
18
|
+
// repainting it. The block is always `items.length + 3` lines - the agent
|
|
19
|
+
// rows, a trailing "Continue" row, the empty-selection warning (blank when
|
|
20
|
+
// there is nothing to warn about), and a one-line legend - so the redraw
|
|
21
|
+
// math never has to account for a growing or shrinking frame.
|
|
22
|
+
//
|
|
23
|
+
// ENTER SELECTS (2026-09-12, real run): the user arrowed onto opencode and
|
|
24
|
+
// pressed Enter expecting to check it; Enter confirmed the whole picker
|
|
25
|
+
// instead, with only Claude checked, and there was no way back short of
|
|
26
|
+
// re-running init. The expectation is the rule now - Enter on an agent row
|
|
27
|
+
// toggles it, exactly like Space - so confirming needs its own row. "Continue"
|
|
28
|
+
// is that row: it carries the cursor like any other, has no checkbox, and is
|
|
29
|
+
// the only row where Enter finishes the picker.
|
|
30
|
+
import { bold, dim, ok, warn, wrap } from "./cli-output.mjs";
|
|
31
|
+
|
|
32
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
33
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
34
|
+
const up = (n) => `\x1b[${n}A`;
|
|
35
|
+
const CLEAR_LINE = "\x1b[2K";
|
|
36
|
+
// 79 code points: fits inside one 80-column row by default, unlike the
|
|
37
|
+
// wording this replaced (82 code points - one over). P1 (review
|
|
38
|
+
// 2026-09-12): draw() counted one physical terminal row per logical line,
|
|
39
|
+
// so the moment the legend itself wrapped at 80 columns the cursor-up count
|
|
40
|
+
// fell one row short on every redraw and the whole picker smeared. A
|
|
41
|
+
// narrower `columns` still wraps this (draw() below now measures physical
|
|
42
|
+
// rows rather than assuming one each), but the common case needs no wrap.
|
|
43
|
+
const LEGEND = "↑↓ move · Enter/Space select · a all · Enter on Continue to finish · Esc cancel";
|
|
44
|
+
|
|
45
|
+
// Strip the SGR escapes this module ever paints with before measuring -
|
|
46
|
+
// colour codes take zero columns on a real terminal. Every remaining code
|
|
47
|
+
// point then counts as one column; true for the arrows and middle dot this
|
|
48
|
+
// legend uses, not a general-purpose East-Asian-width table, but nothing
|
|
49
|
+
// this picker renders needs one.
|
|
50
|
+
const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
51
|
+
function displayWidth(text) {
|
|
52
|
+
return [...String(text).replace(ANSI_RE, "")].length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// How many terminal rows one rendered line actually occupies, so draw()'s
|
|
56
|
+
// cursor-up count matches what the terminal did with it instead of
|
|
57
|
+
// assuming one array entry is always one row (P1).
|
|
58
|
+
function physicalRows(line, columns) {
|
|
59
|
+
return Math.max(1, Math.ceil(displayWidth(line) / columns));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {object} args
|
|
64
|
+
* @param {{key: string, label: string}[]} args.items - rows, in display order.
|
|
65
|
+
* @param {string[]} [args.checked] - keys checked at start.
|
|
66
|
+
* @param {NodeJS.ReadStream} [args.input]
|
|
67
|
+
* @param {NodeJS.WriteStream} [args.output]
|
|
68
|
+
* @param {boolean} [args.color]
|
|
69
|
+
* @param {number} [args.columns] - terminal width for redraw math (P1);
|
|
70
|
+
* defaults to `output.columns`, falling back to 80 for a pipe or a fake
|
|
71
|
+
* stream in a test that reports none.
|
|
72
|
+
* @returns {Promise<string[]|null>} the checked keys in `items` order, or
|
|
73
|
+
* `null` if the user cancelled with Escape, Ctrl-C, Ctrl-D, or stdin ending.
|
|
74
|
+
*/
|
|
75
|
+
export function pickMany({
|
|
76
|
+
items, checked = [], input = process.stdin, output = process.stdout, color = false,
|
|
77
|
+
columns = output.columns || 80,
|
|
78
|
+
}) {
|
|
79
|
+
return new Promise((resolvePromise) => {
|
|
80
|
+
// The cursor ranges over the agent rows plus one more: `items.length`
|
|
81
|
+
// itself is the Continue row, kept as a plain index rather than a
|
|
82
|
+
// sentinel so Up/Down/j/k treat it exactly like any other row.
|
|
83
|
+
const continueIndex = items.length;
|
|
84
|
+
const rowCount = items.length + 1;
|
|
85
|
+
const state = { cursor: 0, checked: new Set(checked), warning: "" };
|
|
86
|
+
let drawn = 0;
|
|
87
|
+
let settled = false;
|
|
88
|
+
|
|
89
|
+
// Fake streams in tests have no setRawMode; skip raw mode rather than
|
|
90
|
+
// throw, per the module's own contract.
|
|
91
|
+
const canRaw = typeof input.setRawMode === "function";
|
|
92
|
+
const wasRaw = canRaw ? input.isRaw : undefined;
|
|
93
|
+
|
|
94
|
+
function renderLines() {
|
|
95
|
+
const lines = items.map((item, i) => {
|
|
96
|
+
const marker = i === state.cursor ? bold(">", { color }) : " ";
|
|
97
|
+
const box = state.checked.has(item.key) ? ok("[x]", { color }) : "[ ]";
|
|
98
|
+
return `${marker} ${box} ${item.label}`;
|
|
99
|
+
});
|
|
100
|
+
const continueMarker = state.cursor === continueIndex ? bold(">", { color }) : " ";
|
|
101
|
+
const continueLabel = state.checked.size > 0 ? bold("Continue", { color }) : dim("Continue", { color });
|
|
102
|
+
lines.push(`${continueMarker} ${continueLabel}`);
|
|
103
|
+
lines.push(state.warning ? warn(state.warning, { color }) : "");
|
|
104
|
+
// Wrapped explicitly into its own rows (rather than left to the
|
|
105
|
+
// terminal) so each pushed entry is already <= `columns` wide: draw()
|
|
106
|
+
// can then paint one CLEAR_LINE per entry and have it actually own
|
|
107
|
+
// the physical row it clears (P1).
|
|
108
|
+
for (const row of wrap(LEGEND, columns, "")) lines.push(dim(row, { color }));
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function draw() {
|
|
113
|
+
const lines = renderLines();
|
|
114
|
+
if (drawn > 0) output.write(up(drawn));
|
|
115
|
+
for (const line of lines) output.write(`\r${CLEAR_LINE}${line}\n`);
|
|
116
|
+
drawn = lines.reduce((total, line) => total + physicalRows(line, columns), 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// P2: a bare ESC, or ESC + "[" with nothing after it yet, held here when
|
|
120
|
+
// it lands at the very end of a `data` chunk - it might be the first
|
|
121
|
+
// byte(s) of a CSI arrow sequence split across two chunks by a slow pty
|
|
122
|
+
// (reproduced by hand 2026-09-12), or it might really be the Escape key
|
|
123
|
+
// on its own. `escapeTimer` decides which: cleared the moment another
|
|
124
|
+
// chunk arrives (so the arrow's remaining bytes complete it normally),
|
|
125
|
+
// and fired after 50ms of silence to treat the hold as Escape.
|
|
126
|
+
let pendingEscape = "";
|
|
127
|
+
let escapeTimer = null;
|
|
128
|
+
|
|
129
|
+
function clearEscapeTimer() {
|
|
130
|
+
if (escapeTimer) { clearTimeout(escapeTimer); escapeTimer = null; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function cleanup() {
|
|
134
|
+
input.removeListener("data", onData);
|
|
135
|
+
input.removeListener("end", onEnd);
|
|
136
|
+
input.removeListener("close", onEnd);
|
|
137
|
+
process.removeListener("SIGINT", onSigint);
|
|
138
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
139
|
+
process.removeListener("SIGHUP", onSighup);
|
|
140
|
+
clearEscapeTimer();
|
|
141
|
+
if (canRaw && wasRaw !== undefined) input.setRawMode(wasRaw);
|
|
142
|
+
// Adding the `data` listener below switches a real stdin into flowing
|
|
143
|
+
// mode; removing the listener does not by itself put it back to
|
|
144
|
+
// sleep. Left flowing, the process never sees an empty event loop and
|
|
145
|
+
// `glytch init` hangs after printing its last line until Ctrl-C - seen
|
|
146
|
+
// for real 2026-09-12. Pause explicitly, on every exit path (confirm,
|
|
147
|
+
// cancel, SIGINT) - they all funnel through here via `finish`.
|
|
148
|
+
if (typeof input.pause === "function") input.pause();
|
|
149
|
+
output.write(SHOW_CURSOR);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function finish(result) {
|
|
153
|
+
if (settled) return;
|
|
154
|
+
settled = true;
|
|
155
|
+
cleanup();
|
|
156
|
+
resolvePromise(result);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A real SIGINT (raw mode disables the tty's own ISIG handling of ^C, so
|
|
160
|
+
// this fires only for a signal sent from outside - `kill -INT`, a parent
|
|
161
|
+
// process, Windows' console handler) must still leave the cursor shown.
|
|
162
|
+
// Cancelling is the right response either way: there is no "resume the
|
|
163
|
+
// picker" story for a signal that arrived out of band.
|
|
164
|
+
function onSigint() { finish(null); }
|
|
165
|
+
|
|
166
|
+
// P3: SIGTERM/SIGHUP used to reach no handler here at all, so `cleanup`
|
|
167
|
+
// never ran and a killed `glytch init` left the shell in raw mode with
|
|
168
|
+
// the cursor hidden. `finish` runs cleanup (and, via it, removes these
|
|
169
|
+
// very listeners) before resolving; re-raising afterwards restores the
|
|
170
|
+
// signal's own default action - now unopposed, because our listener is
|
|
171
|
+
// already gone - instead of this module silently swallowing a kill.
|
|
172
|
+
function onSigterm() { finish(null); process.kill(process.pid, "SIGTERM"); }
|
|
173
|
+
function onSighup() { finish(null); process.kill(process.pid, "SIGHUP"); }
|
|
174
|
+
|
|
175
|
+
// P3: stdin ending or closing out from under the picker (piped input
|
|
176
|
+
// exhausted, the terminal disconnected) used to leave the promise
|
|
177
|
+
// pending forever with the cursor hidden. Treat it exactly like Escape.
|
|
178
|
+
function onEnd() { finish(null); }
|
|
179
|
+
|
|
180
|
+
function toggleCursor() {
|
|
181
|
+
const itemKey = items[state.cursor].key;
|
|
182
|
+
if (state.checked.has(itemKey)) state.checked.delete(itemKey);
|
|
183
|
+
else state.checked.add(itemKey);
|
|
184
|
+
state.warning = "";
|
|
185
|
+
draw();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A chunk is not a keystroke: a fast typist, a paste, or (proven while
|
|
189
|
+
// driving this by hand through a pty) a handful of printf'd keys can all
|
|
190
|
+
// land in one `data` event holding several key events back to back. Walk
|
|
191
|
+
// it token by token - a three-byte arrow sequence, or one character -
|
|
192
|
+
// instead of matching the whole chunk, or every key after the first is
|
|
193
|
+
// silently dropped.
|
|
194
|
+
function handleKey(key) {
|
|
195
|
+
if (settled) return;
|
|
196
|
+
// \x04 is Ctrl-D (EOF) in raw mode - it never reaches "data" as end of
|
|
197
|
+
// stream the way it would in cooked mode, so it must be read as a key
|
|
198
|
+
// like any other (P3).
|
|
199
|
+
if (key === "\x03" || key === "\x1b" || key === "\x04") { finish(null); return; }
|
|
200
|
+
if (key === "\r" || key === "\n") {
|
|
201
|
+
// Enter selects (the rule, 2026-09-12): on an agent row it toggles
|
|
202
|
+
// that row, same as Space. Only Enter on the Continue row confirms.
|
|
203
|
+
if (state.cursor !== continueIndex) { toggleCursor(); return; }
|
|
204
|
+
if (state.checked.size === 0) {
|
|
205
|
+
state.warning = "choose at least one";
|
|
206
|
+
draw();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
finish(items.filter((item) => state.checked.has(item.key)).map((item) => item.key));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (key === "\x1b[A" || key === "k") {
|
|
213
|
+
state.cursor = (state.cursor - 1 + rowCount) % rowCount;
|
|
214
|
+
state.warning = "";
|
|
215
|
+
draw();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (key === "\x1b[B" || key === "j") {
|
|
219
|
+
state.cursor = (state.cursor + 1) % rowCount;
|
|
220
|
+
state.warning = "";
|
|
221
|
+
draw();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (key === " ") {
|
|
225
|
+
// Continue has no checkbox; Space on it does nothing rather than
|
|
226
|
+
// guessing at a confirm it was never asked for.
|
|
227
|
+
if (state.cursor === continueIndex) return;
|
|
228
|
+
toggleCursor();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (key === "a" || key === "A") {
|
|
232
|
+
if (state.checked.size === items.length) state.checked.clear();
|
|
233
|
+
else for (const item of items) state.checked.add(item.key);
|
|
234
|
+
state.warning = "";
|
|
235
|
+
draw();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// Anything else (a stray escape sequence, an unmapped letter) is
|
|
239
|
+
// ignored rather than mis-parsed as a selection.
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// P2 (confirmed, reproduced): a slow pty can split a 3-byte CSI arrow
|
|
243
|
+
// across two "data" events, most often right after the ESC byte. The old
|
|
244
|
+
// walk treated a trailing bare \x1b as a complete, standalone Escape the
|
|
245
|
+
// instant it saw it - cancelling the whole picker on the first half of
|
|
246
|
+
// an arrow key. Held bytes at the end of a chunk are stashed in
|
|
247
|
+
// `pendingEscape` and given 50ms to be completed by the next chunk
|
|
248
|
+
// before `flushPending` resolves them as ordinary keys (a lone \x1b is
|
|
249
|
+
// still Escape; anything else is still ignored).
|
|
250
|
+
function flushPending() {
|
|
251
|
+
escapeTimer = null;
|
|
252
|
+
const held = pendingEscape;
|
|
253
|
+
pendingEscape = "";
|
|
254
|
+
for (const ch of held) {
|
|
255
|
+
if (settled) break;
|
|
256
|
+
handleKey(ch);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function onData(chunk) {
|
|
261
|
+
clearEscapeTimer();
|
|
262
|
+
const data = pendingEscape + chunk.toString("utf8");
|
|
263
|
+
pendingEscape = "";
|
|
264
|
+
let i = 0;
|
|
265
|
+
while (i < data.length && !settled) {
|
|
266
|
+
// The only multi-byte sequences this picker knows are the 3-byte
|
|
267
|
+
// CSI arrows; a bare \x1b not followed by `[A`/`[B` is the Escape
|
|
268
|
+
// key on its own and cancels, exactly as it always did - UNLESS the
|
|
269
|
+
// chunk simply ran out before the sequence could finish, handled
|
|
270
|
+
// below.
|
|
271
|
+
if (data[i] === "\x1b" && data[i + 1] === "[" && (data[i + 2] === "A" || data[i + 2] === "B")) {
|
|
272
|
+
handleKey(data.slice(i, i + 3));
|
|
273
|
+
i += 3;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (data[i] === "\x1b" && (i === data.length - 1 || (data[i + 1] === "[" && i + 1 === data.length - 1))) {
|
|
277
|
+
pendingEscape = data.slice(i);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
handleKey(data[i]);
|
|
281
|
+
i += 1;
|
|
282
|
+
}
|
|
283
|
+
// Deliberately NOT unref'd: a held ESC is a real decision still
|
|
284
|
+
// waiting to be made (Escape, or the rest of an arrow), and the
|
|
285
|
+
// process should wait the 50ms for it rather than being free to exit
|
|
286
|
+
// with the picker's promise never settled.
|
|
287
|
+
if (pendingEscape && !settled) escapeTimer = setTimeout(flushPending, 50);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
process.on("SIGINT", onSigint);
|
|
291
|
+
process.on("SIGTERM", onSigterm);
|
|
292
|
+
process.on("SIGHUP", onSighup);
|
|
293
|
+
if (canRaw) input.setRawMode(true);
|
|
294
|
+
input.on("data", onData);
|
|
295
|
+
input.on("end", onEnd);
|
|
296
|
+
input.on("close", onEnd);
|
|
297
|
+
output.write(HIDE_CURSOR);
|
|
298
|
+
draw();
|
|
299
|
+
});
|
|
300
|
+
}
|
package/lib/platform.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// WHAT MACHINE THE RUNNER IS ON, in the one place the app can read it.
|
|
2
|
+
//
|
|
3
|
+
// Brief 21 session 5 (2026-09-07), rulings 3A and 6C. Two things need
|
|
4
|
+
// this and neither can guess:
|
|
5
|
+
//
|
|
6
|
+
// - THE APP MUST NOT GUESS FROM `navigator`. The words a spawn prompt
|
|
7
|
+
// carries for starting a detached dev server are POSIX on macOS/Linux and
|
|
8
|
+
// PowerShell on Windows (ruling 3A), and the session runs where the RUNNER
|
|
9
|
+
// is - which since brief 19 need not be where the browser is. A board open
|
|
10
|
+
// in Chrome on a Mac, linked to a folder on a Windows box through a
|
|
11
|
+
// synced directory, would be told `setsid` by a `navigator.platform`
|
|
12
|
+
// sniff and hand the session a command it cannot run. So the runner says
|
|
13
|
+
// what it is, in the mark file it already writes every beat, and the app
|
|
14
|
+
// reads it there or writes the POSIX words it always wrote.
|
|
15
|
+
//
|
|
16
|
+
// - WSL IS NOT WINDOWS AND SAYING SO IS THE POINT (ruling 6C: both count,
|
|
17
|
+
// and doctor names which). Node inside WSL reports `linux`, which is
|
|
18
|
+
// correct and is also exactly what the words need: `setsid` works there,
|
|
19
|
+
// `Start-Process` does not. So `wsl` is reported as its own name rather
|
|
20
|
+
// than folded into either - a consumer that only knows `win32` treats it
|
|
21
|
+
// as the Linux it is, and a consumer that wants to NAME the environment
|
|
22
|
+
// (doctor) has the fact.
|
|
23
|
+
//
|
|
24
|
+
// PURE AND INJECTABLE, the same shape as lib/windows-command.mjs beside it:
|
|
25
|
+
// nothing here reads the real platform or the real filesystem unless the
|
|
26
|
+
// caller lets the defaults stand, so the WSL branch is proven on a Mac.
|
|
27
|
+
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
|
+
|
|
30
|
+
/** Linux's own record of the kernel it is running. Under WSL 1 and WSL 2 this
|
|
31
|
+
* names Microsoft; on a real Linux box it does not. It is the check every
|
|
32
|
+
* other tool uses, and it costs one small read at start rather than a
|
|
33
|
+
* shell-out. */
|
|
34
|
+
export const PROC_VERSION = "/proc/version";
|
|
35
|
+
|
|
36
|
+
/** The runner's platform, as the mark file spells it.
|
|
37
|
+
*
|
|
38
|
+
* Returns `process.platform` (`darwin`, `win32`, `linux`, ...) except inside
|
|
39
|
+
* WSL, where it returns `"wsl"`. Never throws: a `/proc/version` that cannot
|
|
40
|
+
* be read is a machine that is not WSL as far as anyone here is concerned. */
|
|
41
|
+
export function runnerPlatform({ platform = process.platform, readFile = readFileSync } = {}) {
|
|
42
|
+
if (platform !== "linux") return platform;
|
|
43
|
+
try {
|
|
44
|
+
if (/microsoft/i.test(String(readFile(PROC_VERSION, "utf8")))) return "wsl";
|
|
45
|
+
} catch {
|
|
46
|
+
// No /proc/version, or no permission to read it. Plain Linux.
|
|
47
|
+
}
|
|
48
|
+
return platform;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the mark file gains, in the shape `markExtra()` returns.
|
|
52
|
+
*
|
|
53
|
+
* A separate function from `runnerPlatform` so the two callers
|
|
54
|
+
* (lib/multiwatch.mjs's daemon and spawn-runner.mjs's hand-started watcher)
|
|
55
|
+
* spread one object rather than each spelling the key, which is how `version`
|
|
56
|
+
* and `agents` already reach the mark. Computed once per beat and cheap: the
|
|
57
|
+
* `/proc/version` read only happens on Linux. */
|
|
58
|
+
export function platformMark(options = {}) {
|
|
59
|
+
return { platform: runnerPlatform(options) };
|
|
60
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Fixed-origin preview proxy. Every HTTP request and WebSocket upgrade is authorized. */
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
|
|
5
|
+
export function previewOrigin(value) {
|
|
6
|
+
const url = new URL(value);
|
|
7
|
+
if (!["http:", "https:"].includes(url.protocol) || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
|
8
|
+
|| url.username || url.password) throw new Error("A preview must target a loopback HTTP dev server.");
|
|
9
|
+
return url;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const AUTH_COOKIE = "__glytch_preview";
|
|
13
|
+
const HOP_HEADERS = ["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"];
|
|
14
|
+
function headersFor(headers, target, upgrade = false) {
|
|
15
|
+
const out = { ...headers };
|
|
16
|
+
const nominated = String(out.connection ?? "").split(",").map((name) => name.trim().toLowerCase());
|
|
17
|
+
for (const name of [...HOP_HEADERS, ...nominated]) delete out[name];
|
|
18
|
+
// Gateway credentials must never reach application code or its logs.
|
|
19
|
+
delete out["x-glytch-preview-grant"];
|
|
20
|
+
if (out.cookie) {
|
|
21
|
+
out.cookie = out.cookie.split(";").filter((part) => part.trim().split("=", 1)[0] !== AUTH_COOKIE).join(";");
|
|
22
|
+
if (!out.cookie.trim()) delete out.cookie;
|
|
23
|
+
}
|
|
24
|
+
out.host = target.host;
|
|
25
|
+
if (upgrade) { out.connection = "Upgrade"; out.upgrade = "websocket"; }
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function responseHeaders(headers) {
|
|
29
|
+
const out = { ...headers };
|
|
30
|
+
const nominated = String(out.connection ?? "").split(",").map((name) => name.trim().toLowerCase());
|
|
31
|
+
for (const name of [...HOP_HEADERS, ...nominated]) delete out[name];
|
|
32
|
+
if (out["set-cookie"]) out["set-cookie"] = out["set-cookie"].filter((cookie) => cookie.split("=", 1)[0].trim() !== AUTH_COOKIE);
|
|
33
|
+
out["cache-control"] = "no-store";
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
const validPath = (path) => typeof path === "string" && path.startsWith("/") && !path.startsWith("//") && !/[\r\n]/.test(path);
|
|
37
|
+
|
|
38
|
+
// Existing callers can return true. Scoped grants return an expiry so open
|
|
39
|
+
// streams and WebSockets cannot outlive membership's bounded lease.
|
|
40
|
+
function expireConnection(authorization, target) {
|
|
41
|
+
if (typeof authorization !== "object" || !Number.isFinite(authorization.expiresAt)) return;
|
|
42
|
+
const timer = setTimeout(() => target.destroy(), Math.max(0, authorization.expiresAt - Date.now()));
|
|
43
|
+
timer.unref?.();
|
|
44
|
+
target.once("close", () => clearTimeout(timer));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The authorization callback is mandatory; public transport never implies access. */
|
|
48
|
+
export async function createPreviewGateway({ target: value, authorize, handleAuth, timeoutMs = 30_000, maxConnections = 128, requestTimeoutMs = 15_000 }) {
|
|
49
|
+
const target = previewOrigin(value);
|
|
50
|
+
if (typeof authorize !== "function") throw new Error("Preview authorization is required.");
|
|
51
|
+
const transport = target.protocol === "https:" ? https : http;
|
|
52
|
+
const sockets = new Set();
|
|
53
|
+
const upstreams = new Set();
|
|
54
|
+
let closed = false;
|
|
55
|
+
const server = http.createServer(async (req, res) => {
|
|
56
|
+
try {
|
|
57
|
+
if (closed || !validPath(req.url)) { res.writeHead(400); res.end(); return; }
|
|
58
|
+
if (handleAuth && await handleAuth(req, res)) return;
|
|
59
|
+
const authorization = await authorize(req);
|
|
60
|
+
if (!authorization) { res.writeHead(403, { "cache-control": "no-store" }); res.end("Preview access is required."); return; }
|
|
61
|
+
if (closed || res.destroyed) return;
|
|
62
|
+
expireConnection(authorization, res);
|
|
63
|
+
const upstream = transport.request({ protocol: target.protocol, hostname: target.hostname.replace(/^\[|\]$/g, ""), port: target.port,
|
|
64
|
+
method: req.method, path: req.url, headers: headersFor(req.headers, target) }, (reply) => {
|
|
65
|
+
res.writeHead(reply.statusCode ?? 502, responseHeaders(reply.headers));
|
|
66
|
+
reply.pipe(res);
|
|
67
|
+
});
|
|
68
|
+
upstreams.add(upstream);
|
|
69
|
+
upstream.on("close", () => upstreams.delete(upstream));
|
|
70
|
+
upstream.setTimeout(timeoutMs, () => upstream.destroy(new Error("Preview upstream timed out.")));
|
|
71
|
+
upstream.on("error", () => { if (!res.headersSent) res.writeHead(502, { "cache-control": "no-store" }); res.end("The dev server is unavailable."); });
|
|
72
|
+
res.on("close", () => upstream.destroy());
|
|
73
|
+
req.pipe(upstream);
|
|
74
|
+
} catch {
|
|
75
|
+
if (!res.headersSent) res.writeHead(403, { "cache-control": "no-store" });
|
|
76
|
+
res.end("Preview access could not be verified.");
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
// Bound unauthenticated slow clients before grant verification or proxying.
|
|
80
|
+
server.maxConnections = maxConnections;
|
|
81
|
+
server.requestTimeout = requestTimeoutMs;
|
|
82
|
+
server.headersTimeout = Math.min(10_000, requestTimeoutMs);
|
|
83
|
+
server.keepAliveTimeout = 5_000;
|
|
84
|
+
server.maxRequestsPerSocket = 100;
|
|
85
|
+
server.setTimeout(requestTimeoutMs, (socket) => socket.destroy());
|
|
86
|
+
server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
|
|
87
|
+
server.on("upgrade", async (req, socket, head) => {
|
|
88
|
+
const refuse = () => socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
89
|
+
try {
|
|
90
|
+
if (closed || !validPath(req.url) || String(req.headers.upgrade).toLowerCase() !== "websocket") { refuse(); return; }
|
|
91
|
+
const authorization = await authorize(req);
|
|
92
|
+
if (!authorization) { refuse(); return; }
|
|
93
|
+
if (closed || socket.destroyed) return;
|
|
94
|
+
expireConnection(authorization, socket);
|
|
95
|
+
const upstream = transport.request({ protocol: target.protocol, hostname: target.hostname.replace(/^\[|\]$/g, ""), port: target.port,
|
|
96
|
+
method: "GET", path: req.url, headers: headersFor(req.headers, target, true) });
|
|
97
|
+
upstreams.add(upstream);
|
|
98
|
+
upstream.on("close", () => upstreams.delete(upstream));
|
|
99
|
+
upstream.setTimeout(timeoutMs, () => upstream.destroy());
|
|
100
|
+
socket.on("close", () => upstream.destroy());
|
|
101
|
+
upstream.on("error", () => socket.destroy());
|
|
102
|
+
upstream.on("response", () => socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"));
|
|
103
|
+
upstream.on("upgrade", (reply, peer, peerHead) => {
|
|
104
|
+
peer.setTimeout(0); socket.setTimeout(0);
|
|
105
|
+
if (closed || socket.destroyed) { peer.destroy(); return; }
|
|
106
|
+
sockets.add(peer); peer.on("close", () => { sockets.delete(peer); socket.destroy(); });
|
|
107
|
+
socket.on("close", () => peer.destroy()); peer.on("error", () => socket.destroy()); socket.on("error", () => peer.destroy());
|
|
108
|
+
const headers = responseHeaders(reply.headers);
|
|
109
|
+
headers.connection = "Upgrade"; headers.upgrade = "websocket";
|
|
110
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(headers).flatMap(([key, values]) => (Array.isArray(values) ? values : [values]).map((v) => `${key}: ${v}\r\n`)).join("")}\r\n`);
|
|
111
|
+
if (peerHead.length) socket.write(peerHead);
|
|
112
|
+
if (head.length) peer.write(head);
|
|
113
|
+
socket.pipe(peer); peer.pipe(socket);
|
|
114
|
+
});
|
|
115
|
+
upstream.end();
|
|
116
|
+
} catch { refuse(); }
|
|
117
|
+
});
|
|
118
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
|
119
|
+
return {
|
|
120
|
+
origin: `http://127.0.0.1:${server.address().port}`,
|
|
121
|
+
close: async () => {
|
|
122
|
+
if (closed) return;
|
|
123
|
+
closed = true;
|
|
124
|
+
for (const request of upstreams) request.destroy();
|
|
125
|
+
for (const socket of sockets) socket.destroy();
|
|
126
|
+
await new Promise((resolve) => server.close(resolve));
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Outbound transport only. Callers expose an authorized gateway, never a raw dev port. */
|
|
2
|
+
import { spawn as spawnProcess } from "node:child_process";
|
|
3
|
+
import { previewOrigin } from "./previewGateway.mjs";
|
|
4
|
+
import { ensureCloudflared } from "./cloudflared.mjs";
|
|
5
|
+
import { killTree } from "./kill.mjs";
|
|
6
|
+
|
|
7
|
+
export function quickTunnelUrl(text) {
|
|
8
|
+
return /https:\/\/[a-z0-9]+(?:-[a-z0-9]+)*\.trycloudflare\.com(?=[\s/]|$)/i.exec(text)?.[0] ?? null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function startQuickPreviewTunnel(gatewayOrigin, {
|
|
12
|
+
bin = process.env.PM_CLOUDFLARED_BIN,
|
|
13
|
+
ensureBinary = ensureCloudflared,
|
|
14
|
+
spawn = spawnProcess,
|
|
15
|
+
timeoutMs = 30_000,
|
|
16
|
+
kill = killTree,
|
|
17
|
+
signal,
|
|
18
|
+
} = {}) {
|
|
19
|
+
const local = previewOrigin(gatewayOrigin);
|
|
20
|
+
if (local.pathname !== "/" || local.search || local.hash) throw new Error("The tunnel requires a gateway origin.");
|
|
21
|
+
if (signal?.aborted) throw new Error("Preview sharing was stopped.");
|
|
22
|
+
if (!bin) bin = await ensureBinary({ signal });
|
|
23
|
+
if (signal?.aborted) throw new Error("Preview sharing was stopped.");
|
|
24
|
+
const child = spawn(bin, ["tunnel", "--no-autoupdate", "--url", local.origin, "--loglevel", "info"], {
|
|
25
|
+
stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
26
|
+
});
|
|
27
|
+
let exited = false;
|
|
28
|
+
let stopped = false;
|
|
29
|
+
let stopPromise;
|
|
30
|
+
child.once("exit", () => { exited = true; });
|
|
31
|
+
child.once("error", () => { exited = true; });
|
|
32
|
+
const stop = () => {
|
|
33
|
+
if (stopPromise) return stopPromise;
|
|
34
|
+
stopped = true;
|
|
35
|
+
stopPromise = (async () => {
|
|
36
|
+
signal?.removeEventListener("abort", abort);
|
|
37
|
+
if (exited || !child.pid) return;
|
|
38
|
+
try { await kill(child.pid); } catch { /* child may already have exited */ }
|
|
39
|
+
if (!exited) {
|
|
40
|
+
await new Promise((resolve) => {
|
|
41
|
+
const timer = setTimeout(resolve, 1_000);
|
|
42
|
+
child.once("exit", () => { clearTimeout(timer); resolve(); });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (!exited) { try { await kill(child.pid, { force: true }); } catch { /* already gone */ } }
|
|
46
|
+
})();
|
|
47
|
+
return stopPromise;
|
|
48
|
+
};
|
|
49
|
+
let cancelStart;
|
|
50
|
+
const abort = () => { cancelStart?.(); void stop(); };
|
|
51
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
52
|
+
try {
|
|
53
|
+
const url = await new Promise((resolve, reject) => {
|
|
54
|
+
let tail = "";
|
|
55
|
+
const timeout = setTimeout(() => finish(new Error("The preview tunnel did not connect. Check the network and retry.")), timeoutMs);
|
|
56
|
+
const onError = (error) => finish(new Error(error.code === "ENOENT"
|
|
57
|
+
? "Shared previews need cloudflared installed on your computer. Install it once, then sharing starts on its own."
|
|
58
|
+
: "The preview tunnel could not start."));
|
|
59
|
+
const onExit = () => finish(new Error("The preview tunnel stopped before it connected."));
|
|
60
|
+
const onData = (chunk) => {
|
|
61
|
+
tail = (tail + chunk.toString()).slice(-8192);
|
|
62
|
+
const found = quickTunnelUrl(tail);
|
|
63
|
+
if (found) finish(null, found);
|
|
64
|
+
};
|
|
65
|
+
const finish = (error, found) => {
|
|
66
|
+
cancelStart = undefined;
|
|
67
|
+
clearTimeout(timeout);
|
|
68
|
+
child.removeListener("error", onError); child.removeListener("exit", onExit);
|
|
69
|
+
child.stdout?.removeListener("data", onData); child.stderr?.removeListener("data", onData);
|
|
70
|
+
// Keep draining output without retaining URLs or user project logs.
|
|
71
|
+
child.stdout?.resume(); child.stderr?.resume();
|
|
72
|
+
if (error) reject(error); else resolve(found);
|
|
73
|
+
};
|
|
74
|
+
cancelStart = () => finish(new Error("Preview sharing was stopped."));
|
|
75
|
+
child.once("error", onError); child.once("exit", onExit);
|
|
76
|
+
child.stdout?.on("data", onData); child.stderr?.on("data", onData);
|
|
77
|
+
if (signal?.aborted) finish(new Error("Preview sharing was stopped."));
|
|
78
|
+
});
|
|
79
|
+
if (stopped || signal?.aborted || exited) throw new Error("Preview sharing was stopped.");
|
|
80
|
+
return { url, stop, alive: () => !stopped && !exited };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await stop();
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|