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,307 @@
|
|
|
1
|
+
// Tests for the CLI identity layer: the credentials store, the `siltrun login`
|
|
2
|
+
// loopback arc (against a mock intake + a fake "browser"), and `siltrun deploy`'s
|
|
3
|
+
// credential precedence (session first, legacy SILT_DEPLOY_TOKEN fallback,
|
|
4
|
+
// per-backend session scoping).
|
|
5
|
+
//
|
|
6
|
+
// No real browser, no real worker: the injected openBrowser plays the browser's
|
|
7
|
+
// role by following the redirect contract directly — exactly the data path the
|
|
8
|
+
// worker's /login/callback 302 produces.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test, afterEach } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
clearCredentials,
|
|
17
|
+
credentialsPath,
|
|
18
|
+
loadCredentials,
|
|
19
|
+
saveCredentials,
|
|
20
|
+
} from "./credentials.ts";
|
|
21
|
+
import { runLogin, runWhoami } from "./login.ts";
|
|
22
|
+
import { runDeploy, type DeployLogger } from "./deploy.ts";
|
|
23
|
+
|
|
24
|
+
const tempDirs: string[] = [];
|
|
25
|
+
function tempHome(): string {
|
|
26
|
+
const d = mkdtempSync(join(tmpdir(), "silt-login-test-"));
|
|
27
|
+
tempDirs.push(d);
|
|
28
|
+
return d;
|
|
29
|
+
}
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
for (const d of tempDirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function quietLogger(): DeployLogger & { lines: string[] } {
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
return {
|
|
37
|
+
lines,
|
|
38
|
+
info: (m: string) => lines.push(`info ${m}`),
|
|
39
|
+
warn: (m: string) => lines.push(`warn ${m}`),
|
|
40
|
+
error: (m: string) => lines.push(`error ${m}`),
|
|
41
|
+
plain: (m = "") => lines.push(m),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── credentials store ────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("credentials store", () => {
|
|
48
|
+
test("round-trips and is chmod 600", () => {
|
|
49
|
+
const env = { SILT_HOME: tempHome() };
|
|
50
|
+
expect(loadCredentials(env)).toBeNull();
|
|
51
|
+
|
|
52
|
+
const creds = {
|
|
53
|
+
token: "s-abc123",
|
|
54
|
+
login: "alice",
|
|
55
|
+
accountId: "acc-1",
|
|
56
|
+
intakeUrl: "http://127.0.0.1:9999",
|
|
57
|
+
createdAt: new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
const p = saveCredentials(creds, env);
|
|
60
|
+
expect(p).toBe(credentialsPath(env));
|
|
61
|
+
expect(loadCredentials(env)).toEqual(creds);
|
|
62
|
+
expect(statSync(p).mode & 0o777).toBe(0o600);
|
|
63
|
+
|
|
64
|
+
expect(clearCredentials(env)).toBe(true);
|
|
65
|
+
expect(loadCredentials(env)).toBeNull();
|
|
66
|
+
expect(clearCredentials(env)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("malformed file reads as not-logged-in", () => {
|
|
70
|
+
const env = { SILT_HOME: tempHome() };
|
|
71
|
+
saveCredentials(
|
|
72
|
+
{ token: "s-x", login: "a", intakeUrl: "http://x", createdAt: "now" },
|
|
73
|
+
env,
|
|
74
|
+
);
|
|
75
|
+
// Corrupt it.
|
|
76
|
+
require("node:fs").writeFileSync(credentialsPath(env), "{nope");
|
|
77
|
+
expect(loadCredentials(env)).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ── the login arc ────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/** A mock intake: only /v0/me — the "browser" below plays the /login/* half. */
|
|
84
|
+
function mockIntake(me: { accountId: string; login: string; provider?: string }) {
|
|
85
|
+
return Bun.serve({
|
|
86
|
+
hostname: "127.0.0.1",
|
|
87
|
+
port: 0,
|
|
88
|
+
fetch(req) {
|
|
89
|
+
const url = new URL(req.url);
|
|
90
|
+
if (url.pathname === "/v0/me") {
|
|
91
|
+
const authz = req.headers.get("Authorization") || "";
|
|
92
|
+
if (!authz.startsWith("Bearer s-")) {
|
|
93
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
|
94
|
+
}
|
|
95
|
+
return new Response(JSON.stringify({ provider: "stub", ...me }), {
|
|
96
|
+
headers: { "Content-Type": "application/json" },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return new Response("nf", { status: 404 });
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A fake browser: parse callback+state off the /login/start URL and hit the
|
|
105
|
+
* loopback the way the worker's final 302 would. */
|
|
106
|
+
function fakeBrowser(opts: { token?: string; stateOverride?: string; login?: string } = {}) {
|
|
107
|
+
return async (startUrl: string): Promise<boolean> => {
|
|
108
|
+
const u = new URL(startUrl);
|
|
109
|
+
const callback = u.searchParams.get("callback")!;
|
|
110
|
+
const state = opts.stateOverride ?? u.searchParams.get("state")!;
|
|
111
|
+
const cb = new URL(callback);
|
|
112
|
+
cb.searchParams.set("token", opts.token ?? "s-sessiontoken");
|
|
113
|
+
cb.searchParams.set("login", opts.login ?? "alice");
|
|
114
|
+
cb.searchParams.set("state", state);
|
|
115
|
+
await fetch(cb.toString()).catch(() => {});
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("siltrun login", () => {
|
|
121
|
+
test("happy arc: token lands, /v0/me confirms, credentials persist", async () => {
|
|
122
|
+
const intake = mockIntake({ accountId: "acc-42", login: "alice" });
|
|
123
|
+
const env = { SILT_HOME: tempHome(), SILT_DEPLOY_URL: `http://127.0.0.1:${intake.port}` };
|
|
124
|
+
try {
|
|
125
|
+
const result = await runLogin({
|
|
126
|
+
env,
|
|
127
|
+
logger: quietLogger(),
|
|
128
|
+
openBrowser: fakeBrowser({ token: "s-fromworker" }),
|
|
129
|
+
timeoutMs: 5000,
|
|
130
|
+
});
|
|
131
|
+
expect(result.ok).toBe(true);
|
|
132
|
+
expect(result.login).toBe("alice");
|
|
133
|
+
|
|
134
|
+
const creds = loadCredentials(env)!;
|
|
135
|
+
expect(creds.token).toBe("s-fromworker");
|
|
136
|
+
expect(creds.accountId).toBe("acc-42");
|
|
137
|
+
expect(creds.intakeUrl).toBe(`http://127.0.0.1:${intake.port}`);
|
|
138
|
+
} finally {
|
|
139
|
+
intake.stop(true);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("state mismatch rejects the CLI wait immediately — no 5-min hang (finding #6)", async () => {
|
|
144
|
+
// Before the fix the loopback handler 400'd the browser but never rejected
|
|
145
|
+
// the CLI's tokenArrived, so runLogin blocked for the full timeout. A generous
|
|
146
|
+
// timeout here would blow the test budget if that regressed; the fix makes it
|
|
147
|
+
// return at once with a clear message.
|
|
148
|
+
const intake = mockIntake({ accountId: "acc-42", login: "alice" });
|
|
149
|
+
const env = { SILT_HOME: tempHome(), SILT_DEPLOY_URL: `http://127.0.0.1:${intake.port}` };
|
|
150
|
+
const logger = quietLogger();
|
|
151
|
+
try {
|
|
152
|
+
const started = Date.now();
|
|
153
|
+
const result = await runLogin({
|
|
154
|
+
env,
|
|
155
|
+
logger,
|
|
156
|
+
openBrowser: fakeBrowser({ stateOverride: "wrong-state-entirely" }),
|
|
157
|
+
timeoutMs: 60_000, // generous on purpose — a hang would take the whole budget
|
|
158
|
+
});
|
|
159
|
+
const elapsed = Date.now() - started;
|
|
160
|
+
expect(result.ok).toBe(false);
|
|
161
|
+
expect(result.error).toContain("state mismatch");
|
|
162
|
+
expect(elapsed).toBeLessThan(5_000); // rejected on the mismatch, not the timeout
|
|
163
|
+
expect(loadCredentials(env)).toBeNull();
|
|
164
|
+
expect(logger.lines.join("\n")).toContain("state mismatch");
|
|
165
|
+
} finally {
|
|
166
|
+
intake.stop(true);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("whoami reports the stored login via /v0/me", async () => {
|
|
171
|
+
const intake = mockIntake({ accountId: "acc-42", login: "alice" });
|
|
172
|
+
const env = { SILT_HOME: tempHome(), SILT_DEPLOY_URL: `http://127.0.0.1:${intake.port}` };
|
|
173
|
+
try {
|
|
174
|
+
saveCredentials(
|
|
175
|
+
{
|
|
176
|
+
token: "s-tok",
|
|
177
|
+
login: "alice",
|
|
178
|
+
intakeUrl: `http://127.0.0.1:${intake.port}`,
|
|
179
|
+
createdAt: "now",
|
|
180
|
+
},
|
|
181
|
+
env,
|
|
182
|
+
);
|
|
183
|
+
const logger = quietLogger();
|
|
184
|
+
expect(await runWhoami({ env, logger })).toBe(true);
|
|
185
|
+
expect(logger.lines.join("\n")).toContain("alice");
|
|
186
|
+
} finally {
|
|
187
|
+
intake.stop(true);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── deploy credential precedence ─────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
// A minimal fetch mock that records the Authorization header of the deploy POST
|
|
195
|
+
// and plays a live deploy so runDeploy completes.
|
|
196
|
+
function recordingFetch(seen: { auth: string | null }) {
|
|
197
|
+
return (async (input: any, init?: any) => {
|
|
198
|
+
const url = String(input);
|
|
199
|
+
if (url.includes("/v0/deploy?")) {
|
|
200
|
+
seen.auth = init?.headers?.Authorization ?? null;
|
|
201
|
+
return new Response(
|
|
202
|
+
JSON.stringify({ deployId: "d1", room: "r", status: "queued" }),
|
|
203
|
+
{ status: 201, headers: { "Content-Type": "application/json" } },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return new Response(
|
|
207
|
+
JSON.stringify({ deployId: "d1", room: "r", status: "live", url: "https://x/room/r" }),
|
|
208
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
209
|
+
);
|
|
210
|
+
}) as typeof fetch;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function deployDeps(env: Record<string, string | undefined>, seen: { auth: string | null }) {
|
|
214
|
+
return {
|
|
215
|
+
env,
|
|
216
|
+
fetchImpl: recordingFetch(seen),
|
|
217
|
+
sleep: async () => {},
|
|
218
|
+
bundle: async (_c: string, out: string) => {
|
|
219
|
+
await Bun.write(out, "export default {}");
|
|
220
|
+
return out;
|
|
221
|
+
},
|
|
222
|
+
doctor: async () => ({ status: "skipped", note: "test" }) as any,
|
|
223
|
+
logger: quietLogger(),
|
|
224
|
+
pollIntervalMs: 1,
|
|
225
|
+
pollBudgetMs: 200,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
describe("siltrun deploy credential precedence", () => {
|
|
230
|
+
const contract = join(import.meta.dir, "deploy.ts"); // any existing file works
|
|
231
|
+
|
|
232
|
+
test("explicit SILT_DEPLOY_TOKEN wins over a stored session (explicit beats ambient)", async () => {
|
|
233
|
+
// Review finding #2: an explicitly-set env token must outrank the login
|
|
234
|
+
// session, even for the session's own backend — and say so once.
|
|
235
|
+
const home = tempHome();
|
|
236
|
+
const base = "http://127.0.0.1:4242";
|
|
237
|
+
saveCredentials(
|
|
238
|
+
{ token: "s-session", login: "alice", intakeUrl: base, createdAt: "now" },
|
|
239
|
+
{ SILT_HOME: home },
|
|
240
|
+
);
|
|
241
|
+
const seen = { auth: null as string | null };
|
|
242
|
+
const deps = deployDeps({ SILT_HOME: home, SILT_DEPLOY_URL: base, SILT_DEPLOY_TOKEN: "t-legacy" }, seen);
|
|
243
|
+
const result = await runDeploy({ contract, room: "r" }, deps);
|
|
244
|
+
expect(result.ok).toBe(true);
|
|
245
|
+
expect(seen.auth).toBe("Bearer t-legacy");
|
|
246
|
+
expect((deps.logger as any).lines.join("\n")).toContain("SILT_DEPLOY_TOKEN");
|
|
247
|
+
expect((deps.logger as any).lines.join("\n")).toContain("logged-in account ignored");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("no env token → the stored session is used for its own backend", async () => {
|
|
251
|
+
// The other half of the precedence: with SILT_DEPLOY_TOKEN unset, the login
|
|
252
|
+
// session is what deploys (byte-compatible with the pre-fix session path).
|
|
253
|
+
const home = tempHome();
|
|
254
|
+
const base = "http://127.0.0.1:4242";
|
|
255
|
+
saveCredentials(
|
|
256
|
+
{ token: "s-session", login: "alice", intakeUrl: base, createdAt: "now" },
|
|
257
|
+
{ SILT_HOME: home },
|
|
258
|
+
);
|
|
259
|
+
const seen = { auth: null as string | null };
|
|
260
|
+
const result = await runDeploy(
|
|
261
|
+
{ contract, room: "r" },
|
|
262
|
+
deployDeps({ SILT_HOME: home, SILT_DEPLOY_URL: base }, seen),
|
|
263
|
+
);
|
|
264
|
+
expect(result.ok).toBe(true);
|
|
265
|
+
expect(seen.auth).toBe("Bearer s-session");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("session for a DIFFERENT backend is not sent — env token used instead", async () => {
|
|
269
|
+
const home = tempHome();
|
|
270
|
+
saveCredentials(
|
|
271
|
+
{ token: "s-session", login: "alice", intakeUrl: "http://other-backend:1", createdAt: "now" },
|
|
272
|
+
{ SILT_HOME: home },
|
|
273
|
+
);
|
|
274
|
+
const seen = { auth: null as string | null };
|
|
275
|
+
const result = await runDeploy(
|
|
276
|
+
{ contract, room: "r" },
|
|
277
|
+
deployDeps(
|
|
278
|
+
{ SILT_HOME: home, SILT_DEPLOY_URL: "http://127.0.0.1:4242", SILT_DEPLOY_TOKEN: "t-legacy" },
|
|
279
|
+
seen,
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
expect(result.ok).toBe(true);
|
|
283
|
+
expect(seen.auth).toBe("Bearer t-legacy");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("no session → legacy env token still works (the QA path)", async () => {
|
|
287
|
+
const seen = { auth: null as string | null };
|
|
288
|
+
const result = await runDeploy(
|
|
289
|
+
{ contract, room: "r" },
|
|
290
|
+
deployDeps(
|
|
291
|
+
{ SILT_HOME: tempHome(), SILT_DEPLOY_URL: "http://127.0.0.1:4242", SILT_DEPLOY_TOKEN: "t-legacy" },
|
|
292
|
+
seen,
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
expect(result.ok).toBe(true);
|
|
296
|
+
expect(seen.auth).toBe("Bearer t-legacy");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("no credential at all → honest abort mentioning siltrun login", async () => {
|
|
300
|
+
const seen = { auth: null as string | null };
|
|
301
|
+
const deps = deployDeps({ SILT_HOME: tempHome(), SILT_DEPLOY_URL: "http://127.0.0.1:4242" }, seen);
|
|
302
|
+
const result = await runDeploy({ contract, room: "r" }, deps);
|
|
303
|
+
expect(result.ok).toBe(false);
|
|
304
|
+
expect(result.error).toBe("no token");
|
|
305
|
+
expect((deps.logger as any).lines.join("\n")).toContain("siltrun login");
|
|
306
|
+
});
|
|
307
|
+
});
|
package/src/login.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// `siltrun login` / `siltrun logout` / `siltrun whoami` — account identity for the CLI.
|
|
2
|
+
//
|
|
3
|
+
// The login is a browser-handoff arc (the wrangler/vercel shape), against the
|
|
4
|
+
// intake worker's /login routes (deploy/CONTROL-PLANE.md v0.3):
|
|
5
|
+
//
|
|
6
|
+
// 1. start a loopback HTTP listener on an ephemeral 127.0.0.1 port
|
|
7
|
+
// 2. open the browser at <intake>/login/start?callback=<loopback>&state=<nonce>
|
|
8
|
+
// (and print the URL, for headless/remote shells)
|
|
9
|
+
// 3. the worker runs the provider dance (stub today, GitHub later — the
|
|
10
|
+
// provider is the WORKER's configuration seam; this CLI code is identical
|
|
11
|
+
// for both) and 302s the browser back to the loopback with an `s-` session
|
|
12
|
+
// token + the state nonce
|
|
13
|
+
// 4. verify the state matches (the CSRF binding), confirm the token against
|
|
14
|
+
// GET /v0/me, and persist it via credentials.ts
|
|
15
|
+
//
|
|
16
|
+
// From then on `siltrun deploy` is account-keyed: it sends the stored session
|
|
17
|
+
// token instead of a hand-issued tester token.
|
|
18
|
+
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
import { DEFAULT_INTAKE_URL } from "./deploy.ts";
|
|
22
|
+
import { normalizeBase, type FetchLike } from "./deploy-client.ts";
|
|
23
|
+
import {
|
|
24
|
+
clearCredentials,
|
|
25
|
+
credentialsPath,
|
|
26
|
+
loadCredentials,
|
|
27
|
+
saveCredentials,
|
|
28
|
+
} from "./credentials.ts";
|
|
29
|
+
import { log, paint } from "./log.ts";
|
|
30
|
+
import type { DeployLogger } from "./deploy.ts";
|
|
31
|
+
|
|
32
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000; // the human is in a browser — be generous
|
|
33
|
+
|
|
34
|
+
export interface LoginDeps {
|
|
35
|
+
env: Record<string, string | undefined>;
|
|
36
|
+
fetchImpl: FetchLike;
|
|
37
|
+
logger: DeployLogger;
|
|
38
|
+
/** Open `url` in the user's browser; resolve false if that failed (we printed
|
|
39
|
+
* the URL anyway, so failure is non-fatal). */
|
|
40
|
+
openBrowser: (url: string) => Promise<boolean>;
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function defaultDeps(): LoginDeps {
|
|
45
|
+
return {
|
|
46
|
+
env: process.env,
|
|
47
|
+
fetchImpl: fetch,
|
|
48
|
+
logger: log,
|
|
49
|
+
openBrowser: systemOpen,
|
|
50
|
+
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function systemOpen(url: string): Promise<boolean> {
|
|
55
|
+
const cmd =
|
|
56
|
+
process.platform === "darwin"
|
|
57
|
+
? ["open", url]
|
|
58
|
+
: process.platform === "win32"
|
|
59
|
+
? ["cmd", "/c", "start", "", url]
|
|
60
|
+
: ["xdg-open", url];
|
|
61
|
+
try {
|
|
62
|
+
const child = spawn(cmd[0]!, cmd.slice(1), { stdio: "ignore", detached: true });
|
|
63
|
+
child.unref();
|
|
64
|
+
return await new Promise((resolve) => {
|
|
65
|
+
child.on("error", () => resolve(false));
|
|
66
|
+
child.on("spawn", () => resolve(true));
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface LoginResult {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
login?: string;
|
|
76
|
+
error?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The page the loopback listener serves once the token lands — the "you can
|
|
80
|
+
* close this tab" moment. Deliberately free of the token. */
|
|
81
|
+
const DONE_PAGE = (login: string) => `<!doctype html><meta charset="utf-8"><title>silt — logged in</title>
|
|
82
|
+
<body style="font-family:system-ui;max-width:26rem;margin:4rem auto;line-height:1.5">
|
|
83
|
+
<h1 style="font-size:1.2rem">✓ Logged in as ${login.replace(/&/g, "&").replace(/</g, "<")}</h1>
|
|
84
|
+
<p>You can close this tab and return to your terminal.</p></body>`;
|
|
85
|
+
|
|
86
|
+
export async function runLogin(overrides: Partial<LoginDeps> = {}): Promise<LoginResult> {
|
|
87
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
88
|
+
const { logger, env } = deps;
|
|
89
|
+
|
|
90
|
+
const baseUrl = normalizeBase(env.SILT_DEPLOY_URL || DEFAULT_INTAKE_URL);
|
|
91
|
+
// State nonce: the CSRF binding between this process and the browser redirect.
|
|
92
|
+
const state = crypto.randomUUID().replace(/-/g, "");
|
|
93
|
+
|
|
94
|
+
// 1. Loopback listener on an ephemeral port.
|
|
95
|
+
let resolveToken: (v: { token: string; login: string }) => void;
|
|
96
|
+
let rejectToken: (e: Error) => void;
|
|
97
|
+
const tokenArrived = new Promise<{ token: string; login: string }>((res, rej) => {
|
|
98
|
+
resolveToken = res;
|
|
99
|
+
rejectToken = rej;
|
|
100
|
+
});
|
|
101
|
+
// The loopback handler can reject this on a state mismatch BEFORE the awaiter
|
|
102
|
+
// below is attached (the browser round-trip lands first). A detached no-op
|
|
103
|
+
// handler keeps that from surfacing as an unhandled rejection; the real
|
|
104
|
+
// `await tokenArrived` still observes the rejection and fails the login.
|
|
105
|
+
tokenArrived.catch(() => {});
|
|
106
|
+
|
|
107
|
+
const server = Bun.serve({
|
|
108
|
+
hostname: "127.0.0.1",
|
|
109
|
+
port: 0,
|
|
110
|
+
fetch(req) {
|
|
111
|
+
const url = new URL(req.url);
|
|
112
|
+
if (url.pathname !== "/cb") return new Response("not found", { status: 404 });
|
|
113
|
+
const token = url.searchParams.get("token") || "";
|
|
114
|
+
const login = url.searchParams.get("login") || "";
|
|
115
|
+
const gotState = url.searchParams.get("state") || "";
|
|
116
|
+
if (gotState !== state || !token) {
|
|
117
|
+
// Wrong/missing state: never accept the token — this is the CSRF gate.
|
|
118
|
+
// AND fail the CLI wait right now instead of hanging until the 5-min
|
|
119
|
+
// timeout: a mismatch means the real token is never coming.
|
|
120
|
+
rejectToken(new Error("state mismatch — re-run `siltrun login`"));
|
|
121
|
+
return new Response("login state mismatch — re-run `siltrun login`", { status: 400 });
|
|
122
|
+
}
|
|
123
|
+
resolveToken({ token, login });
|
|
124
|
+
return new Response(DONE_PAGE(login), {
|
|
125
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const callback = `http://127.0.0.1:${server.port}/cb`;
|
|
132
|
+
const startUrl = `${baseUrl}/login/start?callback=${encodeURIComponent(callback)}&state=${state}`;
|
|
133
|
+
|
|
134
|
+
logger.info(`logging in via ${paint.dim(baseUrl)}`);
|
|
135
|
+
const opened = await deps.openBrowser(startUrl);
|
|
136
|
+
logger.plain(
|
|
137
|
+
opened
|
|
138
|
+
? paint.dim(" browser opened — finish logging in there. If nothing appeared, open:")
|
|
139
|
+
: paint.dim(" could not open a browser — open this URL yourself:"),
|
|
140
|
+
);
|
|
141
|
+
logger.plain(` ${paint.cyan(startUrl)}`);
|
|
142
|
+
|
|
143
|
+
// 2. Wait for the redirect (or time out honestly).
|
|
144
|
+
const timer = setTimeout(
|
|
145
|
+
() => rejectToken(new Error(`no login completed within ${Math.round(deps.timeoutMs / 60000)} min`)),
|
|
146
|
+
deps.timeoutMs,
|
|
147
|
+
);
|
|
148
|
+
let token: string, login: string;
|
|
149
|
+
try {
|
|
150
|
+
({ token, login } = await tokenArrived);
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 3. Confirm the token against /v0/me (and pick up the accountId).
|
|
156
|
+
let accountId: string | undefined;
|
|
157
|
+
try {
|
|
158
|
+
const res = await deps.fetchImpl(`${baseUrl}/v0/me`, {
|
|
159
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
160
|
+
});
|
|
161
|
+
if (res.ok) {
|
|
162
|
+
const me = (await res.json()) as { accountId?: string; login?: string };
|
|
163
|
+
accountId = me.accountId;
|
|
164
|
+
if (me.login) login = me.login;
|
|
165
|
+
} else {
|
|
166
|
+
logger.warn(`could not confirm the session (/v0/me → ${res.status}) — storing it anyway`);
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
logger.warn(
|
|
170
|
+
`could not confirm the session (${e instanceof Error ? e.message : String(e)}) — storing it anyway`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 4. Persist.
|
|
175
|
+
const path = saveCredentials(
|
|
176
|
+
{
|
|
177
|
+
token,
|
|
178
|
+
login,
|
|
179
|
+
accountId,
|
|
180
|
+
intakeUrl: baseUrl,
|
|
181
|
+
createdAt: new Date().toISOString(),
|
|
182
|
+
},
|
|
183
|
+
env,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
logger.plain("");
|
|
187
|
+
logger.info(paint.green(`logged in as ${paint.bold(login)}`) + paint.dim(` — saved to ${path}`));
|
|
188
|
+
logger.plain(paint.dim(" `siltrun deploy` now ships rooms under this account."));
|
|
189
|
+
return { ok: true, login };
|
|
190
|
+
} catch (e) {
|
|
191
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
192
|
+
logger.error(`login failed: ${msg}`);
|
|
193
|
+
return { ok: false, error: msg };
|
|
194
|
+
} finally {
|
|
195
|
+
server.stop(true);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function runLogout(
|
|
200
|
+
env: Record<string, string | undefined> = process.env,
|
|
201
|
+
logger: DeployLogger = log,
|
|
202
|
+
): Promise<boolean> {
|
|
203
|
+
const had = clearCredentials(env);
|
|
204
|
+
if (had) {
|
|
205
|
+
logger.info(`logged out ${paint.dim(`(removed ${credentialsPath(env)})`)}`);
|
|
206
|
+
} else {
|
|
207
|
+
logger.info("not logged in — nothing to remove");
|
|
208
|
+
}
|
|
209
|
+
return had;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function runWhoami(overrides: Partial<LoginDeps> = {}): Promise<boolean> {
|
|
213
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
214
|
+
const { logger, env } = deps;
|
|
215
|
+
|
|
216
|
+
const creds = loadCredentials(env);
|
|
217
|
+
if (!creds) {
|
|
218
|
+
logger.info("not logged in — run `siltrun login`");
|
|
219
|
+
if (env.SILT_DEPLOY_TOKEN) {
|
|
220
|
+
logger.plain(paint.dim(" (SILT_DEPLOY_TOKEN is set — deploys use that legacy beta token)"));
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const res = await deps.fetchImpl(`${normalizeBase(creds.intakeUrl)}/v0/me`, {
|
|
227
|
+
headers: { Authorization: `Bearer ${creds.token}` },
|
|
228
|
+
});
|
|
229
|
+
if (!res.ok) {
|
|
230
|
+
logger.warn(
|
|
231
|
+
`stored session for ${paint.bold(creds.login)} was rejected (${res.status}) — run \`siltrun login\` again`,
|
|
232
|
+
);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const me = (await res.json()) as { login?: string; accountId?: string; provider?: string };
|
|
236
|
+
logger.info(
|
|
237
|
+
`logged in as ${paint.bold(me.login || creds.login)} ` +
|
|
238
|
+
paint.dim(`(${me.provider || "?"} · account ${me.accountId || "?"} · ${creds.intakeUrl})`),
|
|
239
|
+
);
|
|
240
|
+
return true;
|
|
241
|
+
} catch (e) {
|
|
242
|
+
logger.warn(
|
|
243
|
+
`could not reach ${creds.intakeUrl} (${e instanceof Error ? e.message : String(e)}) — ` +
|
|
244
|
+
`stored login is ${paint.bold(creds.login)}`,
|
|
245
|
+
);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** CLI entries (argv unused today — kept for symmetry with dev/deploy). */
|
|
251
|
+
export async function login(_argv: string[]): Promise<void> {
|
|
252
|
+
const result = await runLogin();
|
|
253
|
+
if (!result.ok) process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function logout(_argv: string[]): Promise<void> {
|
|
257
|
+
await runLogout();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function whoami(_argv: string[]): Promise<void> {
|
|
261
|
+
const ok = await runWhoami();
|
|
262
|
+
if (!ok) process.exit(1);
|
|
263
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Resolves the runtime pieces the CLI drives — the Go room-server binary and the Bun
|
|
2
|
+
// room-host — across the two ways `siltrun` runs:
|
|
3
|
+
//
|
|
4
|
+
// 1. INSTALLED (npm): the CLI package depends on @siltrun/room-host (the host/doctor
|
|
5
|
+
// sources) and on per-platform @siltrun/room-server-<os>-<arch> packages carrying a
|
|
6
|
+
// prebuilt server binary (the Wrangler/workerd distribution pattern). Everything
|
|
7
|
+
// resolves through node_modules — no repo checkout, no Go toolchain.
|
|
8
|
+
// 2. IN-REPO (this monorepo): room-server and room-host are siblings under
|
|
9
|
+
// packages/, and the server is built from Go source (server-build.ts) so local
|
|
10
|
+
// .go edits are always honored.
|
|
11
|
+
//
|
|
12
|
+
// Sibling paths win when they exist (in-repo dev stays source-fresh); packaged
|
|
13
|
+
// resolution covers the installed case. Every path is overridable by env for tests
|
|
14
|
+
// and for unusual setups.
|
|
15
|
+
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
// import.meta.dir is the directory of THIS file under Bun: .../packages/cli/src
|
|
20
|
+
// (or .../node_modules/silt/src when installed — siblings then don't exist).
|
|
21
|
+
const packagesDir = resolve(import.meta.dir, "..", "..");
|
|
22
|
+
|
|
23
|
+
/** Resolve an installed package's directory, or null when it isn't installed. */
|
|
24
|
+
function packageDir(name: string): string | null {
|
|
25
|
+
try {
|
|
26
|
+
return dirname(Bun.resolveSync(`${name}/package.json`, import.meta.dir));
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** node platform-arch key, e.g. darwin-arm64 — matches the platform package suffixes. */
|
|
33
|
+
export const platformKey = `${process.platform}-${process.arch}`;
|
|
34
|
+
|
|
35
|
+
/** Prebuilt room-server binary from the installed platform package, or null. */
|
|
36
|
+
function packagedRoomServerBin(): string | null {
|
|
37
|
+
const dir = packageDir(`@siltrun/room-server-${platformKey}`);
|
|
38
|
+
if (!dir) return null;
|
|
39
|
+
const bin = join(
|
|
40
|
+
dir,
|
|
41
|
+
process.platform === "win32" ? "silt-room-server.exe" : "silt-room-server",
|
|
42
|
+
);
|
|
43
|
+
return existsSync(bin) ? bin : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** room-host entry (host.ts / doctor.ts) from the installed @siltrun/room-host, or null. */
|
|
47
|
+
function packagedRoomHostFile(file: string): string | null {
|
|
48
|
+
const dir = packageDir("@siltrun/room-host");
|
|
49
|
+
if (!dir) return null;
|
|
50
|
+
const p = join(dir, file);
|
|
51
|
+
return existsSync(p) ? p : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Sibling locations (the in-repo layout).
|
|
55
|
+
const siblingRoomServerDir = join(packagesDir, "room-server");
|
|
56
|
+
const siblingRoomHost = (file: string) => join(packagesDir, "room-host", file);
|
|
57
|
+
|
|
58
|
+
export const paths = {
|
|
59
|
+
packagesDir,
|
|
60
|
+
/** Go source dir for in-repo builds; server-build skips it when it doesn't exist. */
|
|
61
|
+
roomServerDir: process.env.SILT_ROOM_SERVER_DIR || siblingRoomServerDir,
|
|
62
|
+
/** Prebuilt server binary: env override → installed platform package → null. */
|
|
63
|
+
roomServerBin:
|
|
64
|
+
process.env.SILT_ROOM_SERVER_BIN ||
|
|
65
|
+
(existsSync(siblingRoomServerDir) ? null : packagedRoomServerBin()),
|
|
66
|
+
// SEAM §1 pins the host invocation as `bun host.ts <bundle>`, so host.ts is the entry.
|
|
67
|
+
roomHostEntry:
|
|
68
|
+
process.env.SILT_ROOM_HOST_ENTRY ||
|
|
69
|
+
(existsSync(siblingRoomHost("host.ts"))
|
|
70
|
+
? siblingRoomHost("host.ts")
|
|
71
|
+
: (packagedRoomHostFile("host.ts") ?? siblingRoomHost("host.ts"))),
|
|
72
|
+
// CONTRACT §4 / brief: the determinism doctor, if B has shipped it.
|
|
73
|
+
doctorEntry:
|
|
74
|
+
process.env.SILT_DOCTOR_ENTRY ||
|
|
75
|
+
(existsSync(siblingRoomHost("doctor.ts"))
|
|
76
|
+
? siblingRoomHost("doctor.ts")
|
|
77
|
+
: (packagedRoomHostFile("doctor.ts") ?? siblingRoomHost("doctor.ts"))),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function roomHostDir(): string {
|
|
81
|
+
return dirname(paths.roomHostEntry);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const has = {
|
|
85
|
+
roomHost: () => existsSync(paths.roomHostEntry),
|
|
86
|
+
doctor: () => existsSync(paths.doctorEntry),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The bun binary running this CLI — handed to every downstream spawn (SILT_BUN). */
|
|
90
|
+
export function bunBin(): string {
|
|
91
|
+
return process.env.SILT_BUN || process.execPath;
|
|
92
|
+
}
|