@irtio/cli 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.
@@ -0,0 +1,122 @@
1
+ import {
2
+ createApiClient,
3
+ isLoginRequired
4
+ } from "./chunk-J3G6AUJY.js";
5
+ import {
6
+ resolveControlUrlForUser
7
+ } from "./chunk-UYN5PWLT.js";
8
+
9
+ // src/logs.ts
10
+ import { existsSync } from "fs";
11
+ import { readFile } from "fs/promises";
12
+ import * as path from "path";
13
+ import pc from "picocolors";
14
+ var POLL_MS = 2e3;
15
+ function parseLogsArgs(args) {
16
+ const parsed = {
17
+ follow: false
18
+ };
19
+ for (let i = 0; i < args.length; i++) {
20
+ const arg = args[i];
21
+ switch (arg) {
22
+ case "--follow":
23
+ parsed.follow = true;
24
+ break;
25
+ case "--since": {
26
+ const value = args[++i];
27
+ if (value === void 0) throw new Error("irtio logs: --since needs a value");
28
+ parsed.since = value;
29
+ break;
30
+ }
31
+ case "--project": {
32
+ const value = args[++i];
33
+ if (value === void 0) throw new Error("irtio logs: --project needs a value");
34
+ parsed.project = value;
35
+ break;
36
+ }
37
+ case "--url": {
38
+ const value = args[++i];
39
+ if (value === void 0) throw new Error("irtio logs: --url needs a value");
40
+ parsed.url = value;
41
+ break;
42
+ }
43
+ default:
44
+ throw new Error(`irtio logs: unknown option ${JSON.stringify(arg)}`);
45
+ }
46
+ }
47
+ return parsed;
48
+ }
49
+ function levelColor(level, text) {
50
+ return level === "error" ? pc.red(text) : level === "warn" ? pc.yellow(text) : pc.dim(text);
51
+ }
52
+ function formatRow(row) {
53
+ const time = row.at.replace("T", " ").replace(/\.\d+Z$/, "Z");
54
+ const level = levelColor(row.level, row.level.toUpperCase().padEnd(5));
55
+ const room = (row.roomId ?? "-").padEnd(12);
56
+ return `${pc.dim(time)} ${level} ${room} ${row.message}`;
57
+ }
58
+ async function runLogs(options) {
59
+ const log = options.log ?? ((line) => console.log(line));
60
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
61
+ const client = options.client ?? await createApiClient(controlUrl);
62
+ let since = options.since;
63
+ let polls = 0;
64
+ for (; ; ) {
65
+ const rows = await client.get(`/v1/projects/${options.project}/logs`, {
66
+ since
67
+ });
68
+ for (const row of rows) log(formatRow(row));
69
+ const last = rows[rows.length - 1];
70
+ if (last !== void 0) since = last.at;
71
+ if (!options.follow) return since;
72
+ polls += 1;
73
+ if (options.maxPolls !== void 0 && polls >= options.maxPolls) return since;
74
+ await new Promise((resolve) => setTimeout(resolve, options.pollMs ?? POLL_MS));
75
+ }
76
+ }
77
+ async function readIrtioJsonProject(cwd) {
78
+ const file = path.join(cwd, "irtio.json");
79
+ if (!existsSync(file)) return void 0;
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(await readFile(file, "utf8"));
83
+ } catch {
84
+ throw new Error(`irtio logs: ${file} is not valid JSON`);
85
+ }
86
+ return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
87
+ }
88
+ async function logs(args, deps = {}) {
89
+ const log = deps.log ?? ((line) => console.log(line));
90
+ const errorLog = deps.errorLog ?? ((line) => console.error(line));
91
+ try {
92
+ const parsed = parseLogsArgs(args);
93
+ const project = parsed.project ?? await readIrtioJsonProject(process.cwd());
94
+ if (project === void 0) {
95
+ throw new Error(
96
+ "irtio logs: no project id \u2014 pass --project or run this from a project with irtio.json"
97
+ );
98
+ }
99
+ await runLogs({
100
+ project,
101
+ follow: parsed.follow,
102
+ log,
103
+ ...parsed.since !== void 0 ? { since: parsed.since } : {},
104
+ ...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
105
+ ...deps.client !== void 0 ? { client: deps.client } : {}
106
+ });
107
+ } catch (err) {
108
+ if (isLoginRequired(err)) {
109
+ errorLog(pc.red("not logged in"));
110
+ errorLog("run: irtio login");
111
+ process.exitCode = 1;
112
+ return;
113
+ }
114
+ errorLog(pc.red(err instanceof Error ? err.message : String(err)));
115
+ process.exitCode = 1;
116
+ }
117
+ }
118
+ export {
119
+ logs,
120
+ parseLogsArgs,
121
+ runLogs
122
+ };
@@ -0,0 +1,206 @@
1
+ import {
2
+ bundleRoom
3
+ } from "./chunk-ZWCLCYCS.js";
4
+ import {
5
+ createApiClient,
6
+ isLoginRequired
7
+ } from "./chunk-J3G6AUJY.js";
8
+ import {
9
+ resolveControlUrlForUser
10
+ } from "./chunk-UYN5PWLT.js";
11
+
12
+ // src/migrate.ts
13
+ import { existsSync } from "fs";
14
+ import { mkdir, mkdtemp, readFile, readdir, writeFile } from "fs/promises";
15
+ import { tmpdir } from "os";
16
+ import * as path from "path";
17
+ import { pathToFileURL } from "url";
18
+ import {
19
+ describeType,
20
+ diffSchemas,
21
+ schemaFromCanonical
22
+ } from "@irtio/schema";
23
+ import pc from "picocolors";
24
+ var MIGRATIONS_DIR = "irtio/migrations";
25
+ var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
26
+ function parseMigrateArgs(args) {
27
+ const [sub, name, ...rest] = args;
28
+ if (sub !== "create") {
29
+ throw new Error(
30
+ `irtio migrate: unknown subcommand ${JSON.stringify(sub ?? "")} (expected "create")`
31
+ );
32
+ }
33
+ if (name === void 0 || name.length === 0) {
34
+ throw new Error(
35
+ "irtio migrate create: a name is required, e.g. irtio migrate create rename-hp"
36
+ );
37
+ }
38
+ if (rest.length > 0)
39
+ throw new Error(`irtio migrate create: unexpected extra argument ${JSON.stringify(rest[0])}`);
40
+ return { command: "create", args: { name } };
41
+ }
42
+ function resolveEntry(cwd, room) {
43
+ if (room !== void 0) {
44
+ const file = path.resolve(cwd, room);
45
+ if (!existsSync(file)) throw new Error(`irtio migrate create: no room file at ${file}`);
46
+ return file;
47
+ }
48
+ for (const candidate of ROOM_CANDIDATES) {
49
+ const file = path.resolve(cwd, candidate);
50
+ if (existsSync(file)) return file;
51
+ }
52
+ throw new Error(
53
+ `irtio migrate create: no room file found in ${cwd} (looked for: ${ROOM_CANDIDATES.join(", ")})`
54
+ );
55
+ }
56
+ async function readIrtioJsonProject(cwd) {
57
+ const file = path.join(cwd, "irtio.json");
58
+ if (!existsSync(file)) return void 0;
59
+ const parsed = JSON.parse(await readFile(file, "utf8"));
60
+ return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
61
+ }
62
+ async function scanDirectoryVersion(cwd) {
63
+ const dir = path.join(cwd, MIGRATIONS_DIR);
64
+ if (!existsSync(dir)) return 1;
65
+ const entries = await readdir(dir);
66
+ let max = 0;
67
+ for (const entry of entries) {
68
+ const match = /^(\d+)_/.exec(entry);
69
+ if (match) max = Math.max(max, Number(match[1]));
70
+ }
71
+ return max + 1;
72
+ }
73
+ function describeSchema(schema) {
74
+ const lines = [];
75
+ for (const c of schema.collections) {
76
+ const fields = c.fields.map((f) => `${f.name}: ${describeType(f.type)}`).join(", ");
77
+ lines.push(` * ${c.name}: ${c.kind} { ${fields} }`);
78
+ }
79
+ return lines;
80
+ }
81
+ function template(opts) {
82
+ const breakingLines = opts.breaking.length > 0 ? opts.breaking.map((c) => ` * - ${c.message}`).join("\n") : " * (none found \u2014 this migration was scaffolded without a breaking diff to compare against)";
83
+ const shapeLines = opts.oldShape.length > 0 ? opts.oldShape.join("\n") : " * (unknown \u2014 no room schema was available when this was scaffolded)";
84
+ return `/**
85
+ * Migration to v${opts.version} (from v${opts.fromVersion}).
86
+ *
87
+ * Breaking changes to address:
88
+ ${breakingLines}
89
+ *
90
+ * v${opts.fromVersion}'s shape, for reference (the state \`up\` receives is close to this \u2014 entity
91
+ * collections arrive as \`{ id: { owner, value } }\`, singletons as the record itself):
92
+ ${shapeLines}
93
+ */
94
+
95
+ import type { MigrationHelpers, MigrationState } from '@irtio/runtime';
96
+
97
+ export function up(state: MigrationState, s: MigrationHelpers): MigrationState {
98
+ // TODO: transform \`state\` from v${opts.fromVersion}'s shape to v${opts.version}'s. \`s.log(...)\`
99
+ // goes to the room's log; \`state\` is plain data, not the tracked proxy tree \u2014 mutate and
100
+ // return it, or build and return a new object.
101
+ return state;
102
+ }
103
+
104
+ // \`down\` is scaffolded and stored but unused until rollback ships \u2014 uncomment and fill it
105
+ // in if you want it recorded now.
106
+ //
107
+ // export function down(state: MigrationState, s: MigrationHelpers): MigrationState {
108
+ // return state;
109
+ // }
110
+ `;
111
+ }
112
+ async function runMigrateCreate(options) {
113
+ const cwd = path.resolve(options.cwd ?? process.cwd());
114
+ const log = options.log ?? ((line) => console.log(line));
115
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
116
+ const projectId = options.project ?? await readIrtioJsonProject(cwd);
117
+ let version;
118
+ let versionSource;
119
+ let previousSchemaJson;
120
+ let client;
121
+ try {
122
+ client = options.client ?? await createApiClient(controlUrl);
123
+ if (projectId === void 0) throw new Error("no project id");
124
+ const deployments = await client.get(
125
+ `/v1/projects/${projectId}/deployments`
126
+ );
127
+ const latest = deployments.reduce(
128
+ (best, d) => best === void 0 || d.version > best.version ? d : best,
129
+ void 0
130
+ );
131
+ version = (latest?.version ?? 0) + 1;
132
+ previousSchemaJson = latest?.schemaJson;
133
+ versionSource = "control-plane";
134
+ } catch {
135
+ version = await scanDirectoryVersion(cwd);
136
+ versionSource = "directory-scan";
137
+ }
138
+ log(
139
+ pc.dim(
140
+ `next version: v${version} (from ${versionSource === "control-plane" ? "the control plane" : "scanning irtio/migrations"})`
141
+ )
142
+ );
143
+ const dir = path.join(cwd, MIGRATIONS_DIR);
144
+ const file = path.join(dir, `${version}_${options.name}.ts`);
145
+ if (existsSync(file)) {
146
+ log(pc.yellow(`kept ${path.relative(cwd, file)} (already there \u2014 nothing was overwritten)`));
147
+ return { file, version, skipped: true, versionSource };
148
+ }
149
+ let breaking = [];
150
+ let oldShape = [];
151
+ if (previousSchemaJson !== void 0) {
152
+ try {
153
+ const entry = resolveEntry(cwd, options.room);
154
+ const outDir = await mkdtemp(path.join(tmpdir(), "irtio-migrate-"));
155
+ const result = await bundleRoom({
156
+ entry,
157
+ outDir,
158
+ ...options.irtioPackages !== void 0 ? { irtioPackages: options.irtioPackages } : {}
159
+ });
160
+ const mod = await import(pathToFileURL(result.file).href);
161
+ const oldSchema = schemaFromCanonical(previousSchemaJson);
162
+ breaking = diffSchemas(oldSchema, mod.default.schema).filter((c) => c.kind === "breaking");
163
+ oldShape = describeSchema(oldSchema);
164
+ } catch (err) {
165
+ log(
166
+ pc.dim(
167
+ `(could not compute the live breaking diff: ${err instanceof Error ? err.message : String(err)})`
168
+ )
169
+ );
170
+ }
171
+ }
172
+ await mkdir(dir, { recursive: true });
173
+ const contents = template({ version, fromVersion: version - 1, breaking, oldShape });
174
+ await writeFile(file, contents, { encoding: "utf8" });
175
+ log(pc.green(`created ${path.relative(cwd, file)}`));
176
+ if (breaking.length > 0) {
177
+ log(pc.dim(`quoted ${breaking.length} breaking change(s) in the header comment`));
178
+ }
179
+ return { file, version, skipped: false, versionSource };
180
+ }
181
+ async function migrate(args, deps = {}) {
182
+ const log = deps.log ?? ((line) => console.log(line));
183
+ const errorLog = deps.errorLog ?? ((line) => console.error(line));
184
+ try {
185
+ const { args: createArgs } = parseMigrateArgs(args);
186
+ await runMigrateCreate({
187
+ ...createArgs,
188
+ log,
189
+ ...deps.client !== void 0 ? { client: deps.client } : {}
190
+ });
191
+ } catch (err) {
192
+ if (isLoginRequired(err)) {
193
+ errorLog(pc.red("not logged in"));
194
+ errorLog("run: irtio login");
195
+ process.exitCode = 1;
196
+ return;
197
+ }
198
+ errorLog(pc.red(err instanceof Error ? err.message : String(err)));
199
+ process.exitCode = 1;
200
+ }
201
+ }
202
+ export {
203
+ migrate,
204
+ parseMigrateArgs,
205
+ runMigrateCreate
206
+ };
@@ -0,0 +1,102 @@
1
+ import {
2
+ createApiClient,
3
+ isLoginRequired
4
+ } from "./chunk-J3G6AUJY.js";
5
+ import {
6
+ resolveControlUrlForUser
7
+ } from "./chunk-UYN5PWLT.js";
8
+
9
+ // src/rooms.ts
10
+ import { existsSync } from "fs";
11
+ import { readFile } from "fs/promises";
12
+ import * as path from "path";
13
+ import pc from "picocolors";
14
+ function parseRoomsArgs(args) {
15
+ const parsed = {};
16
+ for (let i = 0; i < args.length; i++) {
17
+ const arg = args[i];
18
+ switch (arg) {
19
+ case "--project": {
20
+ const value = args[++i];
21
+ if (value === void 0) throw new Error("irtio rooms: --project needs a value");
22
+ parsed.project = value;
23
+ break;
24
+ }
25
+ case "--url": {
26
+ const value = args[++i];
27
+ if (value === void 0) throw new Error("irtio rooms: --url needs a value");
28
+ parsed.url = value;
29
+ break;
30
+ }
31
+ default:
32
+ throw new Error(`irtio rooms: unknown option ${JSON.stringify(arg)}`);
33
+ }
34
+ }
35
+ return parsed;
36
+ }
37
+ function statusColor(status) {
38
+ return status === "active" || status === "running" ? pc.green(status) : status === "draining" ? pc.yellow(status) : pc.dim(status);
39
+ }
40
+ function formatTable(rows) {
41
+ if (rows.length === 0) return [pc.dim("no rooms")];
42
+ const idWidth = Math.max(4, ...rows.map((r) => r.roomId.length));
43
+ const out = [pc.dim(`${"ROOM".padEnd(idWidth)} STATUS LAST SEEN`)];
44
+ for (const r of rows) {
45
+ out.push(
46
+ `${r.roomId.padEnd(idWidth)} ${statusColor(r.status).padEnd(10)} ${pc.dim(r.lastSeen)}`
47
+ );
48
+ }
49
+ return out;
50
+ }
51
+ async function runRooms(options) {
52
+ const log = options.log ?? ((line) => console.log(line));
53
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
54
+ const client = options.client ?? await createApiClient(controlUrl);
55
+ const rows = await client.get(`/v1/projects/${options.project}/rooms`);
56
+ for (const line of formatTable(rows)) log(line);
57
+ return rows;
58
+ }
59
+ async function readIrtioJsonProject(cwd) {
60
+ const file = path.join(cwd, "irtio.json");
61
+ if (!existsSync(file)) return void 0;
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(await readFile(file, "utf8"));
65
+ } catch {
66
+ throw new Error(`irtio rooms: ${file} is not valid JSON`);
67
+ }
68
+ return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
69
+ }
70
+ async function rooms(args, deps = {}) {
71
+ const log = deps.log ?? ((line) => console.log(line));
72
+ const errorLog = deps.errorLog ?? ((line) => console.error(line));
73
+ try {
74
+ const parsed = parseRoomsArgs(args);
75
+ const project = parsed.project ?? await readIrtioJsonProject(process.cwd());
76
+ if (project === void 0) {
77
+ throw new Error(
78
+ "irtio rooms: no project id \u2014 pass --project or run this from a project with irtio.json"
79
+ );
80
+ }
81
+ await runRooms({
82
+ project,
83
+ log,
84
+ ...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
85
+ ...deps.client !== void 0 ? { client: deps.client } : {}
86
+ });
87
+ } catch (err) {
88
+ if (isLoginRequired(err)) {
89
+ errorLog(pc.red("not logged in"));
90
+ errorLog("run: irtio login");
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+ errorLog(pc.red(err instanceof Error ? err.message : String(err)));
95
+ process.exitCode = 1;
96
+ }
97
+ }
98
+ export {
99
+ parseRoomsArgs,
100
+ rooms,
101
+ runRooms
102
+ };
@@ -0,0 +1,62 @@
1
+ import { SimulationReport } from '@irtio/bots';
2
+ import { AnySchema } from '@irtio/schema';
3
+
4
+ /**
5
+ * `irtio simulate`: point N real clients at a running room, play it randomly for a few
6
+ * seconds, and print a pass/fail line per built-in invariant.
7
+ *
8
+ * The command is deliberately thin — `@irtio/bots` owns the bots, the trace and the invariants —
9
+ * and it exists so the agent building a room gets eyes on it from day one, without writing a test
10
+ * first. Its one real job is loading `irtio/schema.ts` from the project: a schema module is not a
11
+ * room module, so `bundleRoom` (which insists on a `defineRoom` default export) is the wrong tool
12
+ * and esbuild is used directly, with the same monorepo aliasing `dev.ts` uses for the worker.
13
+ *
14
+ * With no `irtio/schema.ts` the run falls back to a schema-less relay simulation and says so, which
15
+ * is the honest thing to do: a relay room really does have no schema.
16
+ */
17
+
18
+ interface SimulateArgs {
19
+ bots: number;
20
+ seconds: number;
21
+ cheat: boolean;
22
+ room?: string;
23
+ url?: string;
24
+ key?: string;
25
+ trace?: string;
26
+ }
27
+ /** Hand-rolled, like `parseDevArgs`: the CLI has no argument-parsing dependency. */
28
+ declare function parseSimulateArgs(args: readonly string[]): SimulateArgs;
29
+ interface LoadSchemaOptions {
30
+ readonly cwd: string;
31
+ readonly outDir: string;
32
+ /** Redirects `@irtio/*` for the bundle; the monorepo's own `src/` by default (see `dev.ts`). */
33
+ readonly irtioPackages?: Record<string, string> | undefined;
34
+ }
35
+ /**
36
+ * Bundles `irtio/schema.ts` and imports it, returning its `schema` export — or `undefined` when
37
+ * the project has no schema module at all.
38
+ */
39
+ declare function loadProjectSchema(options: LoadSchemaOptions): Promise<{
40
+ schema: AnySchema;
41
+ file: string;
42
+ } | undefined>;
43
+ interface RunSimulationOptions extends Partial<SimulateArgs> {
44
+ /** Project root; defaults to `process.cwd()`. */
45
+ readonly cwd?: string;
46
+ readonly log?: (line: string) => void;
47
+ /** @internal Test seam, same as `startDev`'s. */
48
+ readonly irtioPackages?: Record<string, string> | undefined;
49
+ }
50
+ interface SimulationRun {
51
+ readonly report: SimulationReport;
52
+ readonly tracePath: string;
53
+ /** `false` when there was no schema module and the run fell back to a relay simulation. */
54
+ readonly schema: boolean;
55
+ /** Everything that was printed, in order — the same lines `log` received. */
56
+ readonly output: readonly string[];
57
+ }
58
+ /** The whole command, minus argv parsing and the exit code — tests drive this in-process. */
59
+ declare function runSimulation(options?: RunSimulationOptions): Promise<SimulationRun>;
60
+ declare function simulate(args: readonly string[]): Promise<void>;
61
+
62
+ export { type LoadSchemaOptions, type RunSimulationOptions, type SimulateArgs, type SimulationRun, loadProjectSchema, parseSimulateArgs, runSimulation, simulate };