@irtio/cli 0.5.2 → 0.6.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.
Files changed (37) hide show
  1. package/dist/api.d.ts +52 -0
  2. package/dist/api.js +15 -0
  3. package/dist/bundle.js +1 -1
  4. package/dist/{chunk-GBNHBWES.js → chunk-3HQMVCYA.js} +10 -6
  5. package/dist/{chunk-32QTPKVT.js → chunk-DKWG7MGO.js} +1 -1
  6. package/dist/{chunk-KRQUAEN2.js → chunk-OTSFRVJN.js} +12 -6
  7. package/dist/chunk-RNAH5T4W.js +96 -0
  8. package/dist/chunk-RQSJZWQC.js +452 -0
  9. package/dist/chunk-UPHQM6NZ.js +72 -0
  10. package/dist/chunk-ZD4ND6X6.js +31 -0
  11. package/dist/chunk-ZK5JLUD4.js +94 -0
  12. package/dist/credentials.d.ts +61 -0
  13. package/dist/credentials.js +20 -0
  14. package/dist/delete-project-VENS2B44.js +118 -0
  15. package/dist/deploy.d.ts +149 -0
  16. package/dist/{deploy-3SABPL3T.js → deploy.js} +166 -44
  17. package/dist/{dev-QJOGXLKM.js → dev-QM26ONKS.js} +2957 -231
  18. package/dist/index.js +97 -25
  19. package/dist/init.d.ts +1 -1
  20. package/dist/init.js +20 -4
  21. package/dist/{keys-XBORZAPI.js → keys-JHLMEGRA.js} +8 -4
  22. package/dist/leaderboard-SYPSBPS3.js +352 -0
  23. package/dist/{login-3EXB4CGX.js → login-2M73HBZT.js} +12 -5
  24. package/dist/{logs-2EOXLNWF.js → logs-2W7CPZO5.js} +9 -5
  25. package/dist/{migrate-WUO2GBMX.js → migrate-T3DZJREY.js} +12 -8
  26. package/dist/ratings-VG32WFDG.js +297 -0
  27. package/dist/{rollback-GA6UY772.js → rollback-SO74MVZV.js} +9 -5
  28. package/dist/{rooms-B66LQIIF.js → rooms-VI33P4RA.js} +36 -10
  29. package/dist/simulate.d.ts +147 -4
  30. package/dist/simulate.js +680 -53
  31. package/dist/{static-deploy-5TBH4VNA.js → static-deploy-KOWFKWZA.js} +6 -4
  32. package/dist/status-HF3ZEKB7.js +219 -0
  33. package/dist/usage-4G23QXCH.js +213 -0
  34. package/dist/{whoami-CI5D5RCC.js → whoami-KTMTQNHM.js} +8 -4
  35. package/package.json +23 -7
  36. package/dist/chunk-BPE452KF.js +0 -180
  37. package/dist/chunk-TV66QHFP.js +0 -167
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The CLI's on-disk credential store. One JSON file, keyed by control URL so a machine
3
+ * that talks to more than one control plane (a local dev instance and the real one) keeps both
4
+ * tokens without clobbering each other.
5
+ *
6
+ * Location: `~/.config/irtio/credentials.json` on macOS/Linux, `%APPDATA%/irtio/credentials.json`
7
+ * on Windows. This follows the platform's own convention for CLI config rather than XDG
8
+ * everywhere (`os.homedir()` alone gets the wrong answer on Windows, where `~/.config` is not a
9
+ * thing anyone looks for) — `%APPDATA%` is what `git`, `npm` and most Windows CLIs already use.
10
+ *
11
+ * The file is written mode 0600 (owner read/write only): it holds a bearer token that is exactly
12
+ * as sensitive as a password for whatever it authorizes. `chmod` runs after `writeFile` too,
13
+ * belt-and-suspenders against an umask or an existing file with looser permissions.
14
+ */
15
+ /** One control plane's worth of stored auth. `email` is best-effort (see `login.ts`). */
16
+ interface StoredCredential {
17
+ readonly token: string;
18
+ readonly expiresAt: string;
19
+ readonly email?: string;
20
+ }
21
+ type CredentialsFile = Record<string, StoredCredential>;
22
+ /**
23
+ * `~/.config/irtio/credentials.json`, or `%APPDATA%/irtio/credentials.json` on Windows.
24
+ *
25
+ * `IRT_CREDENTIALS_FILE` overrides this outright — the seam the test suite uses so `login`'s
26
+ * end-to-end tests never touch the real machine's home directory.
27
+ */
28
+ declare function credentialsPath(): string;
29
+ /** Reads the whole file. Missing or corrupt ⇒ empty (a corrupt file is not fatal, just unlucky). */
30
+ declare function readCredentials(): Promise<CredentialsFile>;
31
+ /** Reads the stored credential for one control URL, or `undefined` if there is none. */
32
+ declare function readCredential(controlUrl: string): Promise<StoredCredential | undefined>;
33
+ /** Merges one control URL's credential into the file and writes it back, mode 0600. */
34
+ declare function writeCredential(controlUrl: string, credential: StoredCredential): Promise<string>;
35
+ /** Removes one control URL's credential (used by tests; not currently wired to a command). */
36
+ declare function deleteCredential(controlUrl: string): Promise<void>;
37
+ /**
38
+ * The production control plane, used when nothing more specific is known. Since the single-origin
39
+ * merge the control API lives at the apex; `control.irt.io` has DNS but no certificate, so the
40
+ * old default died at the TLS handshake with a bare "fetch failed".
41
+ */
42
+ declare const DEFAULT_CONTROL_URL = "https://irt.io";
43
+ /** `--url`, else `IRT_CONTROL_URL`, else the production control plane. */
44
+ declare function resolveControlUrl(flag: string | undefined): string;
45
+ /**
46
+ * `resolveControlUrl`, plus the one thing a user always means: **if you signed in somewhere, talk
47
+ * to that somewhere.**
48
+ *
49
+ * `irtio login --url https://example.test` stores the credential under that URL, but every later
50
+ * command used to default to `https://control.irt.io` and answer `not logged in` while a perfectly
51
+ * good credential sat in the file under a different key. Real time was lost to exactly
52
+ * that during testing, escaped only by reading the CLI's compiled source.
53
+ *
54
+ * So: an explicit `--url` or `IRT_CONTROL_URL` still wins, and a file holding credentials for
55
+ * several control planes stays ambiguous and falls back to the default (guessing between two
56
+ * logins would be worse than the default). But the overwhelmingly common case — exactly one
57
+ * stored login — now resolves to that one.
58
+ */
59
+ declare function resolveControlUrlForUser(flag: string | undefined): Promise<string>;
60
+
61
+ export { type CredentialsFile, DEFAULT_CONTROL_URL, type StoredCredential, credentialsPath, deleteCredential, readCredential, readCredentials, resolveControlUrl, resolveControlUrlForUser, writeCredential };
@@ -0,0 +1,20 @@
1
+ import {
2
+ DEFAULT_CONTROL_URL,
3
+ credentialsPath,
4
+ deleteCredential,
5
+ readCredential,
6
+ readCredentials,
7
+ resolveControlUrl,
8
+ resolveControlUrlForUser,
9
+ writeCredential
10
+ } from "./chunk-UPHQM6NZ.js";
11
+ export {
12
+ DEFAULT_CONTROL_URL,
13
+ credentialsPath,
14
+ deleteCredential,
15
+ readCredential,
16
+ readCredentials,
17
+ resolveControlUrl,
18
+ resolveControlUrlForUser,
19
+ writeCredential
20
+ };
@@ -0,0 +1,118 @@
1
+ import {
2
+ HelpRequested,
3
+ helpFor,
4
+ helpRequested
5
+ } from "./chunk-ZD4ND6X6.js";
6
+ import {
7
+ createApiClient,
8
+ isLoginRequired
9
+ } from "./chunk-RNAH5T4W.js";
10
+ import {
11
+ resolveControlUrlForUser
12
+ } from "./chunk-UPHQM6NZ.js";
13
+
14
+ // src/delete-project.ts
15
+ import pc from "picocolors";
16
+ var USAGE = `usage: irtio delete-project <id> --yes [options]
17
+
18
+ Deletes a project and every record the control plane holds about it: origins, deployments,
19
+ signing secrets, rooms, player KV, leaderboard rows, logs, metrics and usage. This cannot be
20
+ undone. Stored
21
+ objects (bundles, saves, static files) are not deleted here; the platform sweeps orphans later.
22
+
23
+ A project whose tenant is currently live is refused; wait for its rooms to empty first.
24
+
25
+ options:
26
+ --yes actually delete. Without it the command only prints what would happen
27
+ --url <control> control plane (default: your stored login)
28
+ -h, --help print this
29
+ `;
30
+ function parseDeleteProjectArgs(args) {
31
+ if (helpRequested(args)) throw helpFor(USAGE);
32
+ let project;
33
+ let yes = false;
34
+ let url;
35
+ for (let i = 0; i < args.length; i++) {
36
+ const arg = args[i];
37
+ switch (arg) {
38
+ case "--yes":
39
+ yes = true;
40
+ break;
41
+ case "--url": {
42
+ const value = args[++i];
43
+ if (value === void 0) throw new Error("irtio delete-project: --url needs a value");
44
+ url = value;
45
+ break;
46
+ }
47
+ default:
48
+ if (arg.startsWith("-")) {
49
+ throw new Error(`irtio delete-project: unknown option ${JSON.stringify(arg)}`);
50
+ }
51
+ if (project !== void 0) {
52
+ throw new Error("irtio delete-project: takes exactly one project id");
53
+ }
54
+ project = arg;
55
+ }
56
+ }
57
+ if (project === void 0) {
58
+ throw new Error(
59
+ "irtio delete-project: needs a project id \u2014 it is never read from irtio.json, so the wrong directory cannot pick a victim"
60
+ );
61
+ }
62
+ return { project, yes, ...url !== void 0 ? { url } : {} };
63
+ }
64
+ async function runDeleteProject(options) {
65
+ const log = options.log ?? ((line) => console.log(line));
66
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
67
+ const client = options.client ?? await createApiClient(controlUrl);
68
+ const project = await client.get(`/v1/projects/${options.project}`);
69
+ if (!options.yes) {
70
+ log(
71
+ `this would delete project ${pc.bold(project.id)} (${project.name}) and every record of it: origins, deployments, keys, rooms, player KV, leaderboard rows, logs, metrics and usage`
72
+ );
73
+ log(pc.dim("nothing was deleted. This cannot be undone once it runs"));
74
+ log(`to proceed: irtio delete-project ${project.id} --yes`);
75
+ return false;
76
+ }
77
+ await client.del(`/v1/projects/${project.id}`);
78
+ log(pc.green(`deleted project ${project.id} (${project.name})`));
79
+ log(
80
+ pc.dim(
81
+ "stored objects (bundles, saves, static files) are left for the orphan sweep; nothing serves them any more"
82
+ )
83
+ );
84
+ return true;
85
+ }
86
+ async function deleteProject(args, deps = {}) {
87
+ const log = deps.log ?? ((line) => console.log(line));
88
+ const errorLog = deps.errorLog ?? ((line) => console.error(line));
89
+ try {
90
+ const parsed = parseDeleteProjectArgs(args);
91
+ await runDeleteProject({
92
+ project: parsed.project,
93
+ yes: parsed.yes,
94
+ log,
95
+ ...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
96
+ ...deps.client !== void 0 ? { client: deps.client } : {}
97
+ });
98
+ } catch (err) {
99
+ if (err instanceof HelpRequested) {
100
+ log(err.usage);
101
+ return;
102
+ }
103
+ if (isLoginRequired(err)) {
104
+ errorLog(pc.red("not logged in"));
105
+ errorLog("run: irtio login");
106
+ process.exitCode = 1;
107
+ return;
108
+ }
109
+ errorLog(pc.red(err instanceof Error ? err.message : String(err)));
110
+ process.exitCode = 1;
111
+ }
112
+ }
113
+ export {
114
+ USAGE,
115
+ deleteProject,
116
+ parseDeleteProjectArgs,
117
+ runDeleteProject
118
+ };
@@ -0,0 +1,149 @@
1
+ import { SchemaChange } from '@irtio/schema';
2
+ import { ApiClient } from './api.js';
3
+ import { BundleOptions } from './bundle.js';
4
+
5
+ /**
6
+ * `irtio deploy --static <dir>` (week 13, O8/D35): upload a BUILT client directory to the
7
+ * project's `static/<project>/<version>/` prefix and flip the pointer. No build step — the
8
+ * developer brings a built directory ("Not in Week 13"). Refuses nothing silently: everything
9
+ * skipped is named with its reason, and every limit is a named error the caller could have seen
10
+ * coming (`STATIC_LIMITS`, the week-12 KV-limits shape).
11
+ */
12
+
13
+ interface RunStaticDeployResult {
14
+ readonly project: string;
15
+ readonly label: string;
16
+ readonly version: number;
17
+ readonly files: number;
18
+ readonly bytes: number;
19
+ readonly url?: string;
20
+ /** The origin the deploy added to the project's allow list, when the site has a URL. */
21
+ readonly origin?: string;
22
+ }
23
+
24
+ /**
25
+ * `irtio deploy`: bundle the room, classify the schema change against the last
26
+ * deployment, refuse anything breaking without a migration, and hand the bundle to the control
27
+ * plane through the artifact-upload + deployment-create handshake. The CLI never holds storage
28
+ * credentials — every byte goes up through `POST /v1/projects/:id/artifacts`.
29
+ *
30
+ * `bundleRoom` is reused verbatim, including for the migration file: `verify: false` skips
31
+ * its "default export is a RoomDefinition" check, which a migration module does not satisfy, so
32
+ * the same import-menu-enforced esbuild path bundles both without any change to `bundle.ts`.
33
+ * A migration only needs `MigrationState`/`MigrationHelpers` as *types* — `import type { ... }
34
+ * from '@irtio/runtime'` — which esbuild elides before the resolver plugin ever sees it, so the
35
+ * `@irtio/server` / `@irtio/schema` import fence is never in the migration author's way.
36
+ *
37
+ * `bundleRoom`'s public result deliberately does not expose the canonical schema JSON or the
38
+ * room's `project` id (`BundleResult` is `file`/`hash`/`schemaHash`/`mode`/`warnings` only), so
39
+ * once the bundle is verified this module re-imports the file it already wrote to read
40
+ * `def.schema.project` and `def.schema.canonical` directly, rather than changing `bundleRoom`'s
41
+ * contract for one caller.
42
+ */
43
+
44
+ interface DeployArgs {
45
+ readonly allowBreaking: boolean;
46
+ readonly project?: string;
47
+ readonly url?: string;
48
+ readonly room?: string;
49
+ readonly strategy?: 'drain' | 'migrate';
50
+ readonly name?: string;
51
+ readonly config?: string;
52
+ readonly noStatic?: boolean;
53
+ }
54
+ declare const USAGE = "usage: irtio deploy [options]\n irtio deploy --static [<dir>] [options]\n\nBundles the room, classifies the schema change against the last deployment, and uploads. A\nbreaking change is refused unless a migration covers it. With \"static\" in the project file the\nclient directory ships too, after the room.\n\noptions:\n --allow-breaking accept a breaking schema change that a migration covers\n --strategy drain|migrate drain (the default) lets running rooms finish on the old version;\n migrate moves live rooms onto the new one now\n --room <file> the room entry\n --project <id> project id (default: the project file)\n --name <name> the name a first deploy registers the project under\n --no-static deploy the room alone, even with \"static\" configured\n -c, --config <f> the project file to read (default irtio.json)\n --url <control> control plane (default: your stored login)\n -h, --help print this\n\nirtio deploy --static uploads a BUILT client directory and nothing else. It takes --label <label>\nplus --project, --name, --config and --url; the directory and label default to the project file.\n";
55
+ declare function parseDeployArgs(args: readonly string[]): DeployArgs;
56
+ interface RunDeployOptions {
57
+ readonly cwd?: string;
58
+ readonly allowBreaking?: boolean;
59
+ readonly project?: string;
60
+ /** The name to register a not-yet-existing project under. Otherwise the config file's `name`. */
61
+ readonly name?: string;
62
+ /** `-c/--config`: the project file to read, relative to `cwd`. Defaults to `irtio.json`. */
63
+ readonly config?: string;
64
+ /** `--no-static`: deploy the room only, leaving a configured static site as it is. */
65
+ readonly noStatic?: boolean;
66
+ readonly controlUrl?: string;
67
+ readonly room?: string;
68
+ /** Week 13 (D28): `migrate` moves live rooms onto the new version; default `drain`. */
69
+ readonly strategy?: 'drain' | 'migrate';
70
+ readonly irtioPackages?: BundleOptions['irtioPackages'];
71
+ readonly ask?: (question: string) => Promise<string>;
72
+ readonly log?: (line: string) => void;
73
+ /** @internal lets tests swap in a client bound to a fixed token instead of the credentials file. */
74
+ readonly client?: ApiClient;
75
+ }
76
+ interface RunDeployResult {
77
+ readonly project: string;
78
+ readonly version: number;
79
+ readonly url: string;
80
+ readonly draining: number;
81
+ readonly applied?: 'live' | 'pending';
82
+ readonly rooms?: readonly {
83
+ roomId: string;
84
+ outcome: string;
85
+ reason?: string;
86
+ }[];
87
+ /** Present when the project file configured a static site and this deploy shipped it too. */
88
+ readonly site?: RunStaticDeployResult;
89
+ }
90
+ /** The room entry, or undefined when the project has none. `--room` naming a missing file still
91
+ * throws: that is a typo, not a project without a room. */
92
+ declare function findRoomEntry(cwd: string, room: string | undefined): string | undefined;
93
+ /** Every room type a project declares, resolved to entry files. */
94
+ interface RoomTypeEntries {
95
+ /** The entry `resolveEntry` would have picked: what a plain roomId gets. */
96
+ readonly default: string;
97
+ /**
98
+ * Type name -> absolute entry, always including `DEFAULT_ROOM_TYPE`. A project with no
99
+ * `irtio/rooms/` directory has exactly one member, which is why its deploy is unchanged.
100
+ */
101
+ readonly types: Readonly<Record<string, string>>;
102
+ }
103
+ /**
104
+ * D58: resolve the project's room types.
105
+ *
106
+ * The default type is what a plain roomId means, so a project must always name one. `irtio/room.ts`
107
+ * (the pre-D58 single entry) stays the default when it is there, which is what keeps an existing
108
+ * project's deploy byte-identical after it grows an `irtio/rooms/` directory; otherwise
109
+ * `irtio/rooms/default.ts` is it. A project with `irtio/rooms/` and neither is refused rather than
110
+ * silently deploying types no plain roomId can reach.
111
+ *
112
+ * `room` (`--room`) replaces the default type's entry only. Extra types are a project layout fact,
113
+ * not something a flag naming one file can restate.
114
+ */
115
+ declare function findRoomTypes(cwd: string, room?: string): RoomTypeEntries;
116
+ /** The whole handshake. Throws on any refusal/error; `deploy()` turns that into exit 1. */
117
+ declare function runDeploy(options?: RunDeployOptions): Promise<RunDeployResult>;
118
+ /**
119
+ * Distinguishes an intentional refusal (already printed) from an unexpected throw.
120
+ *
121
+ * It carries the classification as well as printing it. For the CLI the printed lines are the
122
+ * whole story, but a non-terminal caller has no terminal to read: `@irtio/mcp` narrates this
123
+ * refusal into a tool result, and needs the same `changes` and `hint` the control plane's own 409
124
+ * carries. Without them the most informative failure in the whole deploy path arrives as the
125
+ * string "deploy refused", which is exactly the shape D40 says a tool result must never be.
126
+ *
127
+ * The no-argument form still works, so an older caller keeps compiling.
128
+ */
129
+ declare class DeployRefusedError extends Error {
130
+ readonly name = "DeployRefusedError";
131
+ /** The breaking changes that caused the refusal, as the classifier reported them. */
132
+ readonly changes: readonly SchemaChange[];
133
+ /** The remedy, verbatim: normally `run: irtio migrate create <name>`. */
134
+ readonly hint: string | undefined;
135
+ constructor(reason?: {
136
+ changes?: readonly SchemaChange[];
137
+ hint?: string;
138
+ });
139
+ }
140
+ /** Thin CLI entry. `deps` is a test seam (log/errorLog capture, an injected client/cwd/aliases). */
141
+ declare function deploy(args: readonly string[], deps?: {
142
+ log?: (line: string) => void;
143
+ errorLog?: (line: string) => void;
144
+ client?: ApiClient;
145
+ cwd?: string;
146
+ irtioPackages?: BundleOptions['irtioPackages'];
147
+ }): Promise<void>;
148
+
149
+ export { type DeployArgs, DeployRefusedError, type RoomTypeEntries, type RunDeployOptions, type RunDeployResult, USAGE, deploy, findRoomEntry, findRoomTypes, parseDeployArgs, runDeploy };
@@ -1,38 +1,44 @@
1
- import {
2
- clientImportsRoom
3
- } from "./chunk-TQU6345E.js";
4
- import {
5
- bundleRoom
6
- } from "./chunk-KRQUAEN2.js";
7
1
  import {
8
2
  logEnsured,
9
3
  runStaticDeploy
10
- } from "./chunk-GBNHBWES.js";
11
- import "./chunk-BPE452KF.js";
4
+ } from "./chunk-3HQMVCYA.js";
5
+ import {
6
+ clientImportsRoom
7
+ } from "./chunk-TQU6345E.js";
8
+ import "./chunk-RQSJZWQC.js";
12
9
  import {
13
10
  ensureProject,
14
11
  readProjectConfig
15
- } from "./chunk-32QTPKVT.js";
12
+ } from "./chunk-DKWG7MGO.js";
13
+ import {
14
+ bundleRoom
15
+ } from "./chunk-OTSFRVJN.js";
16
16
  import {
17
- ApiClientError,
18
17
  HelpRequested,
19
- createApiClient,
20
18
  helpFor,
21
- helpRequested,
22
- isLoginRequired,
19
+ helpRequested
20
+ } from "./chunk-ZD4ND6X6.js";
21
+ import {
22
+ ApiClientError,
23
+ createApiClient,
24
+ isLoginRequired
25
+ } from "./chunk-RNAH5T4W.js";
26
+ import {
23
27
  resolveControlUrlForUser
24
- } from "./chunk-TV66QHFP.js";
28
+ } from "./chunk-UPHQM6NZ.js";
25
29
 
