@irtio/cli 0.1.0 → 0.2.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/dist/bundle.d.ts CHANGED
@@ -5,6 +5,14 @@
5
5
  */
6
6
  /** Bare imports room code may use. Everything else (including node:*) is rejected. */
7
7
  declare const ALLOWED_IMPORTS: string[];
8
+ /**
9
+ * D22: the one blessed engine. Unlike the irtio packages it is **not** inlined into the room
10
+ * bundle — the tenant's runtime already loaded and `init()`ed it, and a second bundled copy would
11
+ * be a second WASM instance with its own worlds. Left external, room code's
12
+ * `import RAPIER from '@dimforge/rapier3d-compat'` resolves at load time to the very module the
13
+ * runtime initialized.
14
+ */
15
+ declare const EXTERNAL_IMPORTS: readonly string[];
8
16
  interface BundleOptions {
9
17
  /** The room entry file (must `export default defineRoom(...)`). */
10
18
  readonly entry: string;
@@ -33,4 +41,4 @@ declare class BundleError extends Error {
33
41
  /** Bundles the room entry; throws `BundleError` with the esbuild message on failure. */
34
42
  declare function bundleRoom(options: BundleOptions): Promise<BundleResult>;
35
43
 
36
- export { ALLOWED_IMPORTS, BundleError, type BundleOptions, type BundleResult, bundleRoom };
44
+ export { ALLOWED_IMPORTS, BundleError, type BundleOptions, type BundleResult, EXTERNAL_IMPORTS, bundleRoom };
package/dist/bundle.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  ALLOWED_IMPORTS,
3
3
  BundleError,
4
+ EXTERNAL_IMPORTS,
4
5
  bundleRoom
5
- } from "./chunk-ZWCLCYCS.js";
6
+ } from "./chunk-KRQUAEN2.js";
6
7
  export {
7
8
  ALLOWED_IMPORTS,
8
9
  BundleError,
10
+ EXTERNAL_IMPORTS,
9
11
  bundleRoom
10
12
  };
