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
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Provider-neutral lifecycle for sharing a worktree dev server, over rows
|
|
2
|
+
// (brief 30 Move 2b, 2026-09-11). The app asks with a `run_ops` row of
|
|
3
|
+
// `kind:"preview"` now (`heartbeat`'s `ops`, `opTake` claims it once,
|
|
4
|
+
// `opResult` is UPDATABLE - unlike merge/delete/relaunch, a preview's result
|
|
5
|
+
// ticks through starting/ready/offline/error on the SAME op row rather than
|
|
6
|
+
// one receipt per state). This module proves that the named loopback port is
|
|
7
|
+
// a verified server belonging to the named board run before a tunnel
|
|
8
|
+
// provider ever sees it - the proof is read from the CLAIMED run row (via
|
|
9
|
+
// `runnerApi.runs`) and from `get_node` over `hosted.callTool`, NEVER from
|
|
10
|
+
// `canvas/`: there is no local mirror requirement any more, so a preview
|
|
11
|
+
// works whether or not a browser has this folder linked at all.
|
|
12
|
+
//
|
|
13
|
+
// WHAT REPLACED WHAT. The file version polled a directory of `*.json`
|
|
14
|
+
// requests every tick and noticed a deleted file as "stop it, the request
|
|
15
|
+
// was removed"; there is no such signal over rows (a taken `run_ops` row
|
|
16
|
+
// carries no "cancel" op in the Move 2 contract - `run_op_cancel` only
|
|
17
|
+
// reaches an UNTAKEN one, server-side, before this module ever sees it). So
|
|
18
|
+
// every preview this module tracks ends the same way now: the next proof
|
|
19
|
+
// check fails - the owning run stopped, the browser node was deleted or its
|
|
20
|
+
// URL changed, the recorded server can no longer be verified - and that
|
|
21
|
+
// failure IS the removal signal, exactly as it already was for "the request
|
|
22
|
+
// changed under me" in the file version. `close()` still stops every
|
|
23
|
+
// tracked tunnel on its own shutdown.
|
|
24
|
+
//
|
|
25
|
+
// THE PROOF ITSELF is the same three checks the file version made, sourced
|
|
26
|
+
// differently: which run this preview belongs to (`runnerApi.runs`, matched
|
|
27
|
+
// by `runKey === run.id`, its OWN `from_id` naming the prompt directly -
|
|
28
|
+
// simpler than the old graph search for "a prompt whose `run.key` equals
|
|
29
|
+
// this one", because the row already knows), the browser node's kind, url
|
|
30
|
+
// and incoming edge from that prompt (`get_node`, parsed off its own text
|
|
31
|
+
// reply - the endpoint's only shape), and the recorded dev server itself
|
|
32
|
+
// (`run.server`, mirrored onto the row by `runnerCompletion.mjs` when the
|
|
33
|
+
// child exits, or the worktree's own live `.glytch-server.json` while the
|
|
34
|
+
// run is still going - `verifyPreviewServer`, unchanged).
|
|
35
|
+
|
|
36
|
+
import { readWorktreeRecord, verifyPreviewServer } from "./devserver.mjs";
|
|
37
|
+
import { callTool as hostedCallToolDefault } from "./hosted.mjs";
|
|
38
|
+
import { fromIdOf } from "./boardside.mjs";
|
|
39
|
+
import * as runnerApiModule from "./runnerApi.mjs";
|
|
40
|
+
|
|
41
|
+
export const PREVIEW_VERSION = 1;
|
|
42
|
+
export const MAX_RESTARTS = 5;
|
|
43
|
+
export const STABLE_RESET_MS = 60_000;
|
|
44
|
+
|
|
45
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
46
|
+
const SAFE_ID = /^[A-Za-z0-9_-]+$/;
|
|
47
|
+
// Match the grant issuer's namespaced cloud-board contract. Values outside
|
|
48
|
+
// this shape can never receive a member grant, so reject them before startup.
|
|
49
|
+
const SAFE_CLOUD_BOARD_ID = /^[^\s:]+:[^\s:]+$/;
|
|
50
|
+
const OPAQUE_PREVIEW_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
51
|
+
|
|
52
|
+
function localOrigin(value) {
|
|
53
|
+
let url;
|
|
54
|
+
try { url = new URL(value); } catch { return null; }
|
|
55
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
56
|
+
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return null;
|
|
57
|
+
if (url.username || url.password) return null;
|
|
58
|
+
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
59
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? { url, port, origin: url.origin } : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Validate a `run_ops` row's own `payload` for `kind:"preview"` - the same
|
|
63
|
+
* fields the file request used to carry (Move 2 contract: `{nodeId, runKey,
|
|
64
|
+
* localUrl, boardId, workspaceId, cloudBoardId}`), unchanged in shape. */
|
|
65
|
+
function parsePayload(payload) {
|
|
66
|
+
const value = isObject(payload) ? payload : {};
|
|
67
|
+
if (value.version !== undefined && value.version !== PREVIEW_VERSION) {
|
|
68
|
+
throw new Error("Preview request needs version 1.");
|
|
69
|
+
}
|
|
70
|
+
for (const field of ["boardId", "workspaceId", "cloudBoardId", "nodeId", "runKey", "localUrl"]) {
|
|
71
|
+
if (typeof value[field] !== "string" || value[field].trim() === "") throw new Error(`Preview request needs ${field}.`);
|
|
72
|
+
}
|
|
73
|
+
for (const field of ["boardId", "workspaceId", "nodeId", "runKey"]) {
|
|
74
|
+
if (!SAFE_ID.test(value[field])) throw new Error(`Preview request ${field} contains unsafe characters.`);
|
|
75
|
+
}
|
|
76
|
+
if (!SAFE_CLOUD_BOARD_ID.test(value.cloudBoardId)) throw new Error("Preview request cloudBoardId contains unsafe characters.");
|
|
77
|
+
const origin = localOrigin(value.localUrl);
|
|
78
|
+
if (!origin) throw new Error("localUrl must be a credential-free loopback HTTP(S) URL.");
|
|
79
|
+
return { ...value, origin: origin.origin, port: origin.port };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parse `get_node`'s own text reply (supabase/functions/board-mcp/tools.mjs)
|
|
83
|
+
* back into `{node, incoming, outgoing}`. It is a text protocol for a
|
|
84
|
+
* model, not a second JSON API - one line is the node's own JSON (an image
|
|
85
|
+
* value redacted, harmless here), then "Incoming edges from: a, b" and
|
|
86
|
+
* "Outgoing edges to: c, d" when either exists. */
|
|
87
|
+
export function parseNodeText(text) {
|
|
88
|
+
let node = null;
|
|
89
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
90
|
+
if (!line.startsWith("{")) continue;
|
|
91
|
+
try { node = JSON.parse(line); break; } catch { /* not this line */ }
|
|
92
|
+
}
|
|
93
|
+
const split = (label) => {
|
|
94
|
+
const m = new RegExp(`^${label}: (.*)$`, "m").exec(text ?? "");
|
|
95
|
+
return m ? m[1].split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
96
|
+
};
|
|
97
|
+
return { node, incoming: split("Incoming edges from"), outgoing: split("Outgoing edges to") };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The proof, over rows. Throws with a human-readable reason on any failure
|
|
101
|
+
* - the caller turns that into an `error` opResult, unchanged in spirit
|
|
102
|
+
* from the file version. */
|
|
103
|
+
async function rowServerProof(request, projectId, deps) {
|
|
104
|
+
const listed = await deps.runs({ projectIds: [projectId] }, deps.rowsOpts);
|
|
105
|
+
if (!listed.ok) throw new Error(`Could not list this project's runs: ${listed.error}`);
|
|
106
|
+
const run = (listed.runs ?? []).find((r) => r.id === request.runKey);
|
|
107
|
+
if (!run || run.approved !== true) throw new Error(`No matching approved run for ${request.runKey}.`);
|
|
108
|
+
|
|
109
|
+
const promptId = fromIdOf(run.request);
|
|
110
|
+
if (!promptId) throw new Error(`Run ${request.runKey} has no idea to attach a preview to.`);
|
|
111
|
+
|
|
112
|
+
const nodeOut = await deps.callTool("get_node", { id: request.nodeId }, { ...deps.toolOpts, boardId: request.cloudBoardId });
|
|
113
|
+
if (nodeOut.isError) throw new Error(`Could not read node ${request.nodeId}: ${nodeOut.text}`);
|
|
114
|
+
const browser = parseNodeText(nodeOut.text);
|
|
115
|
+
if (browser.node?.data?.kind !== "browser") throw new Error(`Node ${request.nodeId} is not a browser on board ${request.boardId}.`);
|
|
116
|
+
if (browser.node?.data?.url !== request.localUrl) throw new Error("Browser node local URL does not match the preview request.");
|
|
117
|
+
if (!browser.incoming.includes(promptId)) throw new Error("Browser node is not linked from the idea that owns this run.");
|
|
118
|
+
|
|
119
|
+
// THE RECORDED DEV SERVER: the worktree's own live file while the run is
|
|
120
|
+
// still going (a session that just started its server, before the runner
|
|
121
|
+
// has mirrored anything onto the row), else the row's own `server` column,
|
|
122
|
+
// mirrored by `runnerCompletion.mjs` once the child exits.
|
|
123
|
+
let record = null;
|
|
124
|
+
if (typeof run.sidecar?.worktreePath === "string" && run.sidecar.worktreePath !== "") {
|
|
125
|
+
record = await deps.readLiveRecord(run.sidecar.worktreePath);
|
|
126
|
+
if (record) record = { ...record, worktreePath: run.sidecar.worktreePath };
|
|
127
|
+
}
|
|
128
|
+
if (!record && run.server) record = run.server;
|
|
129
|
+
if (!record) throw new Error("This run has no recorded dev server.");
|
|
130
|
+
if (Number(record.port) !== request.port) throw new Error("Preview URL port does not match the recorded dev server.");
|
|
131
|
+
const verified = await deps.verify(record);
|
|
132
|
+
if (!verified?.ours) throw new Error(`Recorded dev server could not be verified: ${verified?.reason ?? "unknown"}.`);
|
|
133
|
+
return { record, promptId, run };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A rows-driven preview tunnel service. `onOps(ops, projectId)` is fed
|
|
137
|
+
* `heartbeat`'s untaken ops list each beat (like `serviceRowOps`'s
|
|
138
|
+
* merge/delete handling) and claims (`opTake`) every `kind:"preview"` one
|
|
139
|
+
* it finds, once; `tick()` re-verifies and advances every op it is
|
|
140
|
+
* tracking, writing `opResult` on each lifecycle change; `close()` tears
|
|
141
|
+
* every tracked tunnel down. `projectId` is threaded through per call
|
|
142
|
+
* rather than fixed at construction, because `watch()` only learns its
|
|
143
|
+
* project id after the FIRST successful `register` - the service itself is
|
|
144
|
+
* made once, before that. */
|
|
145
|
+
export function createSharedPreviewService({ runnerApi = runnerApiModule, hostedCallTool = hostedCallToolDefault, env, fetchImpl, deps: overrides = {}, startTunnel }) {
|
|
146
|
+
if (typeof startTunnel !== "function") throw new TypeError("startTunnel is required");
|
|
147
|
+
const rowsOpts = { env, fetchImpl };
|
|
148
|
+
const toolOpts = { env, fetchImpl };
|
|
149
|
+
const deps = {
|
|
150
|
+
now: () => Date.now(),
|
|
151
|
+
runs: runnerApi.runs,
|
|
152
|
+
opResult: runnerApi.opResult,
|
|
153
|
+
callTool: hostedCallTool,
|
|
154
|
+
readLiveRecord: readWorktreeRecord,
|
|
155
|
+
verify: verifyPreviewServer,
|
|
156
|
+
rowsOpts,
|
|
157
|
+
toolOpts,
|
|
158
|
+
...overrides,
|
|
159
|
+
};
|
|
160
|
+
const active = new Map(); // opId -> {request, stamp, handle, restarts, nextAttemptAt, readySince}
|
|
161
|
+
const lifetime = new AbortController();
|
|
162
|
+
let closed = false;
|
|
163
|
+
|
|
164
|
+
const writeResult = (opId, status, extra = {}) =>
|
|
165
|
+
deps.opResult({ opId, result: { status, ...extra, updatedAt: deps.now() } }, rowsOpts);
|
|
166
|
+
|
|
167
|
+
const stop = async (opId, state, detail) => {
|
|
168
|
+
active.delete(opId);
|
|
169
|
+
try { await state.handle?.stop?.(); } catch { /* best effort; the result still tells */ }
|
|
170
|
+
await writeResult(opId, "offline", { detail });
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Claim every untaken preview op this beat's heartbeat surfaced. Bad
|
|
174
|
+
* payloads are refused at once, same as a malformed file used to be. */
|
|
175
|
+
async function onOps(ops, projectId) {
|
|
176
|
+
for (const op of ops ?? []) {
|
|
177
|
+
if (closed) return;
|
|
178
|
+
if (op.kind !== "preview" || active.has(op.id)) continue;
|
|
179
|
+
const taken = await runnerApi.opTake({ opId: op.id }, rowsOpts);
|
|
180
|
+
if (!taken.ok || !taken.op) continue; // already taken, gone, or not ours
|
|
181
|
+
if (closed) {
|
|
182
|
+
await writeResult(op.id, "offline", { detail: "Preview service stopped." });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
let request;
|
|
186
|
+
try {
|
|
187
|
+
request = parsePayload(taken.op.payload);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
await writeResult(op.id, "error", { detail: error instanceof Error ? error.message : String(error) });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
active.set(op.id, { request, projectId, handle: null, restarts: 0, nextAttemptAt: 0 });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let ticking = null;
|
|
197
|
+
|
|
198
|
+
/** Re-verify and advance every tracked preview. Called every beat.
|
|
199
|
+
* Single-flight: two concurrent calls share one pass rather than racing
|
|
200
|
+
* each other into starting the same tunnel twice. */
|
|
201
|
+
function tick() {
|
|
202
|
+
if (ticking) return ticking;
|
|
203
|
+
ticking = runTick().finally(() => { ticking = null; });
|
|
204
|
+
return ticking;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function runTick() {
|
|
208
|
+
if (closed) return;
|
|
209
|
+
for (const [opId, state] of [...active]) {
|
|
210
|
+
if (closed) return;
|
|
211
|
+
const { request, projectId } = state;
|
|
212
|
+
if (state.handle) {
|
|
213
|
+
try {
|
|
214
|
+
await rowServerProof(request, projectId, deps);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
await stop(opId, state, error instanceof Error ? error.message : String(error));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
let alive = true;
|
|
220
|
+
try { alive = state.handle.alive ? await state.handle.alive() : true; } catch { alive = false; }
|
|
221
|
+
if (alive) {
|
|
222
|
+
if (deps.now() - state.readySince >= STABLE_RESET_MS) state.restarts = 0;
|
|
223
|
+
await writeResult(opId, "ready", { previewId: state.previewId, url: new URL(state.handle.url).toString() });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
try { await state.handle.stop?.(); } catch { /* already gone */ }
|
|
227
|
+
state.handle = null;
|
|
228
|
+
state.nextAttemptAt = deps.now() + Math.min(30_000, 1000 * 2 ** state.restarts);
|
|
229
|
+
state.restarts += 1;
|
|
230
|
+
await writeResult(opId, "offline", { detail: "Tunnel stopped; restart is scheduled." });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (state.restarts >= MAX_RESTARTS || deps.now() < state.nextAttemptAt) continue;
|
|
234
|
+
let provisional = null;
|
|
235
|
+
try {
|
|
236
|
+
const proof = await rowServerProof(request, projectId, deps);
|
|
237
|
+
if (closed) return;
|
|
238
|
+
await writeResult(opId, "starting");
|
|
239
|
+
const handle = await startTunnel(request.origin, {
|
|
240
|
+
...request, fullLocalUrl: request.localUrl, record: proof.record, promptId: proof.promptId,
|
|
241
|
+
signal: lifetime.signal,
|
|
242
|
+
authorizeTarget: async () => {
|
|
243
|
+
if (closed) return false;
|
|
244
|
+
try { await rowServerProof(request, projectId, deps); return !closed; }
|
|
245
|
+
catch { return false; }
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
if (isObject(handle) && typeof handle.stop === "function") provisional = handle;
|
|
249
|
+
if (!isObject(handle) || typeof handle.url !== "string" || typeof handle.stop !== "function") {
|
|
250
|
+
throw new Error("Tunnel provider returned no preview id/URL/stop handle.");
|
|
251
|
+
}
|
|
252
|
+
if (typeof handle.previewId !== "string" || !OPAQUE_PREVIEW_ID.test(handle.previewId)) {
|
|
253
|
+
throw new Error("Tunnel provider returned no opaque preview id.");
|
|
254
|
+
}
|
|
255
|
+
const publicUrl = new URL(handle.url);
|
|
256
|
+
if (publicUrl.protocol !== "https:" || publicUrl.username || publicUrl.password ||
|
|
257
|
+
["localhost", "127.0.0.1", "[::1]"].includes(publicUrl.hostname)) {
|
|
258
|
+
throw new Error("Tunnel provider URL must be a credential-free, non-loopback HTTPS URL.");
|
|
259
|
+
}
|
|
260
|
+
if (closed) throw new Error("Preview service stopped during tunnel startup.");
|
|
261
|
+
await rowServerProof(request, projectId, deps);
|
|
262
|
+
if (closed) throw new Error("Preview service stopped during tunnel startup.");
|
|
263
|
+
state.handle = handle;
|
|
264
|
+
state.previewId = handle.previewId;
|
|
265
|
+
state.readySince = deps.now();
|
|
266
|
+
provisional = null;
|
|
267
|
+
await writeResult(opId, "ready", { previewId: handle.previewId, url: publicUrl.toString() });
|
|
268
|
+
} catch (error) {
|
|
269
|
+
try { await provisional?.stop?.(); } catch { /* provider cleanup is best effort */ }
|
|
270
|
+
state.restarts += 1;
|
|
271
|
+
state.nextAttemptAt = deps.now() + Math.min(30_000, 1000 * 2 ** (state.restarts - 1));
|
|
272
|
+
await writeResult(opId, "error", { detail: error instanceof Error ? error.message : String(error) });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const close = async () => {
|
|
278
|
+
closed = true;
|
|
279
|
+
lifetime.abort();
|
|
280
|
+
// A provider start already in flight must finish (or abort, on the
|
|
281
|
+
// signal just fired) before we enumerate the handles to stop, otherwise
|
|
282
|
+
// shutdown can leave a late handle exposed.
|
|
283
|
+
let failure;
|
|
284
|
+
try { if (ticking) await ticking; } catch (error) { failure = error; }
|
|
285
|
+
for (const [opId, state] of [...active]) {
|
|
286
|
+
try { await stop(opId, state, "Preview service stopped."); }
|
|
287
|
+
catch (error) { failure ??= error; }
|
|
288
|
+
}
|
|
289
|
+
if (failure) throw failure;
|
|
290
|
+
};
|
|
291
|
+
return { onOps, tick, close };
|
|
292
|
+
}
|
package/lib/signIn.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SIGN IN TO CODEX FROM THE BOARD (#482, ruling #470: "the Codex sign-in
|
|
2
|
+
// page opened for you"). The app files a run row whose request is
|
|
3
|
+
// `{kind: "sign-in", agent: "codex"}`; the runner answers it here by
|
|
4
|
+
// starting `codex login` on this computer. Codex opens the ChatGPT sign-in
|
|
5
|
+
// page in this computer's browser and exits once the sign-in lands, so no
|
|
6
|
+
// one types in a terminal. The runner then probes the agents again, and the
|
|
7
|
+
// app's box goes once the probe says Codex is signed in.
|
|
8
|
+
//
|
|
9
|
+
// Nothing is billed: `codex login` starts no model turn.
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { runnableCommand } from "./windows-command.mjs";
|
|
12
|
+
|
|
13
|
+
/** How long a person has to finish signing in on the page that opened. */
|
|
14
|
+
export const SIGN_IN_WAIT_MS = 10 * 60_000;
|
|
15
|
+
|
|
16
|
+
/** The engines a sign-in request may name. Claude Code signs in from its
|
|
17
|
+
* own first run; opencode's sign-in cannot be checked (detect.mjs). */
|
|
18
|
+
export const SIGN_IN_AGENTS = ["codex"];
|
|
19
|
+
|
|
20
|
+
/** Start `codex login` and wait for it to end. Resolves `{ok, detail}`,
|
|
21
|
+
* never throws: a missing binary or a sign-in left open is an answer. */
|
|
22
|
+
export function codexLogin({ env = process.env, timeoutMs = SIGN_IN_WAIT_MS, platform = process.platform } = {}) {
|
|
23
|
+
const bin = env.PM_CODEX_BIN?.trim() || "codex";
|
|
24
|
+
const command = runnableCommand(bin, ["login"], { platform, env });
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
let child;
|
|
27
|
+
try {
|
|
28
|
+
child = spawn(command.file, command.args, { env, shell: command.shell, windowsHide: true, stdio: "ignore" });
|
|
29
|
+
} catch (error) {
|
|
30
|
+
resolve({ ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
child.kill();
|
|
35
|
+
resolve({ ok: false, detail: "The sign-in page was left open for 10 minutes." });
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
timer.unref?.();
|
|
38
|
+
child.on("error", (error) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolve({ ok: false, detail: error.code === "ENOENT" ? "Codex is not installed on this computer." : error.message });
|
|
41
|
+
});
|
|
42
|
+
child.on("exit", (code) => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
resolve(code === 0 ? { ok: true, detail: "Codex is signed in." } : { ok: false, detail: `codex login ended with code ${code}.` });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|