siltrun 0.1.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/src/deploy.ts ADDED
@@ -0,0 +1,396 @@
1
+ // `siltrun deploy <contract.ts> [--room <name>]` — ship a room to the hosted beta via the
2
+ // pull-model intake worker (deploy/CONTROL-PLANE.md). The tester has no gcloud, no repo,
3
+ // no config files — just a token + this CLI. Flow (client side of the contract):
4
+ //
5
+ // 1. bundle the contract (reuse bundle.ts) — same Bun.build the dev loop uses
6
+ // 2. run the LOCAL determinism doctor (reuse doctor.ts) as a fast pre-gate; drift aborts
7
+ // BEFORE any upload (the box re-gates server-side regardless)
8
+ // 3. POST the RAW bundle JS to /v0/deploy?room=<name> with Bearer <SILT_DEPLOY_TOKEN>
9
+ // 4. poll GET /v0/deploy/<id> (~2s cadence, ~2min budget) with a live status line
10
+ // 5. on live → print the URL; on failed → print the server error; on timeout → say so
11
+ //
12
+ // Sketch appetite: clean happy path + honest failures. Env + flags only — no retry
13
+ // frameworks, no config-file system.
14
+
15
+ import { resolve, join } from "node:path";
16
+ import { existsSync } from "node:fs";
17
+ import { mkdtemp } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+
20
+ import { deriveRoomName, ArgError } from "./args.ts";
21
+ import { bundleContract, BundleError } from "./bundle.ts";
22
+ import { runDoctor, type DoctorResult } from "./doctor.ts";
23
+ import {
24
+ postDeploy,
25
+ getDeployStatus,
26
+ normalizeBase,
27
+ DeployHttpError,
28
+ type DeployRecord,
29
+ type DeployStatus,
30
+ type FetchLike,
31
+ } from "./deploy-client.ts";
32
+ import { loadCredentials } from "./credentials.ts";
33
+ import { log, paint } from "./log.ts";
34
+
35
+ // Baked default intake URL — the deployed silt-deploy-intake worker (beta). Overridable
36
+ // any time via SILT_DEPLOY_URL for local mocks / miniflare.
37
+ export const DEFAULT_INTAKE_URL = "https://silt-deploy-intake.ai-chat-game.workers.dev";
38
+
39
+ // Where a tester gets a token. Kept as a pointer so the "no token" error is actionable.
40
+ const ONBOARDING_DOC = "https://silt.digitalpine.io/beta";
41
+
42
+ // Where a tester reports a failure that isn't theirs to fix (docs/SUPPORT.md — Discord
43
+ // #support is THE channel; there is no support email).
44
+ const SUPPORT_CHANNEL = "#support on Discord (discord.gg/2nbsnZfks5)";
45
+
46
+ // Poll cadence + budget (contract: ~2s, ~2min).
47
+ const POLL_INTERVAL_MS = 2000;
48
+ const POLL_BUDGET_MS = 120_000;
49
+
50
+ // Room names ride in a URL path/query — keep them path-safe. Mirrors args.ts's ROOM_NAME_RE
51
+ // and the server-side validRoomName; duplicated here so `siltrun dev` parsing stays untouched.
52
+ const ROOM_NAME_RE = /^[A-Za-z0-9._-]+$/;
53
+
54
+ // DEPLOYED rooms carry a second, server-enforced rule (intake worker MAX_ROOM_LEN,
55
+ // DIG-676): the box maps a room to a Linux tap device named `tap<room>`, and Linux's
56
+ // IFNAMSIZ caps interface names at 15 chars — so a deployable room name is at most 12.
57
+ // Local `siltrun dev` has no such cap (no tap device involved); enforce it HERE, before
58
+ // any upload, with the same honest reason the server would give later.
59
+ const MAX_ROOM_LEN = 12;
60
+ const ROOM_LEN_REASON =
61
+ `deployed rooms map to a Linux network interface named tap<room>, ` +
62
+ `capped at 15 chars — so room names max out at ${MAX_ROOM_LEN}`;
63
+
64
+ export interface DeployOptions {
65
+ contract: string;
66
+ /** room name; the deployed path is /room/<room>. Derived from the contract when not given. */
67
+ room: string;
68
+ }
69
+
70
+ /**
71
+ * Parse `siltrun deploy` arguments. `argv` is everything AFTER `deploy`. Room derivation matches
72
+ * `siltrun dev`: --room flag (validated), else SILT_ROOM env (validated), else derived from the
73
+ * contract path. Kept separate from parseDevArgs so dev's parser is never perturbed.
74
+ */
75
+ export function parseDeployArgs(
76
+ argv: string[],
77
+ env: Record<string, string | undefined> = process.env,
78
+ ): DeployOptions {
79
+ const positionals: string[] = [];
80
+ const flags = new Map<string, string | boolean>();
81
+
82
+ for (let i = 0; i < argv.length; i++) {
83
+ const a = argv[i]!;
84
+ if (a.startsWith("--")) {
85
+ const eq = a.indexOf("=");
86
+ if (eq >= 0) {
87
+ flags.set(a.slice(2, eq), a.slice(eq + 1));
88
+ } else {
89
+ const key = a.slice(2);
90
+ const next = argv[i + 1];
91
+ if (next != null && !next.startsWith("--")) {
92
+ flags.set(key, next);
93
+ i++;
94
+ } else {
95
+ flags.set(key, true);
96
+ }
97
+ }
98
+ } else {
99
+ positionals.push(a);
100
+ }
101
+ }
102
+
103
+ const contract = positionals[0];
104
+ if (!contract) {
105
+ throw new ArgError("missing contract file — usage: siltrun deploy <contract.ts> [--room <name>]");
106
+ }
107
+
108
+ const explicitRoom = (flags.get("room") as string | undefined) ?? env.SILT_ROOM;
109
+ let room: string;
110
+ if (explicitRoom != null) {
111
+ if (
112
+ typeof explicitRoom !== "string" ||
113
+ !ROOM_NAME_RE.test(explicitRoom) ||
114
+ explicitRoom === "." ||
115
+ explicitRoom === ".."
116
+ ) {
117
+ throw new ArgError(`--room must match [A-Za-z0-9._-]+ (got ${JSON.stringify(explicitRoom)})`);
118
+ }
119
+ if (explicitRoom.length > MAX_ROOM_LEN) {
120
+ throw new ArgError(
121
+ `--room ${JSON.stringify(explicitRoom)} is ${explicitRoom.length} chars — ` +
122
+ `${ROOM_LEN_REASON}. Pick a shorter name.`,
123
+ );
124
+ }
125
+ room = explicitRoom;
126
+ } else {
127
+ room = deriveRoomName(contract);
128
+ if (room.length > MAX_ROOM_LEN) {
129
+ throw new ArgError(
130
+ `the room name derived from your contract path (${JSON.stringify(room)}, ` +
131
+ `${room.length} chars) is too long to deploy — ${ROOM_LEN_REASON}. ` +
132
+ `Pass --room <name> with ${MAX_ROOM_LEN} chars or fewer.`,
133
+ );
134
+ }
135
+ }
136
+
137
+ return { contract, room };
138
+ }
139
+
140
+ // A minimal logger surface so runDeploy's output is capturable in tests. Defaults to the
141
+ // real CLI logger.
142
+ export interface DeployLogger {
143
+ info(msg: string): void;
144
+ warn(msg: string): void;
145
+ error(msg: string): void;
146
+ plain(msg?: string): void;
147
+ }
148
+
149
+ export interface DeployDeps {
150
+ env: Record<string, string | undefined>;
151
+ fetchImpl: FetchLike;
152
+ sleep: (ms: number) => Promise<void>;
153
+ bundle: (contractPath: string, outPath: string) => Promise<string>;
154
+ doctor: (bundlePath: string) => Promise<DoctorResult>;
155
+ logger: DeployLogger;
156
+ pollIntervalMs: number;
157
+ pollBudgetMs: number;
158
+ }
159
+
160
+ export interface DeployResult {
161
+ ok: boolean;
162
+ /** "live" success; otherwise the honest failure kind. */
163
+ status: "live" | "failed" | "timeout" | "aborted";
164
+ deployId?: string;
165
+ url?: string;
166
+ error?: string;
167
+ }
168
+
169
+ function defaultDeps(): DeployDeps {
170
+ return {
171
+ env: process.env,
172
+ fetchImpl: fetch,
173
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
174
+ bundle: bundleContract,
175
+ doctor: (bundlePath) => runDoctor(bundlePath),
176
+ logger: log,
177
+ pollIntervalMs: POLL_INTERVAL_MS,
178
+ pollBudgetMs: POLL_BUDGET_MS,
179
+ };
180
+ }
181
+
182
+ // Human labels for the live status line.
183
+ const STATUS_LABEL: Record<DeployStatus, string> = {
184
+ queued: "queued",
185
+ provisioning: "provisioning room…",
186
+ live: "live",
187
+ failed: "failed",
188
+ };
189
+
190
+ /**
191
+ * The testable core. Does bundle + local doctor pre-gate, then POST + poll, logging as it
192
+ * goes. Returns a DeployResult; never calls process.exit (deploy() maps that). Every failure
193
+ * path is honest — it never claims success it didn't observe.
194
+ */
195
+ export async function runDeploy(
196
+ opts: DeployOptions,
197
+ overrides: Partial<DeployDeps> = {},
198
+ ): Promise<DeployResult> {
199
+ const deps = { ...defaultDeps(), ...overrides };
200
+ const { logger, env } = deps;
201
+
202
+ const contract = resolve(opts.contract);
203
+ if (!existsSync(contract)) {
204
+ logger.error(`contract not found: ${contract}`);
205
+ return { ok: false, status: "aborted", error: "contract not found" };
206
+ }
207
+
208
+ const baseUrl = env.SILT_DEPLOY_URL || DEFAULT_INTAKE_URL;
209
+
210
+ // 1. Credential — fail fast and friendly before doing any work.
211
+ // EXPLICIT BEATS AMBIENT: an explicitly-set SILT_DEPLOY_TOKEN wins over the
212
+ // stored login session (a tester deliberately setting the env var means it).
213
+ // The session is used only when no env token is present. The legacy tester
214
+ // `t-` env path keeps working unchanged; a stored session still only counts
215
+ // for the backend that minted it (pointing SILT_DEPLOY_URL elsewhere must
216
+ // not leak the token there).
217
+ const creds = loadCredentials(env);
218
+ const sessionUsable = !!creds && normalizeBase(creds.intakeUrl) === normalizeBase(baseUrl);
219
+ const envToken = env.SILT_DEPLOY_TOKEN;
220
+ const usingSession = !envToken && sessionUsable;
221
+ const token = envToken || (sessionUsable ? creds!.token : undefined);
222
+ if (!token) {
223
+ logger.error("no deploy credential — run `siltrun login` (or set SILT_DEPLOY_TOKEN to a beta token)");
224
+ logger.plain(paint.dim(` Beta info: ${ONBOARDING_DOC}`));
225
+ return { ok: false, status: "aborted", error: "no token" };
226
+ }
227
+ if (usingSession) {
228
+ logger.info(`deploying as ${paint.bold(creds!.login)}`);
229
+ } else if (envToken && sessionUsable) {
230
+ // Both present — the explicit env token wins; say so once so the operator
231
+ // isn't surprised their logged-in account was bypassed for this deploy.
232
+ logger.info(
233
+ "using SILT_DEPLOY_TOKEN (tester token) — logged-in account ignored for this deploy",
234
+ );
235
+ } else if (envToken && creds) {
236
+ // Logged in, but against a different backend than this deploy targets.
237
+ logger.plain(
238
+ paint.dim(
239
+ ` (logged-in session is for ${creds.intakeUrl}; using SILT_DEPLOY_TOKEN for ${baseUrl})`,
240
+ ),
241
+ );
242
+ }
243
+
244
+ // 2. Bundle (reuse the dev-loop bundler).
245
+ const workDir = await mkdtemp(join(tmpdir(), "silt-deploy-"));
246
+ const bundlePath = join(workDir, "contract.bundle.js");
247
+ try {
248
+ await deps.bundle(contract, bundlePath);
249
+ } catch (e) {
250
+ reportBundleError(e, logger);
251
+ return { ok: false, status: "aborted", error: "bundle failed" };
252
+ }
253
+ const bundleText = await Bun.file(bundlePath).text();
254
+
255
+ // 3. Local determinism doctor as a client-side pre-gate. Only real DRIFT aborts — a doctor
256
+ // that can't run (or isn't present) is a warning, since the box re-gates server-side.
257
+ const doctor = await deps.doctor(bundlePath);
258
+ if (doctor.status === "drift") {
259
+ logger.error("determinism drift — deploy aborted before upload");
260
+ if (doctor.output) logger.plain(paint.dim(" " + doctor.output.split("\n").join("\n ")));
261
+ logger.plain(paint.dim(" Fix the drift and re-run — a drifting room can't go live."));
262
+ return { ok: false, status: "aborted", error: "determinism drift" };
263
+ }
264
+ if (doctor.status === "ok") {
265
+ logger.info(paint.green("determinism ok") + paint.dim(` — ${doctor.note}`));
266
+ } else {
267
+ // skipped or errored: proceed, box re-gates. Calm NOTE (not a scary warn) — a first-timer
268
+ // on a bare install shouldn't read this as a failure; the server-side GREEN arrives below.
269
+ logger.plain(paint.dim(" determinism is verified server-side — verdict below ↓"));
270
+ }
271
+
272
+ // 4. POST the RAW bundle.
273
+ logger.info(`uploading room ${paint.bold(opts.room)} → ${paint.dim(baseUrl)}`);
274
+ let rec: DeployRecord;
275
+ try {
276
+ rec = await postDeploy(baseUrl, token, opts.room, bundleText, deps.fetchImpl);
277
+ } catch (e) {
278
+ if (e instanceof DeployHttpError) {
279
+ logger.error(e.message);
280
+ if (e.status === 401 && usingSession) {
281
+ logger.plain(paint.dim(" your login session may have expired — run `siltrun login` again"));
282
+ }
283
+ } else {
284
+ logger.error(`upload failed: ${e instanceof Error ? e.message : String(e)}`);
285
+ }
286
+ return { ok: false, status: "aborted", error: "upload failed" };
287
+ }
288
+
289
+ logger.info(`queued ${paint.dim(`(deploy ${rec.deployId})`)}`);
290
+
291
+ // 5. Poll with a live status line.
292
+ const deadline = Date.now() + deps.pollBudgetMs;
293
+ let lastStatus: DeployStatus | null = rec.status;
294
+ logger.info(`status: ${paint.cyan(STATUS_LABEL[rec.status] ?? rec.status)}`);
295
+ if (rec.status === "live") return succeed(rec, baseUrl, logger);
296
+ if (rec.status === "failed") return fail(rec, logger);
297
+
298
+ while (Date.now() < deadline) {
299
+ await deps.sleep(deps.pollIntervalMs);
300
+ let cur: DeployRecord;
301
+ try {
302
+ cur = await getDeployStatus(baseUrl, token, rec.deployId, deps.fetchImpl);
303
+ } catch (e) {
304
+ // A transient poll error isn't fatal within the budget — note it and keep polling.
305
+ logger.warn(
306
+ `status poll hiccup: ${e instanceof Error ? e.message : String(e)} — retrying`,
307
+ );
308
+ continue;
309
+ }
310
+
311
+ if (cur.status !== lastStatus) {
312
+ logger.info(`status: ${paint.cyan(STATUS_LABEL[cur.status] ?? cur.status)}`);
313
+ lastStatus = cur.status;
314
+ }
315
+ if (cur.status === "live") return succeed(cur, baseUrl, logger);
316
+ if (cur.status === "failed") return fail(cur, logger);
317
+ }
318
+
319
+ // Honest timeout: we stopped WATCHING, we don't know the outcome. Note: re-running
320
+ // `siltrun deploy` queues a NEW deploy — it does not resume watching this one — so don't
321
+ // suggest it as a way to "check".
322
+ logger.error(
323
+ `stopped watching after ${Math.round(deps.pollBudgetMs / 1000)}s — deploy ${rec.deployId} ` +
324
+ `was still "${lastStatus}". It may yet come up on its own in the next few minutes.`,
325
+ );
326
+ logger.plain(
327
+ paint.dim(
328
+ ` If it doesn't, report it in ${SUPPORT_CHANNEL} with the reference ` +
329
+ `"deploy ${rec.deployId}" and we'll dig in.`,
330
+ ),
331
+ );
332
+ return { ok: false, status: "timeout", deployId: rec.deployId, error: "poll timeout" };
333
+ }
334
+
335
+ function succeed(rec: DeployRecord, baseUrl: string, logger: DeployLogger): DeployResult {
336
+ logger.plain();
337
+ logger.info(paint.green(paint.bold("live")) + ` — room ${paint.bold(rec.room)} is up`);
338
+ if (rec.url) {
339
+ // (a) the room URL — the WebTransport/QUIC endpoint the @siltrun/client dials. It is NOT
340
+ // browser-navigable on its own (no TCP/HTTP surface), so we label it plainly.
341
+ logger.plain();
342
+ logger.plain(` room: ${paint.bold(paint.cyan(rec.url))}`);
343
+
344
+ // (b) the check/play URL with the room pre-filled — the tester OPENS THIS IN A BROWSER to
345
+ // verify the room is live and watch its state in real time. Never hand-construct a URL.
346
+ const checkUrl = `${normalizeBase(baseUrl)}/check?room=${encodeURIComponent(rec.url)}`;
347
+ logger.plain();
348
+ logger.plain(` ${paint.dim("open in a browser to verify + watch your room live:")}`);
349
+ logger.plain(` ${paint.bold(paint.cyan(checkUrl))}`);
350
+ logger.plain();
351
+ }
352
+
353
+ // (c) the server-side determinism verdict, if the box reported one (CONTROL-PLANE v0.2 §B).
354
+ if (rec.doctorVerdict) {
355
+ logger.info(
356
+ paint.green("✓ determinism verified server-side") + paint.dim(` — ${rec.doctorVerdict}`),
357
+ );
358
+ }
359
+
360
+ return { ok: true, status: "live", deployId: rec.deployId, url: rec.url };
361
+ }
362
+
363
+ function fail(rec: DeployRecord, logger: DeployLogger): DeployResult {
364
+ // The server's reason is shown as-is: some reasons ARE the tester's to act on (name
365
+ // too long, empty bundle, beta at capacity). But many are operator diagnostics
366
+ // (journalctl hints, rootfs paths) a tester can't touch — so always close with the
367
+ // honest route: a reference id + the support channel. Vercel bar: every failure is
368
+ // either user-actionable or "it's ours — here's your reference".
369
+ logger.error(`deploy failed${rec.error ? `: ${rec.error}` : ""}`);
370
+ logger.plain(
371
+ paint.dim(
372
+ ` If the reason above isn't something you can act on, it's ours to fix — ` +
373
+ `report it in ${SUPPORT_CHANNEL} with the reference "deploy ${rec.deployId}".`,
374
+ ),
375
+ );
376
+ return { ok: false, status: "failed", deployId: rec.deployId, error: rec.error };
377
+ }
378
+
379
+ function reportBundleError(e: unknown, logger: DeployLogger) {
380
+ if (e instanceof BundleError) {
381
+ logger.error(e.message);
382
+ for (const l of e.logs) logger.plain(paint.dim(" " + l));
383
+ } else {
384
+ logger.error(`bundle failed: ${e instanceof Error ? e.message : String(e)}`);
385
+ }
386
+ }
387
+
388
+ /**
389
+ * CLI entry. Parses argv (everything after `deploy`), runs the deploy, and exits non-zero on
390
+ * any non-live outcome so scripts/CI can gate on it.
391
+ */
392
+ export async function deploy(argv: string[]): Promise<void> {
393
+ const opts = parseDeployArgs(argv);
394
+ const result = await runDeploy(opts);
395
+ if (!result.ok) process.exit(1);
396
+ }
@@ -0,0 +1,59 @@
1
+ // Regression test for the `siltrun dev` watcher TOCTOU (review finding #4).
2
+ //
3
+ // The watcher used existsSync(contract) then statSync(contract). An atomic-rename
4
+ // save (vim, sed -i, most editors) unlinks the old inode before the new one lands;
5
+ // a stat racing that window throws ENOENT AFTER existsSync returned true. That
6
+ // uncaught throw inside the fs.watch callback crashed `siltrun dev`. The fix does a
7
+ // single guarded stat (no exists pre-check) and treats ENOENT as "not re-appeared
8
+ // yet". statMtimeOrNull is that stat; here we prove it swallows the race.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import { statMtimeOrNull } from "./dev.ts";
16
+
17
+ describe("statMtimeOrNull (dev watcher TOCTOU)", () => {
18
+ test("returns the mtimeMs for a real, present file", () => {
19
+ const dir = mkdtempSync(join(tmpdir(), "silt-dev-watch-"));
20
+ try {
21
+ const f = join(dir, "room.ts");
22
+ writeFileSync(f, "export default {}");
23
+ expect(statMtimeOrNull(f)).toBe(statSync(f).mtimeMs);
24
+ } finally {
25
+ rmSync(dir, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ test("ENOENT during the stat (mid atomic-rename) → null, never throws", () => {
30
+ // The exact race: the event fired, but by the time we stat the file is gone
31
+ // for a beat. Injecting a statFn that throws ENOENT reproduces it determinist-
32
+ // ically (the real exists→stat gap is too small to time). Before the fix this
33
+ // ENOENT propagated out of the watch callback and killed the process.
34
+ const enoent = Object.assign(new Error("ENOENT: no such file"), { code: "ENOENT" });
35
+ const throwing = (() => {
36
+ throw enoent;
37
+ }) as unknown as typeof statSync;
38
+ expect(() => statMtimeOrNull("/whatever/room.ts", throwing)).not.toThrow();
39
+ expect(statMtimeOrNull("/whatever/room.ts", throwing)).toBeNull();
40
+ });
41
+
42
+ test("a genuinely-absent path (unlink+rename in flight) → null", () => {
43
+ const dir = mkdtempSync(join(tmpdir(), "silt-dev-watch-"));
44
+ try {
45
+ // No file created — stands in for the instant between unlink and rename.
46
+ expect(statMtimeOrNull(join(dir, "room.ts"))).toBeNull();
47
+ } finally {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ test("a non-ENOENT stat error is genuine and rethrown", () => {
53
+ const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
54
+ const throwing = (() => {
55
+ throw eacces;
56
+ }) as unknown as typeof statSync;
57
+ expect(() => statMtimeOrNull("/whatever/room.ts", throwing)).toThrow(/EACCES/);
58
+ });
59
+ });