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/dev.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// `siltrun dev <contract.ts>` — the local authoritative-room dev loop.
|
|
2
|
+
//
|
|
3
|
+
// Pipeline (brief + hello-room.md §3):
|
|
4
|
+
// 1. build the Go room-server binary if needed
|
|
5
|
+
// 2. bundle the contract (Bun.build) to a stable temp path
|
|
6
|
+
// 3. run the determinism doctor (CONTRACT §4) if room-host shipped one
|
|
7
|
+
// 4. boot the room-server in compute mode, scraping its per-boot cert hash
|
|
8
|
+
// 5. serve room-info on :4000 (SEAM §4) so `useRoom("http://localhost:4000")` works
|
|
9
|
+
// 6. watch the contract; on change → rebuild → doctor → restart the room (simplest
|
|
10
|
+
// correct thing per the brief) → refresh room-info → print what happened
|
|
11
|
+
// 7. Ctrl-C tears everything down.
|
|
12
|
+
|
|
13
|
+
import { resolve, dirname, join } from "node:path";
|
|
14
|
+
import { existsSync, statSync, watch, type FSWatcher } from "node:fs";
|
|
15
|
+
import { mkdtemp } from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
|
|
18
|
+
import type { DevOptions } from "./args.ts";
|
|
19
|
+
import { paths, has, bunBin, platformKey } from "./paths.ts";
|
|
20
|
+
import { ensureRoomServer, ServerBuildError } from "./server-build.ts";
|
|
21
|
+
import { bundleContract, BundleError } from "./bundle.ts";
|
|
22
|
+
import { runDoctor, type DoctorResult } from "./doctor.ts";
|
|
23
|
+
import { ServerSupervisor } from "./supervisor.ts";
|
|
24
|
+
import { startRoomInfoServer, type RoomInfo, type RoomInfoServer } from "./room-info.ts";
|
|
25
|
+
import { printBoot, printDrift } from "./banner.ts";
|
|
26
|
+
import { log, paint } from "./log.ts";
|
|
27
|
+
|
|
28
|
+
export async function dev(opts: DevOptions): Promise<void> {
|
|
29
|
+
const contract = resolve(opts.contract);
|
|
30
|
+
if (!existsSync(contract)) {
|
|
31
|
+
log.error(`contract not found: ${contract}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Compute mode needs room-host (owner B). Without it we can still boot the relay
|
|
36
|
+
// binary + serve room-info (useful standalone), but compute won't function.
|
|
37
|
+
const computeReady = has.roomHost();
|
|
38
|
+
const mode: RoomInfo["mode"] = computeReady ? "compute" : "relay";
|
|
39
|
+
if (!computeReady) {
|
|
40
|
+
// @siltrun/room-host ships as a dependency of this CLI — its absence means a broken
|
|
41
|
+
// install, not a missing feature. Say that, with the way out.
|
|
42
|
+
log.warn(
|
|
43
|
+
`room-host runtime not found (looked at ${paths.roomHostEntry}) — booting RELAY ONLY, ` +
|
|
44
|
+
`your contract will not run. @siltrun/room-host ships with the siltrun package: ` +
|
|
45
|
+
`reinstall (rm -rf node_modules && npm install), or set SILT_ROOM_HOST_ENTRY ` +
|
|
46
|
+
`to a host.ts.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 1. build the server binary
|
|
51
|
+
let binPath: string;
|
|
52
|
+
try {
|
|
53
|
+
binPath = await ensureRoomServer();
|
|
54
|
+
} catch (e) {
|
|
55
|
+
// An installed `siltrun` ships a prebuilt room-server via the platform packages
|
|
56
|
+
// (@siltrun/room-server-<os>-<arch>, optionalDependencies). Reaching this with no
|
|
57
|
+
// repo checkout means the platform package is missing — an unsupported platform
|
|
58
|
+
// or an install that skipped optional deps. Say exactly that, with the way out.
|
|
59
|
+
if (e instanceof ServerBuildError && !existsSync(paths.roomServerDir)) {
|
|
60
|
+
log.warn(
|
|
61
|
+
`no room-server runtime for ${platformKey} — the prebuilt platform package ` +
|
|
62
|
+
`(@siltrun/room-server-${platformKey}) isn't installed. Reinstall without ` +
|
|
63
|
+
`--no-optional, or set SILT_ROOM_SERVER_BIN to a silt-room-server binary. ` +
|
|
64
|
+
`To ship a room to the hosted beta instead, use \`siltrun deploy <contract.ts>\`.`,
|
|
65
|
+
);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
// Genuinely-broken cases (e.g. a `go build` compile failure) stay a hard red error.
|
|
69
|
+
if (e instanceof ServerBuildError) {
|
|
70
|
+
log.error(e.message);
|
|
71
|
+
if (e.detail) log.plain(paint.dim(e.detail));
|
|
72
|
+
} else {
|
|
73
|
+
log.error(`room-server build failed: ${String(e)}`);
|
|
74
|
+
}
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. bundle the contract into a stable temp path (reused across reloads)
|
|
79
|
+
const workDir = await mkdtemp(join(tmpdir(), "silt-dev-"));
|
|
80
|
+
const bundlePath = join(workDir, "contract.bundle.js");
|
|
81
|
+
try {
|
|
82
|
+
await bundleContract(contract, bundlePath);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
reportBundleError(e);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. doctor
|
|
89
|
+
let doctor: DoctorResult = opts.doctor
|
|
90
|
+
? await runDoctor(bundlePath)
|
|
91
|
+
: { status: "skipped", note: "disabled with --no-doctor" };
|
|
92
|
+
|
|
93
|
+
// room-info state — cert refreshes on each (re)boot. The room name (SEAM §4:
|
|
94
|
+
// /room/<name>) comes from --room, or is derived from the contract path (args.ts);
|
|
95
|
+
// the server serves exactly this path and loudly rejects anything else.
|
|
96
|
+
const wtEndpoint = `https://${opts.wtHost}:${opts.wtPort}/room/${opts.room}`;
|
|
97
|
+
let certHash: string | null = null;
|
|
98
|
+
const getInfo = (): RoomInfo => ({ wtEndpoint, certHash, mode });
|
|
99
|
+
|
|
100
|
+
// 4. supervise the room-server.
|
|
101
|
+
// The server treats a non-empty --contract as "enable compute mode"; empty = relay.
|
|
102
|
+
// --room is passed ALWAYS (both modes) so the served path matches wtEndpoint above.
|
|
103
|
+
const computeArgs = computeReady ? ["--contract", bundlePath, "--host", paths.roomHostEntry] : [];
|
|
104
|
+
const supervisor = new ServerSupervisor({
|
|
105
|
+
binPath,
|
|
106
|
+
args: ["--room", opts.room, ...computeArgs],
|
|
107
|
+
cwd: existsSync(paths.roomServerDir) ? paths.roomServerDir : undefined,
|
|
108
|
+
// SILT_BUN: the server spawns the Bun room-host itself (compute.go) — hand it the
|
|
109
|
+
// exact runtime running this CLI so it never depends on `bun` being on PATH.
|
|
110
|
+
env: { WT_BIND: `${opts.wtHost}:${opts.wtPort}`, SILT_BUN: bunBin() },
|
|
111
|
+
onCert: (h) => {
|
|
112
|
+
certHash = h;
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await supervisor.start();
|
|
118
|
+
} catch (e) {
|
|
119
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
120
|
+
log.error(`room-server failed to boot: ${msg}`);
|
|
121
|
+
// The documented stray-dev hazard: another siltrun dev already holds the default
|
|
122
|
+
// ports. When the server's own output says so, hand over the way out.
|
|
123
|
+
if (/address already in use|bind:/i.test(msg)) {
|
|
124
|
+
log.plain(
|
|
125
|
+
paint.dim(
|
|
126
|
+
` port ${opts.wtPort} looks taken — another \`siltrun dev\` may be running. ` +
|
|
127
|
+
`Stop it, or pass --wt-port <n> (and --info-port <n>, pointing useRoom at it).`,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 5. room-info endpoint
|
|
135
|
+
let roomInfo: RoomInfoServer;
|
|
136
|
+
try {
|
|
137
|
+
roomInfo = startRoomInfoServer({ port: opts.infoPort, hostname: "127.0.0.1", getInfo });
|
|
138
|
+
} catch (e) {
|
|
139
|
+
log.error(`could not bind room-info on :${opts.infoPort}: ${String(e)}`);
|
|
140
|
+
log.plain(
|
|
141
|
+
paint.dim(
|
|
142
|
+
` port ${opts.infoPort} is likely held by another \`siltrun dev\`. Stop it, or pass ` +
|
|
143
|
+
`--info-port <n> and point your client at http://localhost:<n> instead.`,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
await supervisor.stop();
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
printBoot({ roomInfoUrl: roomInfo.url, info: getInfo(), contract, doctor });
|
|
151
|
+
printDrift(doctor);
|
|
152
|
+
|
|
153
|
+
// 6. watch + reload
|
|
154
|
+
let reloading = false;
|
|
155
|
+
let queued = false;
|
|
156
|
+
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
157
|
+
|
|
158
|
+
async function reload() {
|
|
159
|
+
if (reloading) {
|
|
160
|
+
queued = true;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
reloading = true;
|
|
164
|
+
try {
|
|
165
|
+
await bundleContract(contract, bundlePath);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
reportBundleError(e);
|
|
168
|
+
log.warn("keeping the last good room running — fix the contract to reload");
|
|
169
|
+
reloading = false;
|
|
170
|
+
if (queued) drainQueued();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
doctor = opts.doctor
|
|
175
|
+
? await runDoctor(bundlePath)
|
|
176
|
+
: { status: "skipped", note: "disabled with --no-doctor" };
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
await supervisor.restart();
|
|
180
|
+
} catch (e) {
|
|
181
|
+
log.error(`restart failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
printBoot({ roomInfoUrl: roomInfo.url, info: getInfo(), contract, doctor, reload: true });
|
|
185
|
+
printDrift(doctor);
|
|
186
|
+
|
|
187
|
+
reloading = false;
|
|
188
|
+
if (queued) drainQueued();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function drainQueued() {
|
|
192
|
+
queued = false;
|
|
193
|
+
void reload();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Watch the contract's directory and decide by the FILE'S OWN mtime, not the event's
|
|
197
|
+
// filename: atomic-rename saves (vim, sed -i, many editors) emit their events under a
|
|
198
|
+
// TEMP filename, so a basename filter silently misses them (caught in the packaged
|
|
199
|
+
// cold walk, 2026-07-17). Any event in the dir is just a prompt to stat the contract.
|
|
200
|
+
let lastMtime = statSync(contract).mtimeMs;
|
|
201
|
+
let watcher: FSWatcher | null = null;
|
|
202
|
+
try {
|
|
203
|
+
watcher = watch(dirname(contract), () => {
|
|
204
|
+
// ONE stat, no existsSync pre-check: an atomic-rename save unlinks the old
|
|
205
|
+
// inode before the new one lands, and a stat racing that window throws
|
|
206
|
+
// ENOENT — the exists→stat gap is a TOCTOU that crashed `siltrun dev`. A null
|
|
207
|
+
// here means "not re-appeared yet"; the rename emits another event once the
|
|
208
|
+
// new file is in place and we re-stat then (the debounce/retry the loop
|
|
209
|
+
// already intends).
|
|
210
|
+
const mtime = statMtimeOrNull(contract);
|
|
211
|
+
if (mtime === null) return;
|
|
212
|
+
if (mtime === lastMtime) return; // an event for something else in the dir
|
|
213
|
+
lastMtime = mtime;
|
|
214
|
+
if (debounce) clearTimeout(debounce);
|
|
215
|
+
debounce = setTimeout(() => void reload(), 150);
|
|
216
|
+
});
|
|
217
|
+
} catch (e) {
|
|
218
|
+
log.warn(`could not watch ${contract} — reload disabled: ${String(e)}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 7. teardown
|
|
222
|
+
let tearing = false;
|
|
223
|
+
const teardown = async (signal: string) => {
|
|
224
|
+
if (tearing) return;
|
|
225
|
+
tearing = true;
|
|
226
|
+
log.plain();
|
|
227
|
+
log.info(`${signal} — shutting down…`);
|
|
228
|
+
if (debounce) clearTimeout(debounce);
|
|
229
|
+
watcher?.close();
|
|
230
|
+
roomInfo.stop();
|
|
231
|
+
await supervisor.stop();
|
|
232
|
+
process.exit(0);
|
|
233
|
+
};
|
|
234
|
+
process.on("SIGINT", () => void teardown("SIGINT"));
|
|
235
|
+
process.on("SIGTERM", () => void teardown("SIGTERM"));
|
|
236
|
+
|
|
237
|
+
// Keep the process alive (room-info server + watcher hold it, but be explicit).
|
|
238
|
+
await new Promise<never>(() => {});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function reportBundleError(e: unknown) {
|
|
242
|
+
if (e instanceof BundleError) {
|
|
243
|
+
log.error(e.message);
|
|
244
|
+
for (const l of e.logs) log.plain(paint.dim(" " + l));
|
|
245
|
+
} else {
|
|
246
|
+
log.error(`bundle failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Stat `path` for its mtimeMs, tolerating the transient absence of an atomic-rename
|
|
252
|
+
* save. Returns null when the file isn't there right now (ENOENT) — the caller
|
|
253
|
+
* treats that as "not re-appeared yet" and waits for the next event. Any other
|
|
254
|
+
* stat error is genuine and rethrown. `statFn` is injectable so the ENOENT race
|
|
255
|
+
* is testable deterministically (the exists→stat TOCTOU can't be timed reliably).
|
|
256
|
+
*/
|
|
257
|
+
export function statMtimeOrNull(path: string, statFn: typeof statSync = statSync): number | null {
|
|
258
|
+
try {
|
|
259
|
+
return statFn(path).mtimeMs;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { interpretDoctorExit, DOCTOR_TICKS } from "./doctor.ts";
|
|
3
|
+
|
|
4
|
+
// B's doctor CLI contract (packages/room-host/doctor.ts):
|
|
5
|
+
// Exit: 0 green · 1 drift · 2 usage/replay failure
|
|
6
|
+
describe("interpretDoctorExit", () => {
|
|
7
|
+
test("exit 0 -> ok", () => {
|
|
8
|
+
const r = interpretDoctorExit(0, "✓ 300 ticks bit-identical", 300);
|
|
9
|
+
expect(r.status).toBe("ok");
|
|
10
|
+
expect(r.note).toContain("300-tick");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("exit 1 -> drift (B's real drift signal)", () => {
|
|
14
|
+
const r = interpretDoctorExit(1, "✗ FIRST DRIFT at tick 42", 300);
|
|
15
|
+
expect(r.status).toBe("drift");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("exit 2 (usage/replay failure) -> errored, NOT drift", () => {
|
|
19
|
+
const r = interpretDoctorExit(2, "usage: bun doctor.ts <bundle.js> <ticks>", 300);
|
|
20
|
+
expect(r.status).toBe("errored");
|
|
21
|
+
expect(r.note).toContain("exit 2");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("other non-zero exits -> errored, NOT drift", () => {
|
|
25
|
+
expect(interpretDoctorExit(127, "", 300).status).toBe("errored");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("standard tick count is 300", () => {
|
|
29
|
+
expect(DOCTOR_TICKS).toBe(300);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Runs the determinism doctor (CONTRACT §4) against a freshly-bundled contract.
|
|
2
|
+
//
|
|
3
|
+
// The doctor is owned by room-host (owner B). Per CONTRACT §4 it replays the tick
|
|
4
|
+
// sequence twice (and across engines when present), hashing state per tick, and
|
|
5
|
+
// reports drift at the exact tick + field. Its CLI (packages/room-host/doctor.ts):
|
|
6
|
+
//
|
|
7
|
+
// Usage: bun doctor.ts <bundle.js> <ticks> [--seed <n>]
|
|
8
|
+
// Exit: 0 green · 1 drift · 2 usage/replay failure
|
|
9
|
+
//
|
|
10
|
+
// Only exit 1 is B's real drift signal; any other non-zero means the doctor itself
|
|
11
|
+
// couldn't run — surfaced as a warning, never as drift.
|
|
12
|
+
//
|
|
13
|
+
// If B hasn't shipped doctor.ts yet, we degrade to a notice — dev keeps running.
|
|
14
|
+
|
|
15
|
+
import { bunBin, has, paths } from "./paths.ts";
|
|
16
|
+
|
|
17
|
+
// The standard replay length used across the reference build.
|
|
18
|
+
export const DOCTOR_TICKS = 300;
|
|
19
|
+
|
|
20
|
+
export type DoctorStatus = "ok" | "drift" | "skipped" | "errored";
|
|
21
|
+
|
|
22
|
+
export interface DoctorResult {
|
|
23
|
+
status: DoctorStatus;
|
|
24
|
+
note: string;
|
|
25
|
+
output?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Map a doctor exit code to a result (pure, testable). */
|
|
29
|
+
export function interpretDoctorExit(code: number, output: string, ticks: number): DoctorResult {
|
|
30
|
+
if (code === 0) {
|
|
31
|
+
return { status: "ok", note: `deterministic — ${ticks}-tick replay matched`, output };
|
|
32
|
+
}
|
|
33
|
+
if (code === 1) {
|
|
34
|
+
return { status: "drift", note: "determinism drift detected", output };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
status: "errored",
|
|
38
|
+
note: `doctor could not run (exit ${code}) — not a drift verdict`,
|
|
39
|
+
output,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function runDoctor(
|
|
44
|
+
bundlePath: string,
|
|
45
|
+
ticks: number = DOCTOR_TICKS,
|
|
46
|
+
): Promise<DoctorResult> {
|
|
47
|
+
if (!has.doctor()) {
|
|
48
|
+
return {
|
|
49
|
+
status: "skipped",
|
|
50
|
+
note: `determinism doctor not present (${paths.doctorEntry}) — skipping check`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Run the doctor under the same bun that runs this CLI — never a PATH lookup,
|
|
56
|
+
// which doesn't exist for npm consumers (bun ships as a package, not a command).
|
|
57
|
+
const proc = Bun.spawn([bunBin(), paths.doctorEntry, bundlePath, String(ticks)], {
|
|
58
|
+
stdout: "pipe",
|
|
59
|
+
stderr: "pipe",
|
|
60
|
+
});
|
|
61
|
+
const [code, out, err] = await Promise.all([
|
|
62
|
+
proc.exited,
|
|
63
|
+
new Response(proc.stdout).text(),
|
|
64
|
+
new Response(proc.stderr).text(),
|
|
65
|
+
]);
|
|
66
|
+
const output = [out.trim(), err.trim()].filter(Boolean).join("\n");
|
|
67
|
+
return interpretDoctorExit(code, output, ticks);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return {
|
|
70
|
+
status: "errored",
|
|
71
|
+
note: `could not run doctor: ${e instanceof Error ? e.message : String(e)}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Tiny zero-dependency logger. ANSI colors, auto-disabled when stdout is not a TTY
|
|
2
|
+
// or when NO_COLOR is set (https://no-color.org).
|
|
3
|
+
|
|
4
|
+
const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
5
|
+
|
|
6
|
+
const wrap = (code: string) => (s: string) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
7
|
+
|
|
8
|
+
export const paint = {
|
|
9
|
+
dim: wrap("2"),
|
|
10
|
+
bold: wrap("1"),
|
|
11
|
+
red: wrap("31"),
|
|
12
|
+
green: wrap("32"),
|
|
13
|
+
yellow: wrap("33"),
|
|
14
|
+
blue: wrap("34"),
|
|
15
|
+
cyan: wrap("36"),
|
|
16
|
+
gray: wrap("90"),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// A stable prefix so CLI output is visually distinct from the room-server's own logs.
|
|
20
|
+
const tag = paint.cyan("silt");
|
|
21
|
+
|
|
22
|
+
export const log = {
|
|
23
|
+
info(msg: string) {
|
|
24
|
+
console.log(`${tag} ${msg}`);
|
|
25
|
+
},
|
|
26
|
+
warn(msg: string) {
|
|
27
|
+
console.warn(`${tag} ${paint.yellow("warn")} ${msg}`);
|
|
28
|
+
},
|
|
29
|
+
error(msg: string) {
|
|
30
|
+
console.error(`${tag} ${paint.red("error")} ${msg}`);
|
|
31
|
+
},
|
|
32
|
+
// A raw line forwarded from a child process (room-server / host), visually demoted.
|
|
33
|
+
child(source: string, line: string) {
|
|
34
|
+
console.log(`${paint.gray(source.padEnd(6))} ${paint.gray(line)}`);
|
|
35
|
+
},
|
|
36
|
+
plain(msg = "") {
|
|
37
|
+
console.log(msg);
|
|
38
|
+
},
|
|
39
|
+
};
|