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/README.md +133 -0
- package/bin/silt.mjs +128 -0
- package/package.json +32 -0
- package/src/args.test.ts +93 -0
- package/src/args.ts +117 -0
- package/src/banner.ts +55 -0
- package/src/bundle.test.ts +80 -0
- package/src/bundle.ts +53 -0
- package/src/cli.ts +95 -0
- package/src/credentials.ts +73 -0
- package/src/deploy-client.ts +143 -0
- package/src/deploy.test.ts +330 -0
- package/src/deploy.ts +396 -0
- package/src/dev.test.ts +59 -0
- package/src/dev.ts +264 -0
- package/src/doctor.test.ts +31 -0
- package/src/doctor.ts +74 -0
- package/src/log.ts +39 -0
- package/src/login.test.ts +307 -0
- package/src/login.ts +263 -0
- package/src/paths.ts +92 -0
- package/src/room-info.test.ts +80 -0
- package/src/room-info.ts +70 -0
- package/src/server-build.ts +78 -0
- package/src/silt-shim.test.ts +54 -0
- package/src/supervisor.test.ts +23 -0
- package/src/supervisor.ts +218 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseDeployArgs, runDeploy, DEFAULT_INTAKE_URL, type DeployLogger } from "./deploy.ts";
|
|
4
|
+
import { bundleContract } from "./bundle.ts";
|
|
5
|
+
import { ArgError } from "./args.ts";
|
|
6
|
+
import type { DeployStatus } from "./deploy-client.ts";
|
|
7
|
+
|
|
8
|
+
// A real example contract — bundled with the REAL bundler so raw-body assertions are against
|
|
9
|
+
// genuine bundled JS, not a synthetic string.
|
|
10
|
+
const HELLO_ROOM = join(import.meta.dir, "..", "..", "..", "examples", "hello-room", "room.ts");
|
|
11
|
+
|
|
12
|
+
// ── capturing logger ────────────────────────────────────────────────────────
|
|
13
|
+
function capLogger(): { logger: DeployLogger; lines: string[]; text: () => string } {
|
|
14
|
+
const lines: string[] = [];
|
|
15
|
+
const push = (m?: string) => {
|
|
16
|
+
lines.push(m ?? "");
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
logger: { info: push, warn: push, error: push, plain: push },
|
|
20
|
+
lines,
|
|
21
|
+
text: () => lines.join("\n"),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── a mock intake worker that plays the v0 contract ─────────────────────────
|
|
26
|
+
interface MockOpts {
|
|
27
|
+
failWith?: string; // if set, GET reports failed with this error
|
|
28
|
+
stayProvisioning?: boolean; // if set, GET never reaches live (for the timeout path)
|
|
29
|
+
}
|
|
30
|
+
function makeMockIntake(opts: MockOpts = {}) {
|
|
31
|
+
const received = {
|
|
32
|
+
posts: 0,
|
|
33
|
+
gets: 0,
|
|
34
|
+
room: undefined as string | undefined,
|
|
35
|
+
body: undefined as string | undefined,
|
|
36
|
+
contentType: undefined as string | undefined,
|
|
37
|
+
auth: undefined as string | undefined,
|
|
38
|
+
};
|
|
39
|
+
let getCount = 0;
|
|
40
|
+
const server = Bun.serve({
|
|
41
|
+
port: 0,
|
|
42
|
+
async fetch(req) {
|
|
43
|
+
const url = new URL(req.url);
|
|
44
|
+
if (req.method === "POST" && url.pathname === "/v0/deploy") {
|
|
45
|
+
received.posts++;
|
|
46
|
+
received.room = url.searchParams.get("room") ?? undefined;
|
|
47
|
+
received.body = await req.text();
|
|
48
|
+
received.contentType = req.headers.get("content-type") ?? undefined;
|
|
49
|
+
received.auth = req.headers.get("authorization") ?? undefined;
|
|
50
|
+
return Response.json(
|
|
51
|
+
{ deployId: "dep_test_1", room: received.room, status: "queued" as DeployStatus },
|
|
52
|
+
{ status: 201 },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (req.method === "GET" && url.pathname.startsWith("/v0/deploy/")) {
|
|
56
|
+
received.gets++;
|
|
57
|
+
getCount++;
|
|
58
|
+
if (opts.failWith) {
|
|
59
|
+
return Response.json({
|
|
60
|
+
deployId: "dep_test_1",
|
|
61
|
+
room: received.room,
|
|
62
|
+
status: "failed" as DeployStatus,
|
|
63
|
+
error: opts.failWith,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (!opts.stayProvisioning && getCount >= 2) {
|
|
67
|
+
return Response.json({
|
|
68
|
+
deployId: "dep_test_1",
|
|
69
|
+
room: received.room,
|
|
70
|
+
status: "live" as DeployStatus,
|
|
71
|
+
url: `https://relay.silt.digitalpine.io:4440/room/${received.room}`,
|
|
72
|
+
doctorVerdict: "SILT-DOCTOR: GREEN (300-tick replay deterministic)",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return Response.json({
|
|
76
|
+
deployId: "dep_test_1",
|
|
77
|
+
room: received.room,
|
|
78
|
+
status: "provisioning" as DeployStatus,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return new Response("not found", { status: 404 });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
return { server, url: `http://localhost:${server.port}`, received, stop: () => server.stop(true) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const noSleep = () => Promise.resolve();
|
|
88
|
+
|
|
89
|
+
// ── arg parsing ─────────────────────────────────────────────────────────────
|
|
90
|
+
describe("parseDeployArgs", () => {
|
|
91
|
+
test("requires a contract positional", () => {
|
|
92
|
+
expect(() => parseDeployArgs([], {})).toThrow(ArgError);
|
|
93
|
+
});
|
|
94
|
+
test("derives the room from the contract path (same rule as dev)", () => {
|
|
95
|
+
expect(parseDeployArgs(["examples/hello-room/room.ts"], {}).room).toBe("hello-room");
|
|
96
|
+
});
|
|
97
|
+
test("--room flag wins and is validated", () => {
|
|
98
|
+
expect(parseDeployArgs(["room.ts", "--room", "my-arena"], {}).room).toBe("my-arena");
|
|
99
|
+
expect(() => parseDeployArgs(["room.ts", "--room", "bad name"], {})).toThrow(ArgError);
|
|
100
|
+
});
|
|
101
|
+
test("deployed room names are capped at 12 chars (tap<room> / IFNAMSIZ, DIG-676)", () => {
|
|
102
|
+
// 12 exactly is fine; 13 is rejected with the honest reason, client-side.
|
|
103
|
+
expect(parseDeployArgs(["room.ts", "--room", "abcdefghijkl"], {}).room).toBe("abcdefghijkl");
|
|
104
|
+
expect(() => parseDeployArgs(["room.ts", "--room", "abcdefghijklm"], {})).toThrow(
|
|
105
|
+
/12|interface/,
|
|
106
|
+
);
|
|
107
|
+
// A DERIVED name over the cap is caught too, with the --room way out.
|
|
108
|
+
expect(() => parseDeployArgs(["examples/my-very-long-game-name/room.ts"], {})).toThrow(
|
|
109
|
+
/--room/,
|
|
110
|
+
);
|
|
111
|
+
// Local dev is deliberately uncapped — this rule is deploy-only.
|
|
112
|
+
});
|
|
113
|
+
test("SILT_ROOM env applies when no flag", () => {
|
|
114
|
+
expect(parseDeployArgs(["room.ts"], { SILT_ROOM: "env-room" }).room).toBe("env-room");
|
|
115
|
+
});
|
|
116
|
+
test("baked default intake URL is a concrete https worker URL (filled at integration)", () => {
|
|
117
|
+
expect(DEFAULT_INTAKE_URL).toMatch(/^https:\/\//);
|
|
118
|
+
expect(DEFAULT_INTAKE_URL).not.toContain("PLACEHOLDER");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── the gate + upload + poll happy path ─────────────────────────────────────
|
|
123
|
+
describe("runDeploy — happy path", () => {
|
|
124
|
+
test("bundle+doctor gate runs, POSTs RAW bundle, polls, prints the live URL", async () => {
|
|
125
|
+
const mock = makeMockIntake();
|
|
126
|
+
const cap = capLogger();
|
|
127
|
+
|
|
128
|
+
// Real bundler (proves reuse), captured so we can byte-compare the uploaded body.
|
|
129
|
+
let capturedBundlePath = "";
|
|
130
|
+
let bundleCalls = 0;
|
|
131
|
+
const bundle = async (contract: string, out: string) => {
|
|
132
|
+
bundleCalls++;
|
|
133
|
+
const p = await bundleContract(contract, out);
|
|
134
|
+
capturedBundlePath = out;
|
|
135
|
+
return p;
|
|
136
|
+
};
|
|
137
|
+
// Doctor injected GREEN + spied so we can assert the gate ran (and ran before any HTTP).
|
|
138
|
+
let doctorCalledAt = -1;
|
|
139
|
+
const doctor = async () => {
|
|
140
|
+
doctorCalledAt = mock.received.posts; // must be 0 — no POST yet when the doctor runs
|
|
141
|
+
return { status: "ok" as const, note: "300-tick replay matched" };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const result = await runDeploy(
|
|
145
|
+
{ contract: HELLO_ROOM, room: "hello-room" },
|
|
146
|
+
{
|
|
147
|
+
env: { SILT_DEPLOY_TOKEN: "tester-token", SILT_DEPLOY_URL: mock.url },
|
|
148
|
+
fetchImpl: fetch,
|
|
149
|
+
sleep: noSleep,
|
|
150
|
+
bundle,
|
|
151
|
+
doctor,
|
|
152
|
+
logger: cap.logger,
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// gate ran, and ran BEFORE any upload
|
|
157
|
+
expect(bundleCalls).toBe(1);
|
|
158
|
+
expect(doctorCalledAt).toBe(0);
|
|
159
|
+
|
|
160
|
+
// POSTed exactly once, to the right room, with Bearer + text/javascript
|
|
161
|
+
expect(mock.received.posts).toBe(1);
|
|
162
|
+
expect(mock.received.room).toBe("hello-room");
|
|
163
|
+
expect(mock.received.auth).toBe("Bearer tester-token");
|
|
164
|
+
expect(mock.received.contentType).toBe("text/javascript");
|
|
165
|
+
|
|
166
|
+
// RAW body: byte-identical to the bundle file, NOT base64
|
|
167
|
+
const bundleText = await Bun.file(capturedBundlePath).text();
|
|
168
|
+
expect(mock.received.body).toBe(bundleText);
|
|
169
|
+
expect(mock.received.body).not.toBe(btoa(bundleText));
|
|
170
|
+
expect(mock.received.body).toContain("clamp"); // a real source identifier survives bundling
|
|
171
|
+
|
|
172
|
+
// polled to live and surfaced the URL
|
|
173
|
+
expect(mock.received.gets).toBeGreaterThanOrEqual(2);
|
|
174
|
+
expect(result.ok).toBe(true);
|
|
175
|
+
expect(result.status).toBe("live");
|
|
176
|
+
expect(result.url).toBe("https://relay.silt.digitalpine.io:4440/room/hello-room");
|
|
177
|
+
expect(cap.text()).toContain("https://relay.silt.digitalpine.io:4440/room/hello-room");
|
|
178
|
+
|
|
179
|
+
// the tester never constructs a URL: the check/play URL is printed with the room
|
|
180
|
+
// pre-filled and CORRECTLY URL-ENCODED (CONTROL-PLANE v0.2 §C).
|
|
181
|
+
const roomUrl = "https://relay.silt.digitalpine.io:4440/room/hello-room";
|
|
182
|
+
const expectedCheckUrl = `${mock.url}/check?room=${encodeURIComponent(roomUrl)}`;
|
|
183
|
+
expect(cap.text()).toContain(expectedCheckUrl);
|
|
184
|
+
// the room segment must be percent-encoded (":" -> "%3A", "/" -> "%2F"), never raw in the query
|
|
185
|
+
expect(cap.text()).toContain("/check?room=https%3A%2F%2Frelay.silt.digitalpine.io%3A4440%2Froom%2Fhello-room");
|
|
186
|
+
|
|
187
|
+
// the server-side determinism verdict is surfaced to the tester (CONTROL-PLANE v0.2 §B)
|
|
188
|
+
expect(cap.text()).toContain("SILT-DOCTOR: GREEN (300-tick replay deterministic)");
|
|
189
|
+
expect(cap.text()).toContain("determinism verified server-side");
|
|
190
|
+
|
|
191
|
+
mock.stop();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── drift aborts before ANY http ────────────────────────────────────────────
|
|
196
|
+
describe("runDeploy — determinism drift", () => {
|
|
197
|
+
test("a drifting contract aborts BEFORE any HTTP call", async () => {
|
|
198
|
+
const cap = capLogger();
|
|
199
|
+
let fetchCalls = 0;
|
|
200
|
+
const spyFetch = ((..._args: Parameters<typeof fetch>) => {
|
|
201
|
+
fetchCalls++;
|
|
202
|
+
return Promise.reject(new Error("network must not be touched on drift"));
|
|
203
|
+
}) as typeof fetch;
|
|
204
|
+
|
|
205
|
+
let bundleCalled = false;
|
|
206
|
+
const bundle = async (_c: string, out: string) => {
|
|
207
|
+
bundleCalled = true;
|
|
208
|
+
await Bun.write(out, "export default { tick(s){return s} }");
|
|
209
|
+
return out;
|
|
210
|
+
};
|
|
211
|
+
const doctor = async () => ({
|
|
212
|
+
status: "drift" as const,
|
|
213
|
+
note: "determinism drift detected",
|
|
214
|
+
output: "✗ FIRST DRIFT at tick 42 — field ships.x",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const result = await runDeploy(
|
|
218
|
+
{ contract: HELLO_ROOM, room: "hello-room" },
|
|
219
|
+
{
|
|
220
|
+
env: { SILT_DEPLOY_TOKEN: "tester-token", SILT_DEPLOY_URL: "http://127.0.0.1:1/unused" },
|
|
221
|
+
fetchImpl: spyFetch,
|
|
222
|
+
sleep: noSleep,
|
|
223
|
+
bundle,
|
|
224
|
+
doctor,
|
|
225
|
+
logger: cap.logger,
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(bundleCalled).toBe(true); // gate ran
|
|
230
|
+
expect(fetchCalls).toBe(0); // …then aborted before any upload
|
|
231
|
+
expect(result.ok).toBe(false);
|
|
232
|
+
expect(result.status).toBe("aborted");
|
|
233
|
+
expect(cap.text()).toContain("drift");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ── missing token → friendly error, no http ─────────────────────────────────
|
|
238
|
+
describe("runDeploy — missing token", () => {
|
|
239
|
+
test("no SILT_DEPLOY_TOKEN → friendly error, no HTTP, no bundle", async () => {
|
|
240
|
+
const cap = capLogger();
|
|
241
|
+
let fetchCalls = 0;
|
|
242
|
+
const spyFetch = (() => {
|
|
243
|
+
fetchCalls++;
|
|
244
|
+
return Promise.reject(new Error("should not fetch without a token"));
|
|
245
|
+
}) as unknown as typeof fetch;
|
|
246
|
+
let bundleCalled = false;
|
|
247
|
+
const bundle = async (_c: string, out: string) => {
|
|
248
|
+
bundleCalled = true;
|
|
249
|
+
return out;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const result = await runDeploy(
|
|
253
|
+
{ contract: HELLO_ROOM, room: "hello-room" },
|
|
254
|
+
{
|
|
255
|
+
env: {}, // no token
|
|
256
|
+
fetchImpl: spyFetch,
|
|
257
|
+
sleep: noSleep,
|
|
258
|
+
bundle,
|
|
259
|
+
doctor: async () => ({ status: "ok" as const, note: "n/a" }),
|
|
260
|
+
logger: cap.logger,
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
expect(result.ok).toBe(false);
|
|
265
|
+
expect(result.status).toBe("aborted");
|
|
266
|
+
expect(fetchCalls).toBe(0);
|
|
267
|
+
expect(bundleCalled).toBe(false);
|
|
268
|
+
expect(cap.text()).toContain("SILT_DEPLOY_TOKEN");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ── server-reported failure surfaces honestly ───────────────────────────────
|
|
273
|
+
describe("runDeploy — server failure", () => {
|
|
274
|
+
test("server 'failed' status surfaces the server error, no false success", async () => {
|
|
275
|
+
const mock = makeMockIntake({ failWith: "beta at capacity — no free ports in 4440-4449" });
|
|
276
|
+
const cap = capLogger();
|
|
277
|
+
|
|
278
|
+
const result = await runDeploy(
|
|
279
|
+
{ contract: HELLO_ROOM, room: "hello-room" },
|
|
280
|
+
{
|
|
281
|
+
env: { SILT_DEPLOY_TOKEN: "tester-token", SILT_DEPLOY_URL: mock.url },
|
|
282
|
+
fetchImpl: fetch,
|
|
283
|
+
sleep: noSleep,
|
|
284
|
+
bundle: bundleContract,
|
|
285
|
+
doctor: async () => ({ status: "ok" as const, note: "ok" }),
|
|
286
|
+
logger: cap.logger,
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
expect(result.ok).toBe(false);
|
|
291
|
+
expect(result.status).toBe("failed");
|
|
292
|
+
expect(result.error).toContain("beta at capacity");
|
|
293
|
+
expect(cap.text()).toContain("beta at capacity");
|
|
294
|
+
// Vercel bar: operator-only reasons must still leave the tester a route —
|
|
295
|
+
// reference id + support channel, on every server failure.
|
|
296
|
+
expect(cap.text()).toContain("dep_test_1");
|
|
297
|
+
expect(cap.text()).toContain("#support");
|
|
298
|
+
mock.stop();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ── honest timeout ──────────────────────────────────────────────────────────
|
|
303
|
+
describe("runDeploy — timeout", () => {
|
|
304
|
+
test("never-live deploy times out honestly (no false success)", async () => {
|
|
305
|
+
const mock = makeMockIntake({ stayProvisioning: true });
|
|
306
|
+
const cap = capLogger();
|
|
307
|
+
|
|
308
|
+
const result = await runDeploy(
|
|
309
|
+
{ contract: HELLO_ROOM, room: "hello-room" },
|
|
310
|
+
{
|
|
311
|
+
env: { SILT_DEPLOY_TOKEN: "tester-token", SILT_DEPLOY_URL: mock.url },
|
|
312
|
+
fetchImpl: fetch,
|
|
313
|
+
sleep: noSleep,
|
|
314
|
+
bundle: bundleContract,
|
|
315
|
+
doctor: async () => ({ status: "ok" as const, note: "ok" }),
|
|
316
|
+
logger: cap.logger,
|
|
317
|
+
pollIntervalMs: 1,
|
|
318
|
+
pollBudgetMs: 8, // tiny budget + noop sleep → a couple polls then honest timeout
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(result.ok).toBe(false);
|
|
323
|
+
expect(result.status).toBe("timeout");
|
|
324
|
+
expect(cap.text().toLowerCase()).toContain("stopped watching");
|
|
325
|
+
// Vercel bar: the tester leaves with a reference + a place to report.
|
|
326
|
+
expect(cap.text()).toContain("dep_test_1");
|
|
327
|
+
expect(cap.text()).toContain("#support");
|
|
328
|
+
mock.stop();
|
|
329
|
+
});
|
|
330
|
+
});
|