@irtio/cli 0.5.0 → 0.5.2

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.
@@ -4,14 +4,24 @@ import {
4
4
  import {
5
5
  bundleRoom
6
6
  } from "./chunk-KRQUAEN2.js";
7
+ import {
8
+ logEnsured,
9
+ runStaticDeploy
10
+ } from "./chunk-GBNHBWES.js";
11
+ import "./chunk-BPE452KF.js";
12
+ import {
13
+ ensureProject,
14
+ readProjectConfig
15
+ } from "./chunk-32QTPKVT.js";
7
16
  import {
8
17
  ApiClientError,
18
+ HelpRequested,
9
19
  createApiClient,
10
- isLoginRequired
11
- } from "./chunk-I37DLT7K.js";
12
- import {
20
+ helpFor,
21
+ helpRequested,
22
+ isLoginRequired,
13
23
  resolveControlUrlForUser
14
- } from "./chunk-NVUKSP5U.js";
24
+ } from "./chunk-TV66QHFP.js";
15
25
 
16
26
  // src/deploy.ts
17
27
  import { existsSync } from "fs";
@@ -23,7 +33,30 @@ import { diffSchemas, schemaFromCanonical } from "@irtio/schema";
23
33
  import pc from "picocolors";
24
34
  var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
25
35
  var MIGRATIONS_DIR = "irtio/migrations";