@@ -0,0 +1,180 @@
1
+ // ../store/dist/index.js
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "fs/promises";
3
+ import * as path from "path";
4
+ var KV_LIMITS = {
5
+ /** Max UTF-8 bytes in one value. */
6
+ valueBytes: 16 * 1024,
7
+ /** Max UTF-8 bytes in one key. */
8
+ keyBytes: 256,
9
+ /** Max UTF-8 bytes in one player id. */
10
+ playerIdBytes: 256,
11
+ /** Max distinct keys one player may hold within one project. */
12
+ keysPerPlayer: 128,
13
+ /** Max rows one project may hold across all its players. */
14
+ rowsPerProject: 1e6
15
+ };
16
+ var PLAYER_ISSUER_RE = /^[a-z0-9._-]{1,64}$/;
17
+ var KV_ERRORS = {
18
+ badKey: "E_KV_BAD_KEY",
19
+ badPlayer: "E_KV_BAD_PLAYER",
20
+ valueTooLarge: "E_KV_VALUE_TOO_LARGE",
21
+ tooManyKeys: "E_KV_TOO_MANY_KEYS",
22
+ projectFull: "E_KV_PROJECT_FULL",
23
+ forbidden: "E_KV_FORBIDDEN",
24
+ unavailable: "E_KV_UNAVAILABLE"
25
+ };
26
+ var SAVES_SEGMENT = "/saves/";
27
+ var SAVE_ID_TIME_DIGITS = 17;
28
+ var DEFAULT_SAVE_RETAIN = 10;
29
+ var PRE_MIGRATION_SAVE_ID = "premigrate";
30
+ function mintSaveId(nowMs, rand) {
31
+ const t = Math.max(0, Math.floor(nowMs));
32
+ const suffix = (Math.floor(Math.abs(rand)) & 65535).toString(16).padStart(4, "0");
33
+ return `${String(t).padStart(SAVE_ID_TIME_DIGITS, "0")}-${suffix}`;
34
+ }
35
+ function saveIdCreatedAt(saveId) {
36
+ if (saveId.length !== SAVE_ID_TIME_DIGITS + 5) return void 0;
37
+ if (saveId[SAVE_ID_TIME_DIGITS] !== "-") return void 0;
38
+ const time = saveId.slice(0, SAVE_ID_TIME_DIGITS);
39
+ if (!/^[0-9]+$/.test(time)) return void 0;
40
+ if (!/^[0-9a-f]{4}$/.test(saveId.slice(SAVE_ID_TIME_DIGITS + 1))) return void 0;
41
+ return Number(time);
42
+ }
43
+ function savesPrefix(liveKey) {
44
+ return `${liveKey}${SAVES_SEGMENT}`;
45
+ }
46
+ function saveKey(liveKey, saveId) {
47
+ return `${savesPrefix(liveKey)}${saveId}`;
48
+ }
49
+ function saveIdOf(liveKey, key) {
50
+ const prefix = savesPrefix(liveKey);
51
+ if (!key.startsWith(prefix)) return void 0;
52
+ const rest = key.slice(prefix.length);
53
+ if (rest === "" || rest.includes("/")) return void 0;
54
+ return rest;
55
+ }
56
+ async function listSaves(store, liveKey) {
57
+ const keys = await store.list(savesPrefix(liveKey));
58
+ const out = [];
59
+ for (const key of keys) {
60
+ const saveId = saveIdOf(liveKey, key);
61
+ if (saveId === void 0) continue;
62
+ const createdAt = saveIdCreatedAt(saveId);
63
+ if (createdAt === void 0) continue;
64
+ out.push({ saveId, key, createdAt });
65
+ }
66
+ out.sort((a, b) => a.saveId < b.saveId ? 1 : a.saveId > b.saveId ? -1 : 0);
67
+ return out;
68
+ }
69
+ function selectForPruning(saves, retain) {
70
+ const keep = Number.isFinite(retain) ? Math.max(1, Math.floor(retain)) : 1;
71
+ return saves.length <= keep ? [] : saves.slice(keep);
72
+ }
73
+ async function pruneSaves(store, saves, retain, log) {
74
+ const doomed = selectForPruning(saves, retain);
75
+ let deleted = 0;
76
+ for (const save of doomed) {
77
+ try {
78
+ await store.delete(save.key);
79
+ deleted++;
80
+ } catch (err) {
81
+ log("warn", `save retention: could not delete ${save.key}`, err);
82
+ }
83
+ }
84
+ return deleted;
85
+ }
86
+ var STATIC_LIMITS = {
87
+ /** Bytes per file. Big enough for a wasm build or a texture atlas; a video does not belong. */
88
+ maxFileBytes: 32 * 1024 * 1024,
89
+ /** Files per deploy. */
90
+ maxFiles: 2e3,
91
+ /** Total bytes per deploy. */
92
+ maxTotalBytes: 256 * 1024 * 1024
93
+ };
94
+ function staticPathProblem(path2) {
95
+ if (path2.length === 0 || path2.length > 512) return "path must be 1-512 characters";
96
+ if (path2.startsWith("/")) return "path must be relative (no leading slash)";
97
+ if (path2.includes("\\")) return "path must use forward slashes";
98
+ if (/[\x00-\x1f\x7f]/.test(path2)) return "path must not contain control characters";
99
+ for (const segment of path2.split("/")) {
100
+ if (segment === "" || segment === "." || segment === "..") {
101
+ return "path segments must be non-empty and not . or ..";
102
+ }
103
+ }
104
+ return void 0;
105
+ }
106
+ var DiskStore = class {
107
+ constructor(dir) {
108
+ this.dir = dir;
109
+ }
110
+ dir;
111
+ fileFor(key) {
112
+ if (!/^[A-Za-z0-9_.@/-]+$/.test(key) || key.includes("..")) {
113
+ throw new Error(`invalid object store key ${JSON.stringify(key)}`);
114
+ }
115
+ return path.join(this.dir, `${key}.snap`);
116
+ }
117
+ async put(key, bytes) {
118
+ const file = this.fileFor(key);
119
+ await mkdir(path.dirname(file), { recursive: true });
120
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
121
+ await writeFile(tmp, bytes);
122
+ for (let attempt = 0; ; attempt++) {
123
+ try {
124
+ await rename(tmp, file);
125
+ return;
126
+ } catch (err) {
127
+ const code = err.code;
128
+ if ((code === "EPERM" || code === "EBUSY") && attempt < 10) {
129
+ await new Promise((resolve) => setTimeout(resolve, 5 * (attempt + 1)));
130
+ continue;
131
+ }
132
+ throw err;
133
+ }
134
+ }
135
+ }
136
+ async get(key) {
137
+ try {
138
+ return new Uint8Array(await readFile(this.fileFor(key)));
139
+ } catch (err) {
140
+ if (err.code === "ENOENT") return void 0;
141
+ throw err;
142
+ }
143
+ }
144
+ async delete(key) {
145
+ await rm(this.fileFor(key), { force: true });
146
+ }
147
+ async list(prefix) {
148
+ const out = [];
149
+ const walk = async (dir, rel) => {
150
+ let entries;
151
+ try {
152
+ entries = await readdir(dir, { withFileTypes: true });
153
+ } catch (err) {
154
+ if (err.code === "ENOENT") return;
155
+ throw err;
156
+ }
157
+ for (const e of entries) {
158
+ const r = rel ? `${rel}/${e.name}` : e.name;
159
+ if (e.isDirectory()) await walk(path.join(dir, e.name), r);
160
+ else if (e.name.endsWith(".snap")) out.push(r.slice(0, -".snap".length));
161
+ }
162
+ };
163
+ await walk(this.dir, "");
164
+ return out.filter((k) => k.startsWith(prefix)).sort();
165
+ }
166
+ };
167
+
168
+ export {
169
+ PLAYER_ISSUER_RE,
170
+ KV_ERRORS,
171
+ DEFAULT_SAVE_RETAIN,
172
+ PRE_MIGRATION_SAVE_ID,
173
+ mintSaveId,
174
+ saveKey,
175
+ listSaves,
176
+ pruneSaves,
177
+ STATIC_LIMITS,
178
+ staticPathProblem,
179
+ DiskStore
180
+ };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  readCredential
3
- } from "./chunk-UYN5PWLT.js";
3
+ } from "./chunk-NVUKSP5U.js";
4
4
 
