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
package/src/bundle.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Bundles a contract file (`room.ts` and its imports) into a single ESM module the
|
|
2
|
+
// room-host can load. Uses Bun's programmatic bundler — the same `bun build` the demo
|
|
3
|
+
// scripts use, but in-process so we control the exact output path across reloads.
|
|
4
|
+
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
export class BundleError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
message: string,
|
|
11
|
+
readonly logs: string[] = [],
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "BundleError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Bundle `contractPath` into `outPath` (an absolute .js path). The same outPath is
|
|
20
|
+
* reused across reloads so downstream `--contract <path>` args stay stable; the file
|
|
21
|
+
* contents are what change. Returns outPath on success, throws BundleError otherwise.
|
|
22
|
+
*/
|
|
23
|
+
export async function bundleContract(contractPath: string, outPath: string): Promise<string> {
|
|
24
|
+
const entry = resolve(contractPath);
|
|
25
|
+
if (!existsSync(entry)) {
|
|
26
|
+
throw new BundleError(`contract not found: ${entry}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let result: Awaited<ReturnType<typeof Bun.build>>;
|
|
30
|
+
try {
|
|
31
|
+
result = await Bun.build({
|
|
32
|
+
entrypoints: [entry],
|
|
33
|
+
target: "bun",
|
|
34
|
+
format: "esm",
|
|
35
|
+
// No minify: readable output makes doctor drift and host errors legible in dev.
|
|
36
|
+
});
|
|
37
|
+
} catch (e) {
|
|
38
|
+
// Bun.build throws an AggregateError on parse/resolve failures in some versions.
|
|
39
|
+
const errs = (e as { errors?: unknown[] })?.errors;
|
|
40
|
+
const logs = Array.isArray(errs)
|
|
41
|
+
? errs.map((x) => (x instanceof Error ? x.message : String(x)))
|
|
42
|
+
: [e instanceof Error ? e.message : String(e)];
|
|
43
|
+
throw new BundleError(`failed to bundle ${entry}`, logs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!result.success || result.outputs.length === 0) {
|
|
47
|
+
const logs = result.logs.map((l) => (typeof l === "string" ? l : l.message));
|
|
48
|
+
throw new BundleError(`failed to bundle ${entry}`, logs);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await Bun.write(outPath, result.outputs[0]!);
|
|
52
|
+
return outPath;
|
|
53
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// The `siltrun` CLI entry. Runs under Bun (the room-host runs on Bun and the contract is
|
|
3
|
+
// bundled with Bun's bundler, so Bun is the toolchain regardless). `npx siltrun` execs
|
|
4
|
+
// this file; the shebang picks Bun.
|
|
5
|
+
|
|
6
|
+
import { parseDevArgs, ArgError } from "./args.ts";
|
|
7
|
+
import { dev } from "./dev.ts";
|
|
8
|
+
import { deploy } from "./deploy.ts";
|
|
9
|
+
import { login, logout, whoami } from "./login.ts";
|
|
10
|
+
import { log, paint } from "./log.ts";
|
|
11
|
+
|
|
12
|
+
const VERSION = "0.1.0";
|
|
13
|
+
|
|
14
|
+
const USAGE = `${paint.bold("siltrun")} — authoritative multiplayer rooms
|
|
15
|
+
|
|
16
|
+
${paint.bold("Usage")}
|
|
17
|
+
siltrun dev <contract.ts> [options] run a local authoritative room + hot reload + doctor
|
|
18
|
+
siltrun deploy <contract.ts> [--room <name>] ship to the hosted beta (bundle → doctor → upload)
|
|
19
|
+
siltrun login log in via your browser — deploys become account-keyed
|
|
20
|
+
siltrun logout remove the stored login
|
|
21
|
+
siltrun whoami show the logged-in account
|
|
22
|
+
|
|
23
|
+
${paint.bold("dev options")}
|
|
24
|
+
--room <name> room name, served at /room/<name> (default: derived from the
|
|
25
|
+
contract filename — or its directory when the filename is
|
|
26
|
+
generic like room.ts/index.ts; env SILT_ROOM)
|
|
27
|
+
--wt-port <n> WebTransport port (default: 4433, env SILT_WT_PORT)
|
|
28
|
+
--info-port <n> room-info port (default: 4000, env SILT_INFO_PORT)
|
|
29
|
+
--wt-host <host> WebTransport bind host (default: 127.0.0.1)
|
|
30
|
+
--no-doctor skip the determinism check on (re)load
|
|
31
|
+
|
|
32
|
+
${paint.bold("deploy options")}
|
|
33
|
+
--room <name> room name for the hosted room (default: derived from the contract,
|
|
34
|
+
same rule as dev; env SILT_ROOM)
|
|
35
|
+
credential 'siltrun login' session first; env SILT_DEPLOY_TOKEN (legacy
|
|
36
|
+
beta token) as the fallback
|
|
37
|
+
env SILT_DEPLOY_URL override the intake URL (default: the baked hosted URL)
|
|
38
|
+
|
|
39
|
+
${paint.bold("examples")}
|
|
40
|
+
siltrun dev room.ts
|
|
41
|
+
siltrun dev room.ts --room my-arena
|
|
42
|
+
npx siltrun dev room.ts --info-port 4100
|
|
43
|
+
siltrun deploy room.ts --room my-arena
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
const argv = process.argv.slice(2);
|
|
48
|
+
const cmd = argv[0];
|
|
49
|
+
|
|
50
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
51
|
+
log.plain(VERSION);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
55
|
+
log.plain(USAGE);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
switch (cmd) {
|
|
60
|
+
case "dev": {
|
|
61
|
+
const opts = parseDevArgs(argv.slice(1));
|
|
62
|
+
await dev(opts);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
case "deploy": {
|
|
66
|
+
await deploy(argv.slice(1));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
case "login": {
|
|
70
|
+
await login(argv.slice(1));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
case "logout": {
|
|
74
|
+
await logout(argv.slice(1));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
case "whoami": {
|
|
78
|
+
await whoami(argv.slice(1));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
default:
|
|
82
|
+
log.error(`unknown command: ${cmd}`);
|
|
83
|
+
log.plain(USAGE);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
main().catch((e) => {
|
|
89
|
+
if (e instanceof ArgError) {
|
|
90
|
+
log.error(e.message);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The CLI's stored identity — written by `siltrun login`, read by `siltrun deploy` /
|
|
2
|
+
// `siltrun whoami`, removed by `siltrun logout`.
|
|
3
|
+
//
|
|
4
|
+
// Location: ~/.silt/credentials.json (override the dir with SILT_HOME — used by
|
|
5
|
+
// tests and by anyone pointing the CLI at a non-default setup). The file holds a
|
|
6
|
+
// SESSION token (`s-…`), which is a bearer credential: the dir is created 0700
|
|
7
|
+
// and the file written 0600, and its contents must never be logged.
|
|
8
|
+
//
|
|
9
|
+
// A session token is minted BY a specific intake backend, so the credentials
|
|
10
|
+
// remember which one (`intakeUrl`). Consumers only trust the stored token for
|
|
11
|
+
// that same backend — pointing SILT_DEPLOY_URL somewhere else falls back to
|
|
12
|
+
// SILT_DEPLOY_TOKEN rather than sending a foreign backend's token.
|
|
13
|
+
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
export interface Credentials {
|
|
19
|
+
/** The `s-` session token. A bearer secret — never log it. */
|
|
20
|
+
token: string;
|
|
21
|
+
/** Provider login (e.g. the GitHub username; a dev name under the stub). */
|
|
22
|
+
login: string;
|
|
23
|
+
/** Stable account id on the intake backend. */
|
|
24
|
+
accountId?: string;
|
|
25
|
+
/** The intake base URL this token was minted by (tokens are per-backend). */
|
|
26
|
+
intakeUrl: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Env = Record<string, string | undefined>;
|
|
31
|
+
|
|
32
|
+
export function configDir(env: Env = process.env): string {
|
|
33
|
+
return env.SILT_HOME || join(homedir(), ".silt");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function credentialsPath(env: Env = process.env): string {
|
|
37
|
+
return join(configDir(env), "credentials.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read stored credentials; null when absent or unreadable/malformed (an
|
|
41
|
+
* unparseable file behaves like "not logged in" — `siltrun login` rewrites it). */
|
|
42
|
+
export function loadCredentials(env: Env = process.env): Credentials | null {
|
|
43
|
+
const p = credentialsPath(env);
|
|
44
|
+
if (!existsSync(p)) return null;
|
|
45
|
+
try {
|
|
46
|
+
const c = JSON.parse(readFileSync(p, "utf8")) as Credentials;
|
|
47
|
+
if (!c || typeof c.token !== "string" || !c.token || typeof c.intakeUrl !== "string") {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return c;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function saveCredentials(creds: Credentials, env: Env = process.env): string {
|
|
57
|
+
const dir = configDir(env);
|
|
58
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
59
|
+
const p = credentialsPath(env);
|
|
60
|
+
writeFileSync(p, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
|
|
61
|
+
// writeFileSync's mode only applies on create — an existing file keeps its old
|
|
62
|
+
// bits, so re-assert.
|
|
63
|
+
chmodSync(p, 0o600);
|
|
64
|
+
return p;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Remove stored credentials. Returns true when there was something to remove. */
|
|
68
|
+
export function clearCredentials(env: Env = process.env): boolean {
|
|
69
|
+
const p = credentialsPath(env);
|
|
70
|
+
if (!existsSync(p)) return false;
|
|
71
|
+
rmSync(p);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The client side of the v0 intake API (deploy/CONTROL-PLANE.md — "The intake API").
|
|
2
|
+
// Pure HTTP: no logging, no process.exit, `fetch` injected so it's fully testable against
|
|
3
|
+
// a local mock. The CLI (deploy.ts) wires this to real fetch + a live status line.
|
|
4
|
+
//
|
|
5
|
+
// POST /v0/deploy?room=<name> Bearer <tester-token> body = raw bundled JS (text/javascript)
|
|
6
|
+
// -> 201 { deployId, room, status:"queued" }
|
|
7
|
+
// GET /v0/deploy/<id> Bearer <tester-token>
|
|
8
|
+
// -> 200 { deployId, room, status, url?, error? }
|
|
9
|
+
//
|
|
10
|
+
// The bundle rides as the RAW request body (never base64) — postDeploy sends the JS text
|
|
11
|
+
// straight through as `text/javascript`.
|
|
12
|
+
|
|
13
|
+
export type DeployStatus = "queued" | "provisioning" | "live" | "failed";
|
|
14
|
+
|
|
15
|
+
export interface DeployRecord {
|
|
16
|
+
deployId: string;
|
|
17
|
+
room: string;
|
|
18
|
+
status: DeployStatus;
|
|
19
|
+
url?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
/**
|
|
22
|
+
* The raw in-guest determinism verdict the box reported (e.g.
|
|
23
|
+
* "SILT-DOCTOR: GREEN (300-tick replay deterministic)"), surfaced to the tester on `live`.
|
|
24
|
+
* Present once the box has run its server-side doctor gate (CONTROL-PLANE.md v0.2 §B).
|
|
25
|
+
*/
|
|
26
|
+
doctorVerdict?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A fetch-shaped function so tests can inject one; defaults to the global fetch. */
|
|
30
|
+
export type FetchLike = typeof fetch;
|
|
31
|
+
|
|
32
|
+
/** Thrown for transport/HTTP-contract failures. `status` is the HTTP code when there was one. */
|
|
33
|
+
export class DeployHttpError extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
message: string,
|
|
36
|
+
readonly status?: number,
|
|
37
|
+
) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "DeployHttpError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Strip a trailing slash so `${base}/v0/...` never doubles up. */
|
|
44
|
+
export function normalizeBase(baseUrl: string): string {
|
|
45
|
+
return baseUrl.replace(/\/+$/, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readError(res: Response): Promise<string> {
|
|
49
|
+
// The worker returns JSON errors when it can; fall back to raw text.
|
|
50
|
+
const raw = await res.text().catch(() => "");
|
|
51
|
+
if (!raw) return "";
|
|
52
|
+
try {
|
|
53
|
+
const j = JSON.parse(raw) as { error?: string; message?: string };
|
|
54
|
+
return j.error || j.message || raw;
|
|
55
|
+
} catch {
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* POST the bundled contract to the intake worker. `bundleText` is sent as the RAW request
|
|
62
|
+
* body (text/javascript) — the transport contract forbids base64. Returns the queued record.
|
|
63
|
+
*/
|
|
64
|
+
export async function postDeploy(
|
|
65
|
+
baseUrl: string,
|
|
66
|
+
token: string,
|
|
67
|
+
room: string,
|
|
68
|
+
bundleText: string,
|
|
69
|
+
fetchImpl: FetchLike = fetch,
|
|
70
|
+
): Promise<DeployRecord> {
|
|
71
|
+
const url = `${normalizeBase(baseUrl)}/v0/deploy?room=${encodeURIComponent(room)}`;
|
|
72
|
+
let res: Response;
|
|
73
|
+
try {
|
|
74
|
+
res = await fetchImpl(url, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: `Bearer ${token}`,
|
|
78
|
+
"Content-Type": "text/javascript",
|
|
79
|
+
},
|
|
80
|
+
body: bundleText, // RAW — no base64, no JSON envelope.
|
|
81
|
+
});
|
|
82
|
+
} catch (e) {
|
|
83
|
+
throw new DeployHttpError(
|
|
84
|
+
`could not reach the intake service at ${normalizeBase(baseUrl)} — ${e instanceof Error ? e.message : String(e)}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (res.status === 401) {
|
|
89
|
+
throw new DeployHttpError("token rejected (401) — your SILT_DEPLOY_TOKEN is not valid", 401);
|
|
90
|
+
}
|
|
91
|
+
if (res.status === 400) {
|
|
92
|
+
const detail = await readError(res);
|
|
93
|
+
throw new DeployHttpError(`upload rejected (400)${detail ? ` — ${detail}` : ""}`, 400);
|
|
94
|
+
}
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
const detail = await readError(res);
|
|
97
|
+
throw new DeployHttpError(
|
|
98
|
+
`intake returned ${res.status}${detail ? ` — ${detail}` : ""}`,
|
|
99
|
+
res.status,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rec = (await res.json().catch(() => null)) as DeployRecord | null;
|
|
104
|
+
if (!rec || typeof rec.deployId !== "string" || !rec.deployId) {
|
|
105
|
+
throw new DeployHttpError("intake accepted the upload but returned no deployId");
|
|
106
|
+
}
|
|
107
|
+
return rec;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** GET the current status of a deploy. */
|
|
111
|
+
export async function getDeployStatus(
|
|
112
|
+
baseUrl: string,
|
|
113
|
+
token: string,
|
|
114
|
+
deployId: string,
|
|
115
|
+
fetchImpl: FetchLike = fetch,
|
|
116
|
+
): Promise<DeployRecord> {
|
|
117
|
+
const url = `${normalizeBase(baseUrl)}/v0/deploy/${encodeURIComponent(deployId)}`;
|
|
118
|
+
let res: Response;
|
|
119
|
+
try {
|
|
120
|
+
res = await fetchImpl(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
121
|
+
} catch (e) {
|
|
122
|
+
throw new DeployHttpError(
|
|
123
|
+
`could not reach the intake service — ${e instanceof Error ? e.message : String(e)}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (res.status === 401) {
|
|
128
|
+
throw new DeployHttpError("token rejected (401) while polling status", 401);
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const detail = await readError(res);
|
|
132
|
+
throw new DeployHttpError(
|
|
133
|
+
`status poll returned ${res.status}${detail ? ` — ${detail}` : ""}`,
|
|
134
|
+
res.status,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const rec = (await res.json().catch(() => null)) as DeployRecord | null;
|
|
139
|
+
if (!rec || typeof rec.status !== "string") {
|
|
140
|
+
throw new DeployHttpError("status poll returned an unrecognized body");
|
|
141
|
+
}
|
|
142
|
+
return rec;
|
|
143
|
+
}
|