36
+ var USAGE = `usage: irtio deploy [options]
37
+ irtio deploy --static [<dir>] [options]
38
+
39
+ Bundles the room, classifies the schema change against the last deployment, and uploads. A
40
+ breaking change is refused unless a migration covers it. With "static" in the project file the
41
+ client directory ships too, after the room.
42
+
43
+ options:
44
+ --allow-breaking accept a breaking schema change that a migration covers
45
+ --strategy drain|migrate drain (the default) lets running rooms finish on the old version;
46
+ migrate moves live rooms onto the new one now
47
+ --room <file> the room entry
48
+ --project <id> project id (default: the project file)
49
+ --name <name> the name a first deploy registers the project under
50
+ --no-static deploy the room alone, even with "static" configured
51
+ -c, --config <f> the project file to read (default irtio.json)
52
+ --url <control> control plane (default: your stored login)
53
+ -h, --help print this
54
+
55
+ irtio deploy --static uploads a BUILT client directory and nothing else. It takes --label <label>
56
+ plus --project, --name, --config and --url; the directory and label default to the project file.
57
+ `;
26
58
  function parseDeployArgs(args) {
59
+ if (helpRequested(args)) throw helpFor(USAGE);
27
60
  const parsed = {
28
61
  allowBreaking: false
29
62
  };
@@ -33,12 +66,32 @@ function parseDeployArgs(args) {
33
66
  case "--allow-breaking":
34
67
  parsed.allowBreaking = true;
35
68
  break;
69
+ case "--no-static":
70
+ parsed.noStatic = true;
71
+ break;
36
72
  case "--project": {
37
73
  const value = args[++i];
38
74
  if (value === void 0) throw new Error("irtio deploy: --project needs a value");
39
75
  parsed.project = value;
40
76
  break;
41
77
  }
78
+ case "--name": {
79
+ const value = args[++i];
80
+ if (value === void 0 || value === "") {
81
+ throw new Error("irtio deploy: --name needs a value");
82
+ }
83
+ parsed.name = value;
84
+ break;
85
+ }
86
+ case "-c":
87
+ case "--config": {
88
+ const value = args[++i];
89
+ if (value === void 0 || value === "") {
90
+ throw new Error("irtio deploy: --config needs a value");
91
+ }
92
+ parsed.config = value;
93
+ break;
94
+ }
42
95
  case "--url": {
43
96
  const value = args[++i];
44
97
  if (value === void 0) throw new Error("irtio deploy: --url needs a value");
@@ -74,7 +127,7 @@ async function defaultAsk(question) {
74
127
  rl.close();
75
128
  }
76
129
  }
77
- function resolveEntry(cwd, room) {
130
+ function findRoomEntry(cwd, room) {
78
131
  if (room !== void 0) {
79
132
  const file = path.resolve(cwd, room);
80
133
  if (!existsSync(file)) throw new Error(`irtio deploy: no room file at ${file}`);
@@ -84,46 +137,19 @@ function resolveEntry(cwd, room) {
84
137
  const file = path.resolve(cwd, candidate);
85
138
  if (existsSync(file)) return file;
86
139
  }
140
+ return void 0;
141
+ }
142
+ function resolveEntry(cwd, room) {
143
+ const entry = findRoomEntry(cwd, room);
144
+ if (entry !== void 0) return entry;
87
145
  throw new Error(
88
146
  `irtio deploy: no room file found in ${cwd}
89
147
  looked for: ${ROOM_CANDIDATES.join(", ")}
90
148
  pass --room <file> if it lives elsewhere`
91
149
  );
92
150
  }
93
- async function readIrtioJsonProject(cwd) {
94
- const file = path.join(cwd, "irtio.json");
95
- if (!existsSync(file)) return void 0;
96
- let parsed;
97
- try {
98
- parsed = JSON.parse(await readFile(file, "utf8"));
99
- } catch (err) {
100
- throw new Error(`irtio deploy: ${file} is not valid JSON: ${String(err)}`);
101
- }
102
- const project = parsed?.project;
103
- if (project === void 0) return void 0;
104
- if (typeof project !== "string" || project.length === 0) {
105
- throw new Error(`irtio deploy: ${file} has a "project" that is not a non-empty string`);
106
- }
107
- return project;
108
- }
109
- async function readIrtioJsonClient(cwd) {
110
- const file = path.join(cwd, "irtio.json");
111
- if (!existsSync(file)) return void 0;
112
- let parsed;
113
- try {
114
- parsed = JSON.parse(await readFile(file, "utf8"));
115
- } catch (err) {
116
- throw new Error(`irtio deploy: ${file} is not valid JSON: ${String(err)}`);
117
- }
118
- const client = parsed?.client;
119
- if (client === void 0) return void 0;
120
- if (typeof client !== "string" || client.length === 0) {
121
- throw new Error(`irtio deploy: ${file} has a "client" that is not a non-empty string`);
122
- }
123
- return client;
124
- }
125
- async function warnIfClientImportsRoom(cwd, entry, log) {
126
- const client = await readIrtioJsonClient(cwd);
151
+ async function warnIfClientImportsRoom(cwd, entry, config, log) {
152
+ const client = config.client;
127
153
  if (client === void 0) return;
128
154
  const clientFile = path.resolve(cwd, client);
129
155
  if (await clientImportsRoom(clientFile, entry)) {
@@ -162,8 +188,9 @@ async function runDeploy(options = {}) {
162
188
  const log = options.log ?? ((line) => console.log(line));
163
189
  const ask = options.ask ?? defaultAsk;
164
190
  const controlUrl = await resolveControlUrlForUser(options.controlUrl);
191
+ const config = await readProjectConfig(cwd, "irtio deploy", options.config);
165
192
  const entry = resolveEntry(cwd, options.room);
166
- await warnIfClientImportsRoom(cwd, entry, log);
193
+ await warnIfClientImportsRoom(cwd, entry, config, log);
167
194
  const outDir = path.join(cwd, DEPLOY_BUILD_DIR);
168
195
  await mkdir(outDir, { recursive: true });
169
196
  let result;
@@ -179,26 +206,19 @@ async function runDeploy(options = {}) {
179
206
  log(pc.green(`bundled ${path.basename(result.file)} (${result.hash.slice(0, 12)})`));
180
207
  const bundled = await loadBundledSchema(result.file);
181
208
  const client = options.client ?? await createApiClient(controlUrl);
182
- const projectId = options.project ?? await readIrtioJsonProject(cwd) ?? bundled.project;
209
+ const projectId = options.project ?? config.project ?? bundled.project;
183
210
  if (projectId === void 0) {
184
211
  throw new Error(
185
212
  'irtio deploy: no project id \u2014 pass --project, add "project" to irtio.json, or set it in defineSchema(...)'
186
213
  );
187
214
  }
188
- let projectExists = true;
189
- try {
190
- await client.get(`/v1/projects/${projectId}`);
191
- } catch (err) {
192
- if (err instanceof ApiClientError && err.status === 404) projectExists = false;
193
- else throw err;
194
- }
195
- if (!projectExists) {
196
- const defaultName = path.basename(cwd);
197
- const answer = (await ask(`project ${projectId} does not exist yet \u2014 name it [${defaultName}]: `)).trim();
198
- const name = answer.length > 0 ? answer : defaultName;
199
- await client.post("/v1/projects", { name, id: projectId });
200
- log(pc.dim(`created project ${projectId} (${name})`));
201
- }
215
+ const ensured = await ensureProject(client, projectId, {
216
+ cwd,
217
+ config,
218
+ command: "irtio deploy",
219
+ ...options.name !== void 0 ? { name: options.name } : {}
220
+ });
221
+ logEnsured(log, projectId, ensured);
202
222
  const deployments = await client.get(`/v1/projects/${projectId}/deployments`);
203
223
  const newestOf = (list) => list.reduce(
204
224
  (best, d) => best === void 0 || d.version > best.version ? d : best,
@@ -339,6 +359,19 @@ async function runDeploy(options = {}) {
339
359
  log(pc.dim(`rooms already running finish on v${previous.version}`));
340
360
  }
341
361
  }
362
+ if (config.static !== void 0 && options.noStatic !== true) {
363
+ const site = await runStaticDeploy({
364
+ cwd,
365
+ project: projectId,
366
+ log,
367
+ client,
368
+ controlUrl,
369
+ dir: config.static.dir,
370
+ ...config.static.label !== void 0 ? { label: config.static.label } : {},
371
+ ...options.config !== void 0 ? { config: options.config } : {}
372
+ });
373
+ return { ...response, site };
374
+ }
342
375
  return response;
343
376
  }
344
377
  var DeployRefusedError = class extends Error {
@@ -352,10 +385,30 @@ async function deploy(args, deps = {}) {
352
385
  const errorLog = deps.errorLog ?? ((line) => console.error(line));
353
386
  try {
354
387
  const parsed = parseDeployArgs(args);
388
+ const cwd = path.resolve(deps.cwd ?? process.cwd());
389
+ const config = await readProjectConfig(cwd, "irtio deploy", parsed.config);
390
+ if (parsed.room === void 0 && parsed.noStatic !== true && config.static !== void 0 && findRoomEntry(cwd, void 0) === void 0) {
391
+ log(pc.dim("no room file \u2014 deploying the static site only"));
392
+ await runStaticDeploy({
393
+ cwd,
394
+ log,
395
+ dir: config.static.dir,
396
+ ...config.static.label !== void 0 ? { label: config.static.label } : {},
397
+ ...parsed.project !== void 0 ? { project: parsed.project } : {},
398
+ ...parsed.name !== void 0 ? { name: parsed.name } : {},
399
+ ...parsed.config !== void 0 ? { config: parsed.config } : {},
400
+ ...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
401
+ ...deps.client !== void 0 ? { client: deps.client } : {}
402
+ });
403
+ return;
404
+ }
355
405
  await runDeploy({
356
406
  allowBreaking: parsed.allowBreaking,
357
407
  log,
358
408
  ...parsed.project !== void 0 ? { project: parsed.project } : {},
409
+ ...parsed.name !== void 0 ? { name: parsed.name } : {},
410
+ ...parsed.config !== void 0 ? { config: parsed.config } : {},
411
+ ...parsed.noStatic !== void 0 ? { noStatic: parsed.noStatic } : {},
359
412
  ...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
360
413
  ...parsed.room !== void 0 ? { room: parsed.room } : {},
361
414
  ...parsed.strategy !== void 0 ? { strategy: parsed.strategy } : {},
@@ -364,6 +417,10 @@ async function deploy(args, deps = {}) {
364
417
  ...deps.irtioPackages !== void 0 ? { irtioPackages: deps.irtioPackages } : {}
365
418
  });
366
419
  } catch (err) {
420
+ if (err instanceof HelpRequested) {
421
+ log(err.usage);
422
+ return;
423
+ }
367
424
  if (err instanceof DeployRefusedError) {
368
425
  process.exitCode = 1;
369
426
  return;
@@ -380,7 +437,9 @@ async function deploy(args, deps = {}) {
380
437
  }
381
438
  export {
382
439
  DeployRefusedError,
440
+ USAGE,
383
441
  deploy,
442
+ findRoomEntry,
384
443
  parseDeployArgs,
385
444
  runDeploy
386
445
  };
@@ -16,11 +16,19 @@ import {
16
16
  pruneSaves,
17
17
  saveKey
18
18
  } from "./chunk-BPE452KF.js";
19
+ import {
20
+ readProjectConfig
21
+ } from "./chunk-32QTPKVT.js";
22
+ import {
23
+ HelpRequested,
24
+ helpFor,
25
+ helpRequested
26
+ } from "./chunk-TV66QHFP.js";
19
27
 
20
28
  // src/dev.ts
21
29
  import { existsSync } from "fs";
22
30
  import { watch } from "fs";
23
- import { mkdir, readFile } from "fs/promises";
31
+ import { mkdir } from "fs/promises";
24
32
  import { createRequire } from "module";
25
33
  import * as path from "path";
26
34
  import { fileURLToPath, pathToFileURL as pathToFileURL3 } from "url";
@@ -43,6 +51,46 @@ var DEFAULT_LIMITS = {
43
51
  saveRetain: DEFAULT_SAVE_RETAIN
44
52
  };
45
53
 
54
+ // ../supervisor/src/codes.ts
55
+ import { randomInt } from "crypto";
56
+ var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
57
+ var ROOM_ID_RE = /^[A-Za-z0-9_-]{1,32}$/;
58
+ var TENANT_ROOM_ID = "__tenant";
59
+ var CODE_SHAPE_RE = /^[A-Za-z0-9]{4,5}$/;
60
+ function pick(n) {
61
+ let out = "";
62
+ for (let i = 0; i < n; i++) out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
63
+ return out;
64
+ }
65
+ function newRoomCode(taken) {
66
+ for (let len = 4; len <= 8; len++) {
67
+ for (let attempt = 0; attempt < 64; attempt++) {
68
+ const code = pick(len);
69
+ if (!taken(code)) return code;
70
+ }
71
+ }
72
+ throw new Error("newRoomCode: exhausted the room code space");
73
+ }
74
+ function newClientId(taken) {
75
+ for (let attempt = 0; attempt < 128; attempt++) {
76
+ const id = `c${pick(10)}`;
77
+ if (!taken(id)) return id;
78
+ }
79
+ throw new Error("newClientId: could not allocate a unique client id");
80
+ }
81
+ function looksLikeRoomCode(id) {
82
+ if (!CODE_SHAPE_RE.test(id)) return false;
83
+ for (const ch of id.toUpperCase()) {
84
+ if (!CODE_ALPHABET.includes(ch)) return false;
85
+ }
86
+ return true;
87
+ }
88
+ function normalizeRoomId(raw) {
89
+ if (!ROOM_ID_RE.test(raw)) return void 0;
90
+ if (raw === TENANT_ROOM_ID) return void 0;
91
+ return looksLikeRoomCode(raw) ? raw.toUpperCase() : raw;
92
+ }
93
+
46
94
  // ../supervisor/src/resilience.ts
47
95
  var SNAPSHOT_FLUSH_MAX_RETRIES = 3;
48
96
  var SNAPSHOT_FLUSH_RETRY_DELAYS_MS = [100, 200, 400];
@@ -409,44 +457,6 @@ function verifyResume(secret, token, now = Date.now()) {
409
457
  return { clientId, roomId, role, expMs };
410
458
  }
411
459
 
412
- // ../supervisor/src/codes.ts
413
- import { randomInt } from "crypto";
414
- var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
415
- var ROOM_ID_RE = /^[A-Za-z0-9_-]{1,32}$/;
416
- var CODE_SHAPE_RE = /^[A-Za-z0-9]{4,5}$/;
417
- function pick(n) {
418
- let out = "";
419
- for (let i = 0; i < n; i++) out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
420
- return out;
421
- }
422
- function newRoomCode(taken) {
423
- for (let len = 4; len <= 8; len++) {
424
- for (let attempt = 0; attempt < 64; attempt++) {
425
- const code = pick(len);
426
- if (!taken(code)) return code;
427
- }
428
- }
429
- throw new Error("newRoomCode: exhausted the room code space");
430
- }
431
- function newClientId(taken) {
432
- for (let attempt = 0; attempt < 128; attempt++) {
433
- const id = `c${pick(10)}`;
434
- if (!taken(id)) return id;
435
- }
436
- throw new Error("newClientId: could not allocate a unique client id");
437
- }
438
- function looksLikeRoomCode(id) {
439
- if (!CODE_SHAPE_RE.test(id)) return false;
440
- for (const ch of id.toUpperCase()) {
441
- if (!CODE_ALPHABET.includes(ch)) return false;
442
- }
443
- return true;
444
- }
445
- function normalizeRoomId(raw) {
446
- if (!ROOM_ID_RE.test(raw)) return void 0;
447
- return looksLikeRoomCode(raw) ? raw.toUpperCase() : raw;
448
- }
449
-
450
460
  // ../supervisor/src/relay.ts
451
461
  import { PRESENCE_COLLECTION, relaySchema } from "@irtio/protocol";
452
462
  import {
@@ -1180,7 +1190,7 @@ var WorkerHost = class {
1180
1190
  }
1181
1191
  }
1182
1192
  onMessage(msg) {
1183
- if ((msg.t === "serialized" || msg.t === "inspected") && this.pending.has(msg.reqId)) {
1193
+ if ((msg.t === "serialized" || msg.t === "inspected" || msg.t === "stats") && msg.reqId !== void 0 && this.pending.has(msg.reqId)) {
1184
1194
  const resolve2 = this.pending.get(msg.reqId);
1185
1195
  this.pending.delete(msg.reqId);
1186
1196
  resolve2?.(msg);
@@ -1251,6 +1261,19 @@ var WorkerHost = class {
1251
1261
  );
1252
1262
  return msg?.t === "inspected" ? msg : void 0;
1253
1263
  }
1264
+ /**
1265
+ * D36: the room's own tick counters — `overruns` and `maxTickMs` above all. `undefined` when
1266
+ * the worker died or did not answer in time, and the caller must report that as *unavailable*
1267
+ * rather than as zero: a room shedding ticks and a room nobody asked look identical otherwise,
1268
+ * and that is exactly the lie the `tick-health` invariant exists to prevent.
1269
+ */
1270
+ async stats(timeoutMs = 2e3) {
1271
+ const msg = await this.request(
1272
+ (reqId) => ({ t: "stats", reqId }),
1273
+ timeoutMs
1274
+ );
1275
+ return msg?.t === "stats" ? msg.stats : void 0;
1276
+ }
1254
1277
  /** Intentional shutdown: `onDead` is not fired. */
1255
1278
  async terminate() {
1256
1279
  if (this.disposed) return;
@@ -1370,6 +1393,11 @@ var SupervisorImpl = class {
1370
1393
  limits;
1371
1394
  store;
1372
1395
  processMetrics = newProcessMetrics();
1396
+ /**
1397
+ * D38: the ring behind the reserved `__tenant` room id. Same bounded ring every room gets, so
1398
+ * a noisy boot costs the same as a noisy room and nothing here can grow without limit.
1399
+ */
1400
+ tenantLogs = new LogRing();
1373
1401
  /** The newest runnable deployment; every new room is created under it. */
1374
1402
  bundle;
1375
1403
  schemaHash;
@@ -1601,13 +1629,31 @@ var SupervisorImpl = class {
1601
1629
  // -------------------------------------------------------------------------
1602
1630
  // Logging
1603
1631
  // -------------------------------------------------------------------------
1632
+ /**
1633
+ * D38: a tenant-level event now enters the log pipeline instead of only reaching the console.
1634
+ *
1635
+ * `/admin/logs` reads per-room rings, so before this every event that was not about a room —
1636
+ * boot, resume, deploy, placement, alarm recovery, storage failures — was invisible to the host
1637
+ * agent, to the control plane, and to `irtio logs`. Those are precisely the lines somebody
1638
+ * reaches for when a project is not working, and the only way to see them was to be on the box
1639
+ * reading the guest console.
1640
+ *
1641
+ * They go into a ring under the reserved id `__tenant` (D38), so the whole existing pipeline
1642
+ * carries them with no new route and no new frame.
1643
+ */
1604
1644
  log(level, ...args) {
1645
+ this.tenantLogs.push(level, args);
1646
+ this.emit(level, args);
1647
+ }
1648
+ /** The console/config sink, with no ring behind it. */
1649
+ emit(level, args) {
1605
1650
  if (this.config.log) this.config.log(level, ...args);
1606
1651
  else console[level === "info" ? "log" : level]("[irtio]", ...args);
1607
1652
  }
1653
+ /** A room event: it belongs in that room's ring, and nowhere near the `__tenant` one. */
1608
1654
  roomLog(room, level, ...args) {
1609
1655
  room.log(level, ...args);
1610
- this.log(level, `[${room.id}]`, ...args);
1656
+ this.emit(level, [`[${room.id}]`, ...args]);
1611
1657
  }
1612
1658
  /**
1613
1659
  * Week-5 snapshot keys carry the deployment version (`@v3`); version `0` keeps the week-3 key
@@ -1654,11 +1700,18 @@ var SupervisorImpl = class {
1654
1700
  json({ rooms: this.rooms(), deploymentVersion: this.deploymentVersion });
1655
1701
  return true;
1656
1702
  case "/admin/metrics":
1657
- json({
1658
- at: Date.now(),
1659
- metrics: this.metrics(),
1660
- rooms: this.rooms().map((r) => r.metrics)
1661
- });
1703
+ void this.refreshTickHealth().then(
1704
+ () => json({
1705
+ at: Date.now(),
1706
+ metrics: this.metrics(),
1707
+ rooms: this.rooms().map((r) => r.metrics)
1708
+ }),
1709
+ () => json({
1710
+ at: Date.now(),
1711
+ metrics: this.metrics(),
1712
+ rooms: this.rooms().map((r) => r.metrics)
1713
+ })
1714
+ );
1662
1715
  return true;
1663
1716
  // D31 (week 7): the ONE write on this otherwise read-only surface, and a deliberate
1664
1717
  // exception to its "the agent must not be able to change anything" rule: after a
@@ -1800,13 +1853,14 @@ var SupervisorImpl = class {
1800
1853
  const since = Number(url.searchParams.get("since") ?? 0) || 0;
1801
1854
  const limit = Math.min(Number(url.searchParams.get("limit") ?? 200) || 200, 1e3);
1802
1855
  const entries = [];
1803
- for (const room of this.registry.values()) {
1804
- for (const entry of this.logs(room.id)) {
1856
+ const streams = [TENANT_ROOM_ID, ...this.registry.values().map((r) => r.id)];
1857
+ for (const roomId of streams) {
1858
+ for (const entry of this.logs(roomId)) {
1805
1859
  if (entry.at <= since) continue;
1806
1860
  entries.push({
1807
1861
  at: entry.at,
1808
1862
  level: entry.level,
1809
- roomId: room.id,
1863
+ roomId,
1810
1864
  message: entry.args.map((x) => typeof x === "string" ? x : String(x)).join(" ")
1811
1865
  });
1812
1866
  }
@@ -3127,8 +3181,35 @@ var SupervisorImpl = class {
3127
3181
  };
3128
3182
  }
3129
3183
  logs(roomId) {
3184
+ if (roomId === TENANT_ROOM_ID) return this.tenantLogs.list();
3130
3185
  return this.registry.get(roomId)?.logs.list() ?? [];
3131
3186
  }
3187
+ /**
3188
+ * D36: pull every live room's tick counters out of its worker and into `RoomMetrics`.
3189
+ *
3190
+ * The counters exist already — `RoomCore.stats.overruns` has been incremented next to the
3191
+ * `tick overrun` log line since week 2 — but they never left the worker thread, so the one
3192
+ * signal that says "this room is shedding work" reached nothing a client, a CLI or an operator
3193
+ * could read. Every `overruns` a report shows now came through here.
3194
+ *
3195
+ * A room that does not answer is left exactly as it was, with `overruns` still `undefined` if
3196
+ * it has never answered. That is the whole point of the field being optional: `tick-health`
3197
+ * reports `unavailable` for it, rather than a zero nobody measured.
3198
+ */
3199
+ async refreshTickHealth(timeoutMs = 2e3) {
3200
+ const live = this.registry.values().filter((r) => r.worker?.alive === true);
3201
+ const answered = await Promise.all(
3202
+ live.map(async (room) => {
3203
+ const stats = await room.worker?.stats(timeoutMs);
3204
+ if (!stats) return void 0;
3205
+ room.metrics.ticks = stats.ticks;
3206
+ room.metrics.maxTickMs = stats.maxTickMs;
3207
+ room.metrics.overruns = stats.overruns;
3208
+ return room.id;
3209
+ })
3210
+ );
3211
+ return answered.filter((id) => id !== void 0);
3212
+ }
3132
3213
  async inspect(roomId) {
3133
3214
  const room = this.registry.get(roomId);
3134
3215
  if (!room) return void 0;
@@ -3402,40 +3483,8 @@ function resolveEntry(cwd, room) {
3402
3483
  create one (export default defineRoom(schema, {...})) or pass --room <file>`
3403
3484
  );
3404
3485
  }
3405
- async function readProjectId(cwd) {
3406
- const file = path.join(cwd, "irtio.json");
3407
- if (!existsSync(file)) return "dev";
3408
- let parsed;
3409
- try {
3410
- parsed = JSON.parse(await readFile(file, "utf8"));
3411
- } catch (err) {
3412
- throw new Error(`irtio dev: ${file} is not valid JSON: ${String(err)}`);
3413
- }
3414
- const project = parsed?.project;
3415
- if (project === void 0) return "dev";
3416
- if (typeof project !== "string" || project.length === 0) {
3417
- throw new Error(`irtio dev: ${file} has a "project" that is not a non-empty string`);
3418
- }
3419
- return project;
3420
- }
3421
- async function readClientEntry(cwd) {
3422
- const file = path.join(cwd, "irtio.json");
3423
- if (!existsSync(file)) return void 0;
3424
- let parsed;
3425
- try {
3426
- parsed = JSON.parse(await readFile(file, "utf8"));
3427
- } catch (err) {
3428
- throw new Error(`irtio dev: ${file} is not valid JSON: ${String(err)}`);
3429
- }
3430
- const client = parsed?.client;
3431
- if (client === void 0) return void 0;
3432
- if (typeof client !== "string" || client.length === 0) {
3433
- throw new Error(`irtio dev: ${file} has a "client" that is not a non-empty string`);
3434
- }
3435
- return client;
3436
- }
3437
- async function warnIfClientImportsRoom(cwd, entry, log) {
3438
- const client = await readClientEntry(cwd);
3486
+ async function warnIfClientImportsRoom(cwd, entry, config, log) {
3487
+ const client = config.client;
3439
3488
  if (client === void 0) return;
3440
3489
  const clientFile = path.resolve(cwd, client);
3441
3490
  if (await clientImportsRoom(clientFile, entry)) {
@@ -3508,13 +3557,14 @@ async function resolveWorkerEntry2(outDir) {
3508
3557
  return outfile;
3509
3558
  }
3510
3559
  async function buildState(supervisor, projectId, bundle) {
3560
+ await supervisor.refreshTickHealth().catch(() => []);
3511
3561
  const infos = supervisor.rooms();
3512
3562
  const inspects = await Promise.all(
3513
3563
  infos.map((room) => supervisor.inspect(room.id).catch(() => void 0))
3514
3564
  );
3515
3565
  const logs = [];
3516
- for (const room of infos) {
3517
- for (const entry of supervisor.logs(room.id)) logs.push({ ...entry, roomId: room.id });
3566
+ for (const roomId of [TENANT_ROOM_ID, ...infos.map((r) => r.id)]) {
3567
+ for (const entry of supervisor.logs(roomId)) logs.push({ ...entry, roomId });
3518
3568
  }
3519
3569
  logs.sort((a, b) => a.at - b.at);
3520
3570
  return {
@@ -3550,11 +3600,12 @@ async function startDev(options = {}) {
3550
3600
  const cwd = path.resolve(options.cwd ?? process.cwd());
3551
3601
  const log = options.log ?? ((line) => console.log(line));
3552
3602
  const entry = resolveEntry(cwd, options.room);
3553
- const projectId = await readProjectId(cwd);
3603
+ const config = await readProjectConfig(cwd, "irtio dev", options.config);
3604
+ const projectId = config.project ?? "dev";
3554
3605
  const outDir = path.join(cwd, ".irtio", "dev");
3555
3606
  const port = options.port ?? DEFAULT_PORT;
3556
3607
  await mkdir(outDir, { recursive: true });
3557
- await warnIfClientImportsRoom(cwd, entry, log);
3608
+ await warnIfClientImportsRoom(cwd, entry, config, log);
3558
3609
  const bundleOptions = {
3559
3610
  entry,
3560
3611
  outDir,
@@ -3685,7 +3736,20 @@ async function startDev(options = {}) {
3685
3736
  }
3686
3737
  };
3687
3738
  }
3739
+ var USAGE = `usage: irtio dev [options]
3740
+
3741
+ Bundles the room file, runs a real supervisor on it locally, and serves the inspector on the same
3742
+ port. Rebuilds and resets the rooms whenever the room code changes.
3743
+
3744
+ options:
3745
+ --room <file> the room entry (default: irtio/room.ts, irtio/room.js, room.ts)
3746
+ --port <n> port to listen on (default 7070; 0 picks a free one)
3747
+ --no-watch do not rebuild on change
3748
+ -c, --config <f> the project file to read (default irtio.json)
3749
+ -h, --help print this
3750
+ `;
3688
3751
  function parseDevArgs(args) {
3752
+ if (helpRequested(args)) throw helpFor(USAGE);
3689
3753
  const parsed = { watch: true };
3690
3754
  for (let i = 0; i < args.length; i++) {
3691
3755
  const arg = args[i];
@@ -3712,6 +3776,10 @@ function parseDevArgs(args) {
3712
3776
  case "--no-watch":
3713
3777
  parsed.watch = false;
3714
3778
  break;
3779
+ case "-c":
3780
+ case "--config":
3781
+ parsed.config = value();
3782
+ break;
3715
3783
  default:
3716
3784
  throw new Error(`irtio dev: unknown option ${JSON.stringify(arg)}`);
3717
3785
  }
@@ -3719,15 +3787,21 @@ function parseDevArgs(args) {
3719
3787
  return parsed;
3720
3788
  }
3721
3789
  async function dev(args) {
3722
- const parsed = parseDevArgs(args);
3790
+ let parsed;
3723
3791
  let server;
3724
3792
  try {
3793
+ parsed = parseDevArgs(args);
3725
3794
  server = await startDev({
3726
3795
  watch: parsed.watch,
3727
3796
  ...parsed.room !== void 0 ? { room: parsed.room } : {},
3728
- ...parsed.port !== void 0 ? { port: parsed.port } : {}
3797
+ ...parsed.port !== void 0 ? { port: parsed.port } : {},
3798
+ ...parsed.config !== void 0 ? { config: parsed.config } : {}
3729
3799
  });
3730
3800
  } catch (err) {
3801
+ if (err instanceof HelpRequested) {
3802
+ console.log(err.usage);
3803
+ return;
3804
+ }
3731
3805
  console.error(pc.red(err instanceof Error ? err.message : String(err)));
3732
3806
  process.exitCode = 1;
3733
3807
  return;
@@ -3754,6 +3828,7 @@ async function dev(args) {
3754
3828
  });
3755
3829
  }
3756
3830
  export {
3831
+ USAGE,
3757
3832
  dev,
3758
3833
  parseDevArgs,
3759
3834
  startDev