5
5
  // src/api-client.ts
6
6
  var ApiClientError = class extends Error {
@@ -1,11 +1,12 @@
1
1
  // src/bundle.ts
2
- import { createHash } from "crypto";
3
- import { mkdir, writeFile } from "fs/promises";
2
+ import { createHash, randomBytes } from "crypto";
3
+ import { access, mkdir, rename, unlink, writeFile } from "fs/promises";
4
4
  import * as path from "path";
5
5
  import { pathToFileURL } from "url";
6
6
  import { Worker } from "worker_threads";
7
7
  import * as esbuild from "esbuild";
8
- var ALLOWED_IMPORTS = ["@irtio/server", "@irtio/schema"];
8
+ var ALLOWED_IMPORTS = ["@irtio/server", "@irtio/schema", "@dimforge/rapier3d-compat"];
9
+ var EXTERNAL_IMPORTS = ["@dimforge/rapier3d-compat"];
9
10
  var BundleError = class extends Error {
10
11
  name = "BundleError";
11
12
  };
@@ -17,6 +18,7 @@ function importMenuPlugin(allowed) {
17
18
  if (args.kind === "entry-point" || path.isAbsolute(args.path)) return null;
18
19
  const root = args.path.split("/")[0].startsWith("@") ? args.path.split("/").slice(0, 2).join("/") : args.path.split("/")[0];
19
20
  if (allowed.has(root)) {
21
+ if (EXTERNAL_IMPORTS.includes(root)) return { path: args.path, external: true };
20
22
  const redirect = allowed.get(root);
21
23
  if (redirect) return { path: redirect };
22
24
  return null;
@@ -71,6 +73,7 @@ async function bundleRoom(options) {
71
73
  write: false,
72
74
  outdir: options.outDir,
73
75
  plugins: [importMenuPlugin(allowed)],
76
+ metafile: true,
74
77
  logLevel: "silent",
75
78
  mainFields: ["module", "main"],
76
79
  conditions: ["import"]
@@ -82,14 +85,34 @@ async function bundleRoom(options) {
82
85
  const out = result.outputFiles[0];
83
86
  if (!out) throw new BundleError("esbuild produced no output");
84
87
  const warnings = result.warnings.map((w) => w.text);
88
+ const importsRapier = Object.values(result.metafile?.outputs ?? {}).some(
89
+ (o) => o.imports.some((i) => i.path === "@dimforge/rapier3d-compat")
90
+ );
85
91
  const hash = createHash("sha256").update(out.contents).digest("hex");
86
92
  const file = path.join(options.outDir, `room.${hash.slice(0, 16)}.mjs`);
87
93
  await mkdir(options.outDir, { recursive: true });
88
94
  const banner = `// irtio room bundle ${hash}
89
95
  `;
90
- await writeFile(file, banner + out.text);
96
+ const tmp = path.join(
97
+ options.outDir,
98
+ `.room.${hash.slice(0, 16)}.${randomBytes(6).toString("hex")}.tmp`
99
+ );
100
+ await writeFile(tmp, banner + out.text);
101
+ try {
102
+ await rename(tmp, file);
103
+ } catch (err) {
104
+ const alreadyThere = await access(file).then(() => true).catch(() => false);
105
+ await unlink(tmp).catch(() => {
106
+ });
107
+ if (!alreadyThere) throw err;
108
+ }
91
109
  if (options.verify === false) return { file, hash, warnings };
92
110
  const verified = await verifyBundle(file);
111
+ if (importsRapier && !verified.physics) {
112
+ throw new BundleError(
113
+ "irtio: this room imports @dimforge/rapier3d-compat but declares no physics. Add\n physics: { engine: 'rapier3d', gravity: { x: 0, y: -9.81, z: 0 }, bodies: { ... } }\nto defineRoom(...), and mark the body-backed fields with the schema's physics option. Without it the runtime never creates a world, and nothing you build with the engine steps."
114
+ );
115
+ }
93
116
  const fullHash = createHash("sha256").update(out.contents).update(verified.canonical).digest("hex");
94
117
  return {
95
118
  file,
@@ -110,7 +133,7 @@ async function verifyBundle(file) {
110
133
  parentPort.postMessage({ ok: false, reason:
111
134
  'the bundle\\'s default export is not a room \u2014 end your room file with: export default defineRoom(schema, {...})' });
112
135
  } else {
113
- parentPort.postMessage({ ok: true, schemaHash: def.schema.hash, canonical: def.schema.canonical, mode: def.config.mode });
136
+ parentPort.postMessage({ ok: true, schemaHash: def.schema.hash, canonical: def.schema.canonical, mode: def.config.mode, physics: def.config.physics !== undefined });
114
137
  }
115
138
  } catch (err) {
116
139
  parentPort.postMessage({ ok: false, reason: String(err && err.stack || err) });
@@ -133,7 +156,12 @@ async function verifyBundle(file) {
133
156
  });
134
157
  });
135
158
  if (!msg.ok) throw new BundleError(`bundle failed to load: ${msg.reason}`);
136
- return { schemaHash: msg.schemaHash, canonical: msg.canonical, mode: msg.mode };
159
+ return {
160
+ schemaHash: msg.schemaHash,
161
+ canonical: msg.canonical,
162
+ mode: msg.mode,
163
+ physics: msg.physics === true
164
+ };
137
165
  } finally {
138
166
  await worker.terminate();
139
167
  }
@@ -141,6 +169,7 @@ async function verifyBundle(file) {
141
169
 
142
170
  export {
143
171
  ALLOWED_IMPORTS,
172
+ EXTERNAL_IMPORTS,
144
173
  BundleError,
145
174
  bundleRoom
146
175
  };
@@ -46,7 +46,9 @@ async function resolveControlUrlForUser(flag) {
46
46
  const explicit = flag ?? process.env.IRT_CONTROL_URL;
47
47
  if (explicit !== void 0) return explicit;
48
48
  const all = await readCredentials();
49
- const urls = Object.keys(all);
49
+ const now = Date.now();
50
+ const live = Object.entries(all).filter(([, cred]) => new Date(cred.expiresAt).getTime() > now).map(([url]) => url);
51
+ const urls = live.length > 0 ? live : Object.keys(all);
50
52
  return urls.length === 1 ? urls[0] : DEFAULT_CONTROL_URL;
51
53
  }
52
54
 
@@ -0,0 +1,36 @@
1
+ // src/client-graph.ts
2
+ import * as path from "path";
3
+ import * as esbuild from "esbuild";
4
+ function externalizeBarePlugin() {
5
+ return {
6
+ name: "irtio-externalize-bare",
7
+ setup(build2) {
8
+ build2.onResolve({ filter: /^[^./]/ }, (args) => {
9
+ if (args.kind === "entry-point" || path.isAbsolute(args.path)) return null;
10
+ return { path: args.path, external: true };
11
+ });
12
+ }
13
+ };
14
+ }
15
+ async function clientImportsRoom(clientEntry, roomEntry) {
16
+ let result;
17
+ try {
18
+ result = await esbuild.build({
19
+ entryPoints: [clientEntry],
20
+ bundle: true,
21
+ write: false,
22
+ metafile: true,
23
+ logLevel: "silent",
24
+ platform: "neutral",
25
+ plugins: [externalizeBarePlugin()]
26
+ });
27
+ } catch {
28
+ return false;
29
+ }
30
+ const roomAbs = path.resolve(roomEntry);
31
+ return Object.keys(result.metafile.inputs).some((input) => path.resolve(input) === roomAbs);
32
+ }
33
+
34
+ export {
35
+ clientImportsRoom
36
+ };
@@ -1,19 +1,21 @@
1
+ import {
2
+ clientImportsRoom
3
+ } from "./chunk-TQU6345E.js";
1
4
  import {
2
5
  bundleRoom
3
- } from "./chunk-ZWCLCYCS.js";
6
+ } from "./chunk-KRQUAEN2.js";
4
7
  import {
5
8
  ApiClientError,
6
9
  createApiClient,
7
10
  isLoginRequired
8
- } from "./chunk-J3G6AUJY.js";
11
+ } from "./chunk-I37DLT7K.js";
9
12
  import {
10
13
  resolveControlUrlForUser
11
- } from "./chunk-UYN5PWLT.js";
14
+ } from "./chunk-NVUKSP5U.js";
12
15
 
13
16
  // src/deploy.ts
14
17
  import { existsSync } from "fs";
15
- import { mkdtemp, readFile, readdir, rm } from "fs/promises";
16
- import { tmpdir } from "os";
18
+ import { mkdir, readFile, readdir, rm } from "fs/promises";
17
19
  import * as path from "path";
18
20
  import { createInterface } from "readline/promises";
19
21
  import { pathToFileURL } from "url";
@@ -49,6 +51,14 @@ function parseDeployArgs(args) {
49
51
  parsed.room = value;
50
52
  break;
51
53
  }
54
+ case "--strategy": {
55
+ const value = args[++i];
56
+ if (value !== "drain" && value !== "migrate") {
57
+ throw new Error('irtio deploy: --strategy must be "drain" or "migrate"');
58
+ }
59
+ parsed.strategy = value;
60
+ break;
61
+ }
52
62
  default:
53
63
  throw new Error(`irtio deploy: unknown option ${JSON.stringify(arg)}`);
54
64
  }
@@ -96,6 +106,34 @@ async function readIrtioJsonProject(cwd) {
96
106
  }
97
107
  return project;
98
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);
127
+ if (client === void 0) return;
128
+ const clientFile = path.resolve(cwd, client);
129
+ if (await clientImportsRoom(clientFile, entry)) {
130
+ log(
131
+ pc.yellow(
132
+ `irtio: WARNING: the client entry ${client} imports the room file ${path.relative(cwd, entry)} \u2014 room code must never ship to clients; move shared code (e.g. the physics world builder) into its own module both sides import`
133
+ )
134
+ );
135
+ }
136
+ }
99
137
  async function loadBundledSchema(file) {
100
138
  const mod = await import(pathToFileURL(file).href);
101
139
  return {
@@ -118,13 +156,16 @@ async function findMigrationFile(cwd, version) {
118
156
  const match = entries.find((f) => f.startsWith(`${version}_`) && f.endsWith(".ts"));
119
157
  return match ? path.join(dir, match) : void 0;
120
158
  }
159
+ var DEPLOY_BUILD_DIR = path.join(".irtio", "deploy");
121
160
  async function runDeploy(options = {}) {
122
161
  const cwd = path.resolve(options.cwd ?? process.cwd());
123
162
  const log = options.log ?? ((line) => console.log(line));
124
163
  const ask = options.ask ?? defaultAsk;
125
164
  const controlUrl = await resolveControlUrlForUser(options.controlUrl);
126
165
  const entry = resolveEntry(cwd, options.room);
127
- const outDir = await mkdtemp(path.join(tmpdir(), "irtio-deploy-"));
166
+ await warnIfClientImportsRoom(cwd, entry, log);
167
+ const outDir = path.join(cwd, DEPLOY_BUILD_DIR);
168
+ await mkdir(outDir, { recursive: true });
128
169
  let result;
129
170
  try {
130
171
  result = await bundleRoom({
@@ -158,16 +199,16 @@ async function runDeploy(options = {}) {
158
199
  await client.post("/v1/projects", { name, id: projectId });
159
200
  log(pc.dim(`created project ${projectId} (${name})`));
160
201
  }
161
- const deployments = await client.get(
162
- `/v1/projects/${projectId}/deployments`
163
- );
164
- const previous = deployments.reduce(
202
+ const deployments = await client.get(`/v1/projects/${projectId}/deployments`);
203
+ const newestOf = (list) => list.reduce(
165
204
  (best, d) => best === void 0 || d.version > best.version ? d : best,
166
205
  void 0
167
206
  );
207
+ const previous = newestOf(deployments.filter((d) => d.rolledBackAt == null));
208
+ const newest = newestOf(deployments);
168
209
  let allowBreaking = options.allowBreaking ?? false;
169
210
  let migrationFile;
170
- const nextVersion = (previous?.version ?? 0) + 1;
211
+ const nextVersion = (newest?.version ?? 0) + 1;
171
212
  if (previous !== void 0) {
172
213
  const oldSchema = schemaFromCanonical(previous.schemaJson);
173
214
  const changes = diffSchemas(oldSchema, bundled.schema);
@@ -227,6 +268,20 @@ async function runDeploy(options = {}) {
227
268
  const answer = (await ask("production origin (localhost is always allowed) \u2014 leave blank to skip: ")).trim();
228
269
  if (answer.length > 0) origin = answer;
229
270
  }
271
+ const strategy = options.strategy ?? "drain";
272
+ if (strategy === "migrate") {
273
+ log(pc.yellow("strategy: migrate \u2014 live rooms move onto the new version now."));
274
+ log(
275
+ pc.yellow(
276
+ "Schema unchanged: connected clients see a short gap and a resync on the same socket."
277
+ )
278
+ );
279
+ log(
280
+ pc.yellow(
281
+ "Schema changed at all: EVERY connected client is disconnected with E_SCHEMA_MISMATCH and must reload."
282
+ )
283
+ );
284
+ }
230
285
  let response;
231
286
  try {
232
287
  const created = await client.post(`/v1/projects/${projectId}/deployments`, {
@@ -236,13 +291,16 @@ async function runDeploy(options = {}) {
236
291
  schemaHash: result.schemaHash,
237
292
  ...migrationKey !== void 0 ? { migrationKey } : {},
238
293
  ...allowBreaking ? { allowBreaking: true } : {},
239
- ...origin !== void 0 ? { origin } : {}
294
+ ...origin !== void 0 ? { origin } : {},
295
+ ...strategy !== "drain" ? { strategy } : {}
240
296
  });
241
297
  response = {
242
298
  project: projectId,
243
299
  version: created.version,
244
300
  url: created.url,
245
- draining: created.draining
301
+ draining: created.draining,
302
+ ...created.applied !== void 0 ? { applied: created.applied } : {},
303
+ ...created.rooms !== void 0 ? { rooms: created.rooms } : {}
246
304
  };
247
305
  } catch (err) {
248
306
  if (err instanceof ApiClientError && err.code === "E_BREAKING_SCHEMA" && err.changes) {
@@ -259,8 +317,27 @@ async function runDeploy(options = {}) {
259
317
  });
260
318
  log(pc.green(`deployed v${response.version}`));
261
319
  log(pc.dim(`reachable at ${response.url}`));
262
- if (previous !== void 0) {
263
- log(pc.dim(`rooms already running finish on v${previous.version}`));
320
+ if (strategy === "migrate") {
321
+ if (response.applied === "live") {
322
+ const rooms = response.rooms ?? [];
323
+ if (rooms.length === 0) log(pc.dim("no live rooms to migrate"));
324
+ for (const r of rooms) {
325
+ const line = ` ${r.roomId}: ${r.outcome}${r.reason ? ` \u2014 ${r.reason}` : ""}`;
326
+ log(r.outcome === "failed" ? pc.red(line) : pc.dim(line));
327
+ }
328
+ } else {
329
+ log(pc.dim("no tenant is running; rooms migrate on their next wake"));
330
+ }
331
+ } else if (previous !== void 0) {
332
+ if (response.applied === "live") {
333
+ log(
334
+ pc.dim(
335
+ `rooms already running finish on v${previous.version}; new rooms start on v${response.version}`
336
+ )
337
+ );
338
+ } else {
339
+ log(pc.dim(`rooms already running finish on v${previous.version}`));
340
+ }
264
341
  }
265
342
  return response;
266
343
  }
@@ -281,6 +358,7 @@ async function deploy(args, deps = {}) {
281
358
  ...parsed.project !== void 0 ? { project: parsed.project } : {},
282
359
  ...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
283
360
  ...parsed.room !== void 0 ? { room: parsed.room } : {},
361
+ ...parsed.strategy !== void 0 ? { strategy: parsed.strategy } : {},
284
362
  ...deps.client !== void 0 ? { client: deps.client } : {},
285
363
  ...deps.cwd !== void 0 ? { cwd: deps.cwd } : {},
286
364
  ...deps.irtioPackages !== void 0 ? { irtioPackages: deps.irtioPackages } : {}