26
30
  // src/deploy.ts
27
- import { existsSync } from "fs";
31
+ import { existsSync, readdirSync } from "fs";
28
32
  import { mkdir, readFile, readdir, rm } from "fs/promises";
29
33
  import * as path from "path";
30
34
  import { createInterface } from "readline/promises";
31
35
  import { pathToFileURL } from "url";
36
+ import { DEFAULT_ROOM_TYPE, ROOM_TYPE_RE } from "@irtio/protocol";
32
37
  import { diffSchemas, schemaFromCanonical } from "@irtio/schema";
33
38
  import pc from "picocolors";
34
39
  var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
35
40
  var MIGRATIONS_DIR = "irtio/migrations";
41
+ var ROOM_TYPES_DIR = "irtio/rooms";
36
42
  var USAGE = `usage: irtio deploy [options]
37
43
  irtio deploy --static [<dir>] [options]
38
44
 
@@ -148,6 +154,45 @@ function resolveEntry(cwd, room) {
148
154
  pass --room <file> if it lives elsewhere`
149
155
  );
150
156
  }
157
+ function findRoomTypes(cwd, room) {
158
+ const dir = path.resolve(cwd, ROOM_TYPES_DIR);
159
+ const types = {};
160
+ if (existsSync(dir)) {
161
+ for (const name of readdirSync(dir).sort()) {
162
+ const ext = path.extname(name);
163
+ if (ext !== ".ts" && ext !== ".js") continue;
164
+ const type = name.slice(0, -ext.length);
165
+ if (!ROOM_TYPE_RE.test(type)) {
166
+ throw new Error(
167
+ `irtio deploy: ${path.join(ROOM_TYPES_DIR, name)} is not a usable room type name
168
+ a type name is lowercase letters, digits, "_" or "-", starting with a letter or digit, at most 24 characters
169
+ rename the file, or move it out of ${ROOM_TYPES_DIR}/ if it is not a room entry`
170
+ );
171
+ }
172
+ types[type] = path.join(dir, name);
173
+ }
174
+ }
175
+ const explicit = findRoomEntry(cwd, room);
176
+ if (explicit !== void 0) {
177
+ if (room === void 0 && types[DEFAULT_ROOM_TYPE] !== void 0) {
178
+ throw new Error(
179
+ `irtio deploy: this project names its default room type twice, in ${ROOM_CANDIDATES[0]} and in ${ROOM_TYPES_DIR}/default.ts
180
+ keep one of them: a plain roomId resolves to exactly one definition`
181
+ );
182
+ }
183
+ return { default: explicit, types: { ...types, [DEFAULT_ROOM_TYPE]: explicit } };
184
+ }
185
+ const fallback = types[DEFAULT_ROOM_TYPE];
186
+ if (fallback !== void 0) return { default: fallback, types };
187
+ if (Object.keys(types).length > 0) {
188
+ throw new Error(
189
+ `irtio deploy: ${ROOM_TYPES_DIR}/ declares ${Object.keys(types).join(", ")} but no default room type, and a roomId with no type prefix has to resolve to one
190
+ add ${ROOM_TYPES_DIR}/default.ts, or keep the single entry at ${ROOM_CANDIDATES[0]}`
191
+ );
192
+ }
193
+ const entry = resolveEntry(cwd, room);
194
+ return { default: entry, types: { [DEFAULT_ROOM_TYPE]: entry } };
195
+ }
151
196
  async function warnIfClientImportsRoom(cwd, entry, config, log) {
152
197
  const client = config.client;
153
198
  if (client === void 0) return;
@@ -162,10 +207,17 @@ async function warnIfClientImportsRoom(cwd, entry, config, log) {
162
207
  }
163
208
  async function loadBundledSchema(file) {
164
209
  const mod = await import(pathToFileURL(file).href);
210
+ const config = mod.default.config;
165
211
  return {
166
212
  project: mod.default.schema.project,
167
213
  canonical: mod.default.schema.canonical,
168
- schema: mod.default.schema
214
+ schema: mod.default.schema,
215
+ memoryMb: typeof config?.memoryMb === "number" ? config.memoryMb : void 0,
216
+ maxAwake: typeof config?.maxAwake === "number" ? config.maxAwake : void 0,
217
+ // The declared string, verbatim and unparsed. `defineRoom` already refused anything the
218
+ // grammar does not accept, and control refuses it again; the CLI's job is to carry what the
219
+ // bundle says rather than to have an opinion of its own about a duration.
220
+ retention: typeof config?.retention === "string" ? config.retention : void 0
169
221
  };
170
222
  }
171
223
  function printBreaking(log, breaking, hint) {
@@ -189,7 +241,8 @@ async function runDeploy(options = {}) {
189
241
  const ask = options.ask ?? defaultAsk;
190
242
  const controlUrl = await resolveControlUrlForUser(options.controlUrl);
191
243
  const config = await readProjectConfig(cwd, "irtio deploy", options.config);
192
- const entry = resolveEntry(cwd, options.room);
244
+ const roomTypes = findRoomTypes(cwd, options.room);
245
+ const entry = roomTypes.default;
193
246
  await warnIfClientImportsRoom(cwd, entry, config, log);
194
247
  const outDir = path.join(cwd, DEPLOY_BUILD_DIR);
195
248
  await mkdir(outDir, { recursive: true });
@@ -228,25 +281,25 @@ async function runDeploy(options = {}) {
228
281
  const newest = newestOf(deployments);
229
282
  let allowBreaking = options.allowBreaking ?? false;
230
283
  let migrationFile;
284
+ let schemaChange = "none";
231
285
  const nextVersion = (newest?.version ?? 0) + 1;
232
286
  if (previous !== void 0) {
233
287
  const oldSchema = schemaFromCanonical(previous.schemaJson);
234
288
  const changes = diffSchemas(oldSchema, bundled.schema);
235
289
  const breaking = changes.filter((c) => c.kind === "breaking");
236
290
  const additive = changes.filter((c) => c.kind === "additive");
291
+ schemaChange = breaking.length > 0 ? "breaking" : additive.length > 0 ? "additive" : "none";
237
292
  if (breaking.length > 0) {
238
293
  if (!allowBreaking) {
239
- printBreaking(log, breaking, "run: irtio migrate create <name>");
240
- throw new DeployRefusedError();
294
+ const hint = "run: irtio migrate create <name>";
295
+ printBreaking(log, breaking, hint);
296
+ throw new DeployRefusedError({ changes: breaking, hint });
241
297
  }
242
298
  migrationFile = await findMigrationFile(cwd, nextVersion);
243
299
  if (migrationFile === void 0) {
244
- printBreaking(
245
- log,
246
- breaking,
247
- `--allow-breaking needs a migration for v${nextVersion} \u2014 run: irtio migrate create <name>`
248
- );
249
- throw new DeployRefusedError();
300
+ const hint = `--allow-breaking needs a migration for v${nextVersion}. Run: irtio migrate create <name>`;
301
+ printBreaking(log, breaking, hint);
302
+ throw new DeployRefusedError({ changes: breaking, hint });
250
303
  }
251
304
  log(pc.yellow(`${breaking.length} breaking change(s), proceeding with --allow-breaking:`));
252
305
  for (const change of breaking) log(` ${change.message}`);
@@ -259,7 +312,7 @@ async function runDeploy(options = {}) {
259
312
  }
260
313
  if (changes.length === 0) log(pc.dim("no schema changes"));
261
314
  } else {
262
- log(pc.dim("no previous deployment \u2014 first deploy for this project"));
315
+ log(pc.dim("no previous deployment, so this is the first deploy for this project"));
263
316
  }
264
317
  let migrationKey;
265
318
  if (migrationFile !== void 0) {
@@ -283,6 +336,45 @@ async function runDeploy(options = {}) {
283
336
  roomBytes
284
337
  );
285
338
  log(pc.green("uploaded bundle"));
339
+ const extraRooms = {};
340
+ for (const [type, typeEntry] of Object.entries(roomTypes.types)) {
341
+ if (type === DEFAULT_ROOM_TYPE) continue;
342
+ let typeResult;
343
+ try {
344
+ typeResult = await bundleRoom({
345
+ entry: typeEntry,
346
+ outDir,
347
+ ...options.irtioPackages !== void 0 ? { irtioPackages: options.irtioPackages } : {}
348
+ });
349
+ } catch (err) {
350
+ throw new Error(
351
+ `irtio deploy: room type "${type}": ${err instanceof Error ? err.message : String(err)}`
352
+ );
353
+ }
354
+ if (typeResult.schemaHash === void 0) {
355
+ throw new Error(`irtio deploy: room type "${type}" bundled without a schema hash`);
356
+ }
357
+ const typeSchema = await loadBundledSchema(typeResult.file);
358
+ const uploaded = await client.postBytes(
359
+ `/v1/projects/${projectId}/artifacts`,
360
+ new Uint8Array(await readFile(typeResult.file))
361
+ );
362
+ extraRooms[type] = {
363
+ bundleHash: typeResult.hash,
364
+ bundleKey: uploaded.key,
365
+ schemaJson: typeSchema.canonical,
366
+ schemaHash: typeResult.schemaHash,
367
+ // M5 part 3.5: this type's sizing declarations travel with its bundle, so control and the
368
+ // host agent can size the VM before it boots. Spread, never null: a type that declares
369
+ // neither sends neither, and the env generation of a project that has never declared
370
+ // anything is unchanged.
371
+ ...typeSchema.memoryMb !== void 0 ? { memoryMb: typeSchema.memoryMb } : {},
372
+ ...typeSchema.maxAwake !== void 0 ? { maxAwake: typeSchema.maxAwake } : {},
373
+ ...typeSchema.retention !== void 0 ? { retention: typeSchema.retention } : {}
374
+ };
375
+ log(pc.green(`bundled and uploaded room type ${type} (${typeResult.hash.slice(0, 12)})`));
376
+ }
377
+ const hasExtraTypes = Object.keys(extraRooms).length > 0;
286
378
  let origin;
287
379
  if (previous === void 0) {
288
380
  const answer = (await ask("production origin (localhost is always allowed) \u2014 leave blank to skip: ")).trim();
@@ -290,17 +382,35 @@ async function runDeploy(options = {}) {
290
382
  }
291
383
  const strategy = options.strategy ?? "drain";
292
384
  if (strategy === "migrate") {
293
- log(pc.yellow("strategy: migrate \u2014 live rooms move onto the new version now."));
294
- log(
295
- pc.yellow(
296
- "Schema unchanged: connected clients see a short gap and a resync on the same socket."
297
- )
298
- );
299
- log(
300
- pc.yellow(
301
- "Schema changed at all: EVERY connected client is disconnected with E_SCHEMA_MISMATCH and must reload."
302
- )
385
+ log(pc.yellow("strategy: migrate. Live rooms move onto the new version now."));
386
+ const physics = bundled.schema.collections.some(
387
+ (c) => c.physics !== void 0
303
388
  );
389
+ if (schemaChange === "breaking") {
390
+ log(
391
+ pc.yellow(
392
+ "Breaking schema change: EVERY connected client is disconnected with E_SCHEMA_MISMATCH and must reload."
393
+ )
394
+ );
395
+ } else {
396
+ log(
397
+ pc.yellow(
398
+ schemaChange === "none" ? "Schema unchanged: connected clients see a short gap and a resync on the same socket." : "Additive schema change: connected clients see a short gap and a resync on the same socket, on the new schema."
399
+ )
400
+ );
401
+ if (physics) {
402
+ log(
403
+ pc.yellow(
404
+ "This schema has physics collections. A change touching one of those is treated as breaking for now, so those clients reload."
405
+ )
406
+ );
407
+ }
408
+ log(
409
+ pc.dim(
410
+ "Clients built before irtio 0.6 are still disconnected with E_SCHEMA_MISMATCH on any schema change."
411
+ )
412
+ );
413
+ }
304
414
  }
305
415
  let response;
306
416
  try {
@@ -309,6 +419,13 @@ async function runDeploy(options = {}) {
309
419
  bundleKey: uploadedRoom.key,
310
420
  schemaJson: bundled.canonical,
311
421
  schemaHash: result.schemaHash,
422
+ // M5 part 3.5: the default type's declarations, top-level beside its schema for the reason
423
+ // `DeploymentSpec` states — the default type is not in the `rooms` map, and putting it there
424
+ // would give every single-room project a `rooms` key and cold-boot the fleet.
425
+ ...bundled.memoryMb !== void 0 ? { memoryMb: bundled.memoryMb } : {},
426
+ ...bundled.maxAwake !== void 0 ? { maxAwake: bundled.maxAwake } : {},
427
+ ...bundled.retention !== void 0 ? { retention: bundled.retention } : {},
428
+ ...hasExtraTypes ? { rooms: extraRooms } : {},
312
429
  ...migrationKey !== void 0 ? { migrationKey } : {},
313
430
  ...allowBreaking ? { allowBreaking: true } : {},
314
431
  ...origin !== void 0 ? { origin } : {},
@@ -324,12 +441,10 @@ async function runDeploy(options = {}) {
324
441
  };
325
442
  } catch (err) {
326
443
  if (err instanceof ApiClientError && err.code === "E_BREAKING_SCHEMA" && err.changes) {
327
- printBreaking(
328
- log,
329
- err.changes,
330
- err.hint ?? "run: irtio migrate create <name>"
331
- );
332
- throw new DeployRefusedError();
444
+ const changes = err.changes;
445
+ const hint = err.hint ?? "run: irtio migrate create <name>";
446
+ printBreaking(log, changes, hint);
447
+ throw new DeployRefusedError({ changes, hint });
333
448
  }
334
449
  throw err;
335
450
  }
@@ -376,8 +491,14 @@ async function runDeploy(options = {}) {
376
491
  }
377
492
  var DeployRefusedError = class extends Error {
378
493
  name = "DeployRefusedError";
379
- constructor() {
494
+ /** The breaking changes that caused the refusal, as the classifier reported them. */
495
+ changes;
496
+ /** The remedy, verbatim: normally `run: irtio migrate create <name>`. */
497
+ hint;
498
+ constructor(reason = {}) {
380
499
  super("deploy refused");
500
+ this.changes = reason.changes ?? [];
501
+ this.hint = reason.hint;
381
502
  }
382
503
  };
383
504
  async function deploy(args, deps = {}) {
@@ -388,7 +509,7 @@ async function deploy(args, deps = {}) {
388
509
  const cwd = path.resolve(deps.cwd ?? process.cwd());
389
510
  const config = await readProjectConfig(cwd, "irtio deploy", parsed.config);
390
511
  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"));
512
+ log(pc.dim("no room file, so deploying the static site only"));
392
513
  await runStaticDeploy({
393
514
  cwd,
394
515
  log,
@@ -440,6 +561,7 @@ export {
440
561
  USAGE,
441
562
  deploy,
442
563
  findRoomEntry,
564
+ findRoomTypes,
443
565
  parseDeployArgs,
444
566
  runDeploy
445
567
  };