iterate 0.2.2 → 0.2.3

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 (3) hide show
  1. package/README.md +10 -10
  2. package/bin/iterate.js +317 -437
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -29,11 +29,10 @@ Initial setup (writes auth + launcher config):
29
29
 
30
30
  ```bash
31
31
  npx iterate setup \
32
- --base-url https://dev-yourname-os.dev.iterate.com \
32
+ --os-base-url https://dev-yourname-os.dev.iterate.com \
33
+ --daemon-base-url http://localhost:3001 \
33
34
  --admin-password-env-var-name SERVICE_AUTH_TOKEN \
34
35
  --user-email dev-yourname@iterate.com \
35
- --repo-path managed \
36
- --auto-install true \
37
36
  --scope global
38
37
  ```
39
38
 
@@ -51,6 +50,9 @@ npx iterate os project list
51
50
  - `iterate install` - force clone/install for resolved checkout
52
51
  - `iterate whoami`
53
52
  - `iterate os ...`
53
+ - `iterate daemon ...`
54
+
55
+ `setup --scope global` writes auth + launcher values into `global`; `setup --scope workspace` writes them into `workspaces[process.cwd()]`.
54
56
 
55
57
  ## Config file
56
58
 
@@ -70,11 +72,10 @@ Config shape:
70
72
  },
71
73
  "workspaces": {
72
74
  "/absolute/workspace/path": {
73
- "baseUrl": "https://dev-yourname-os.dev.iterate.com",
75
+ "osBaseUrl": "https://dev-yourname-os.dev.iterate.com",
76
+ "daemonBaseUrl": "http://localhost:3001",
74
77
  "adminPasswordEnvVarName": "SERVICE_AUTH_TOKEN",
75
- "userEmail": "dev-yourname@iterate.com",
76
- "repoPath": "/absolute/path/to/iterate",
77
- "autoInstall": false
78
+ "userEmail": "dev-yourname@iterate.com"
78
79
  }
79
80
  }
80
81
  }
@@ -114,11 +115,10 @@ You can pin explicitly:
114
115
 
115
116
  ```bash
116
117
  npx iterate setup \
117
- --base-url https://dev-yourname-os.dev.iterate.com \
118
+ --os-base-url https://dev-yourname-os.dev.iterate.com \
119
+ --daemon-base-url http://localhost:3001 \
118
120
  --admin-password-env-var-name SERVICE_AUTH_TOKEN \
119
121
  --user-email dev-yourname@iterate.com \
120
- --repo-path local \
121
- --auto-install false \
122
122
  --scope workspace
123
123
  ```
124
124
 
package/bin/iterate.js CHANGED
@@ -1,66 +1,130 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
3
 
4
- import { spawn } from "node:child_process";
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
- import { dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { dirname, join, resolve } from "node:path";
8
8
  import process from "node:process";
9
- import { pathToFileURL } from "node:url";
10
- import * as prompts from "@clack/prompts";
11
- import { createTRPCClient, httpLink } from "@trpc/client";
12
- import { initTRPC } from "@trpc/server";
13
- import { createAuthClient } from "better-auth/client";
14
- import { adminClient } from "better-auth/client/plugins";
15
- import superjson from "superjson";
16
- import { createCli } from "trpc-cli";
17
- import { proxify } from "trpc-cli/dist/proxify.js";
18
- import { z } from "zod/v4";
19
-
20
- const DEFAULT_REPO_URL = "https://github.com/iterate/iterate.git";
21
- const XDG_CONFIG_PATH = join(
22
- process.env.XDG_CONFIG_HOME ? process.env.XDG_CONFIG_HOME : join(homedir(), ".config"),
23
- "iterate",
24
- "config.json",
25
- );
26
- const XDG_REPO_DIR = join(
27
- process.env.XDG_DATA_HOME ? process.env.XDG_DATA_HOME : join(homedir(), ".local", "share"),
28
- "iterate",
29
- "repo",
30
- );
31
- const DEFAULT_REPO_DIR = XDG_REPO_DIR;
32
- const CONFIG_PATH = XDG_CONFIG_PATH;
33
- const APP_ROUTER_PATH = join("apps", "os", "backend", "trpc", "root.ts");
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ // --- Local delegation (must run before any heavy imports) ---
34
12
 
35
13
  /**
36
- * @typedef {{
37
- * repoPath?: string;
38
- * repoRef?: string;
39
- * repoUrl?: string;
40
- * autoInstall?: boolean;
41
- * }} LauncherConfig
14
+ * Walk up from `startDir` looking for `relativePath` to exist.
15
+ * Returns the directory where it was found, or null.
16
+ * @param {string} relativePath
17
+ * @param {string} [startDir]
18
+ * @returns {string | null}
42
19
  */
20
+ const findUp = (relativePath, startDir = process.cwd()) => {
21
+ let dir = resolve(startDir);
22
+ while (true) {
23
+ if (existsSync(join(dir, relativePath))) return dir;
24
+ const parent = dirname(dir);
25
+ if (parent === dir) return null;
26
+ dir = parent;
27
+ }
28
+ };
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
43
31
 
44
32
  /**
45
- * @typedef {{
46
- * global?: Record<string, unknown>;
47
- * workspaces?: Record<string, Record<string, unknown>>;
48
- * } & Record<string, unknown>} ConfigFile
33
+ * If we're already running from a local version, skip delegation to avoid loops.
34
+ * Otherwise, find the closest local iterate CLI and re-exec into it.
49
35
  */
36
+ const delegateToLocal = () => {
37
+ if (process.env.__ITERATE_CLI_DELEGATED) return;
38
+
39
+ const selfReal = realpathSync(__filename);
40
+
41
+ // 1. Check if we're inside the iterate repo (has pnpm-workspace.yaml at root)
42
+ const repoRoot = findUp("pnpm-workspace.yaml");
43
+ if (repoRoot) {
44
+ const repoScript = join(repoRoot, "packages/iterate/bin/iterate.js");
45
+ if (existsSync(repoScript) && realpathSync(repoScript) !== selfReal) {
46
+ reExec(repoScript);
47
+ return;
48
+ }
49
+ }
50
50
 
51
- /** @typedef {"env" | "config" | "cwd" | "default"} RepoDirSource */
51
+ // 2. Check for a local node_modules install
52
+ const nmRoot = findUp("node_modules/.bin/iterate");
53
+ if (nmRoot) {
54
+ const nmScript = join(nmRoot, "node_modules/.bin/iterate");
55
+ if (existsSync(nmScript) && realpathSync(nmScript) !== selfReal) {
56
+ reExec(nmScript);
57
+ return;
58
+ }
59
+ }
60
+ };
52
61
 
53
62
  /**
54
- * @typedef {{
55
- * repoDir: string;
56
- * repoDirSource: RepoDirSource;
57
- * repoRef?: string;
58
- * repoUrl: string;
59
- * autoInstall: boolean;
60
- * cwdRepoDir?: string;
61
- * launcherConfig: LauncherConfig;
62
- * }} RuntimeOptions
63
+ * Re-exec into `scriptPath` with the same argv, never returning.
64
+ * @param {string} scriptPath
63
65
  */
66
+ const reExec = (scriptPath) => {
67
+ try {
68
+ execFileSync(process.execPath, [scriptPath, ...process.argv.slice(2)], {
69
+ stdio: "inherit",
70
+ env: { ...process.env, __ITERATE_CLI_DELEGATED: "1" },
71
+ });
72
+ } catch (e) {
73
+ process.exit(e && typeof e === "object" && "status" in e ? Number(e.status) || 1 : 1);
74
+ }
75
+ process.exit(0);
76
+ };
77
+
78
+ delegateToLocal();
79
+
80
+ // --- Normal CLI startup (dynamic imports so delegation can short-circuit first) ---
81
+
82
+ const prompts = await import("@clack/prompts");
83
+ const { createTRPCClient, httpLink } = await import("@trpc/client");
84
+ const { initTRPC } = await import("@trpc/server");
85
+ const { createAuthClient } = await import("better-auth/client");
86
+ const { adminClient } = await import("better-auth/client/plugins");
87
+ const { default: superjson } = await import("superjson");
88
+ const { createCli } = await import("trpc-cli");
89
+ const { proxify } = await import("trpc-cli/dist/proxify.js");
90
+ const { z } = await import("zod/v4");
91
+
92
+ const XDG_CONFIG_PARENT = join(
93
+ process.env.XDG_CONFIG_HOME ? process.env.XDG_CONFIG_HOME : join(homedir(), ".config"),
94
+ "iterate",
95
+ );
96
+
97
+ const XDG_CONFIG_PATH = join(XDG_CONFIG_PARENT, "config.json");
98
+ const CONFIG_PATH = XDG_CONFIG_PATH;
99
+ // todo write json schema to file too - need to make everything zod first
100
+ // const CONFIG_SCHEMA_PATH = join(XDG_CONFIG_PARENT, "config-schema.json");
101
+
102
+ const SetupInput = z.object({
103
+ osBaseUrl: z
104
+ .string()
105
+ .describe(`Base URL for OS API (for example https://dev-yourname-os.dev.iterate.com)`),
106
+ daemonBaseUrl: z.string().describe(`Base URL for daemon API (for example http://localhost:3001)`),
107
+ adminPasswordEnvVarName: z.string().describe("Env var name containing admin password"),
108
+ userEmail: z.string().describe("User email to impersonate for OS calls"),
109
+ scope: z.enum(["workspace", "global"]).describe("Where to store launcher config"),
110
+ });
111
+
112
+ const AuthConfig = z.object({
113
+ osBaseUrl: z.string(),
114
+ daemonBaseUrl: z.string(),
115
+ adminPasswordEnvVarName: z.string(),
116
+ userEmail: z.string(),
117
+ });
118
+
119
+ const ConfigFile = z.object({
120
+ global: AuthConfig.partial().optional(),
121
+ workspaces: z.record(z.string(), AuthConfig).optional(),
122
+ /** a place where I put old/invalid configs I can't quite let go of */
123
+ rubbish: z.unknown().optional(),
124
+ });
125
+
126
+ /** @typedef {import('zod').infer<typeof AuthConfig>} AuthConfig */
127
+ /** @typedef {import('zod').infer<typeof ConfigFile>} ConfigFile */
64
128
 
65
129
  /**
66
130
  * @typedef {{
@@ -71,14 +135,6 @@ const APP_ROUTER_PATH = join("apps", "os", "backend", "trpc", "root.ts");
71
135
  * }} SpawnOptions
72
136
  */
73
137
 
74
- /**
75
- * @typedef {{
76
- * repoDir: string;
77
- * repoRef?: string;
78
- * repoUrl: string;
79
- * }} CheckoutOptions
80
- */
81
-
82
138
  const isAgent =
83
139
  process.env.AGENT === "1" ||
84
140
  process.env.OPENCODE === "1" ||
@@ -87,28 +143,6 @@ const isAgent =
87
143
 
88
144
  const t = initTRPC.meta().create();
89
145
 
90
- const SetupInput = z.object({
91
- baseUrl: z
92
- .string()
93
- .describe(`Base URL for os API (for example https://dev-yourname-os.dev.iterate.com)`),
94
- adminPasswordEnvVarName: z.string().describe("Env var name containing admin password"),
95
- userEmail: z.string().describe("User email to impersonate for os calls"),
96
- repoPath: z.string().describe("Path to iterate checkout (or 'local' / 'managed' shortcuts)"),
97
- autoInstall: z.boolean().describe("Auto install dependencies when missing"),
98
- scope: z.enum(["workspace", "global"]).describe("Where to store launcher config"),
99
- });
100
-
101
- const AuthConfig = z.object({
102
- baseUrl: z.string(),
103
- adminPasswordEnvVarName: z.string(),
104
- userEmail: z.string(),
105
- });
106
-
107
- /** @param {string} message */
108
- const log = (message) => {
109
- process.stderr.write(`[iterate] ${message}\n`);
110
- };
111
-
112
146
  /**
113
147
  * @param {unknown} value
114
148
  * @returns {value is Record<string, unknown>}
@@ -117,44 +151,6 @@ const isObject = (value) => {
117
151
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
118
152
  };
119
153
 
120
- /** @param {unknown} value */
121
- const nonEmptyString = (value) => {
122
- if (typeof value !== "string") {
123
- return undefined;
124
- }
125
- const trimmed = value.trim();
126
- return trimmed.length > 0 ? trimmed : undefined;
127
- };
128
-
129
- /** @param {string} input */
130
- const normalizePath = (input) => {
131
- if (input === "~") {
132
- return homedir();
133
- }
134
- if (input.startsWith("~/")) {
135
- return join(homedir(), input.slice(2));
136
- }
137
- if (isAbsolute(input)) {
138
- return input;
139
- }
140
- return resolve(input);
141
- };
142
-
143
- /** @param {unknown} value */
144
- const parseBoolean = (value) => {
145
- if (typeof value !== "string") {
146
- return undefined;
147
- }
148
- const normalized = value.toLowerCase();
149
- if (normalized === "1" || normalized === "true") {
150
- return true;
151
- }
152
- if (normalized === "0" || normalized === "false") {
153
- return false;
154
- }
155
- return undefined;
156
- };
157
-
158
154
  /** @returns {ConfigFile} */
159
155
  const readConfigFile = () => {
160
156
  if (!existsSync(CONFIG_PATH)) {
@@ -169,30 +165,9 @@ const readConfigFile = () => {
169
165
  throw new Error(`Invalid JSON in ${CONFIG_PATH}: ${detail}`);
170
166
  }
171
167
 
172
- if (!isObject(parsed)) {
173
- throw new Error(`${CONFIG_PATH} must contain a JSON object.`);
174
- }
175
168
  return parsed;
176
169
  };
177
170
 
178
- /** @param {unknown} launcher */
179
- const sanitizeLauncherConfig = (launcher) => {
180
- if (!isObject(launcher)) {
181
- return {};
182
- }
183
- return {
184
- repoPath: nonEmptyString(launcher.repoPath),
185
- repoRef: nonEmptyString(launcher.repoRef),
186
- repoUrl: nonEmptyString(launcher.repoUrl),
187
- autoInstall: typeof launcher.autoInstall === "boolean" ? launcher.autoInstall : undefined,
188
- };
189
- };
190
-
191
- /** @param {ConfigFile} configFile */
192
- const getGlobalConfig = (configFile) => {
193
- return isObject(configFile.global) ? configFile.global : {};
194
- };
195
-
196
171
  /**
197
172
  * @param {ConfigFile} configFile
198
173
  * @param {string} workspacePath
@@ -209,119 +184,42 @@ const getWorkspaceConfig = (configFile, workspacePath) => {
209
184
  */
210
185
  const getMergedWorkspaceConfig = (configFile, workspacePath) => {
211
186
  return {
212
- ...getGlobalConfig(configFile),
187
+ ...configFile.global,
213
188
  ...getWorkspaceConfig(configFile, workspacePath),
214
189
  };
215
190
  };
216
191
 
217
- /** @param {string} workspacePath */
218
- const readLauncherConfig = (workspacePath) => {
219
- const configFile = readConfigFile();
220
- return sanitizeLauncherConfig(getMergedWorkspaceConfig(configFile, workspacePath));
221
- };
222
-
223
192
  /**
224
- * @param {{
225
- * launcherPatch: Partial<LauncherConfig>;
226
- * workspacePatch?: Record<string, unknown>;
227
- * scope: "workspace" | "global";
228
- * workspacePath: string;
229
- * }} options
193
+ * @param {{ patch?: Partial<AuthConfig>; scope: "workspace" | "global"; workspacePath: string; }} options
194
+ * @returns {ConfigFile}
230
195
  */
231
- const writeLauncherConfig = ({ launcherPatch, workspacePatch, scope, workspacePath }) => {
232
- const configFile = readConfigFile();
233
- const existingGlobal = getGlobalConfig(configFile);
234
- const existingWorkspaces = isObject(configFile.workspaces) ? configFile.workspaces : {};
235
-
236
- const nextGlobal = scope === "global" ? { ...existingGlobal, ...launcherPatch } : existingGlobal;
237
- const nextWorkspaces =
238
- scope === "workspace" || workspacePatch
239
- ? {
240
- ...existingWorkspaces,
241
- [workspacePath]: {
242
- ...getWorkspaceConfig(configFile, workspacePath),
243
- ...(scope === "workspace" ? launcherPatch : {}),
244
- ...(workspacePatch ?? {}),
245
- },
246
- }
247
- : existingWorkspaces;
248
-
249
- mkdirSync(dirname(CONFIG_PATH), { recursive: true });
250
- const { launcher: _unusedLauncher, ...rest } = configFile;
251
- const next = {
252
- ...rest,
253
- global: nextGlobal,
254
- workspaces: nextWorkspaces,
255
- };
256
- writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`);
257
- return next;
258
- };
259
-
260
- /** @param {string} dir */
261
- const isIterateRepo = (dir) => {
262
- return (
263
- existsSync(join(dir, ".git")) &&
264
- existsSync(join(dir, "pnpm-workspace.yaml")) &&
265
- existsSync(join(dir, APP_ROUTER_PATH))
196
+ const writeNewConfig = ({ patch, scope, workspacePath }) => {
197
+ patch = Object.fromEntries(
198
+ Object.entries(patch || {}).filter(([_key, value]) => value !== undefined),
266
199
  );
267
- };
200
+ const configFile = readConfigFile();
201
+ const cloned = structuredClone(configFile);
268
202
 
269
- /** @param {string} startDir */
270
- const findNearestIterateRepo = (startDir) => {
271
- let current = resolve(startDir);
272
- for (;;) {
273
- if (isIterateRepo(current)) {
274
- return current;
275
- }
276
- const parent = dirname(current);
277
- if (parent === current) {
278
- return undefined;
279
- }
280
- current = parent;
203
+ if (scope === "global") {
204
+ cloned.global = { ...configFile.global, ...patch };
281
205
  }
282
- };
283
-
284
- /** @returns {RuntimeOptions} */
285
- const resolveRuntimeOptions = () => {
286
- const launcherConfig = readLauncherConfig(process.cwd());
287
- const cwdRepoDir = findNearestIterateRepo(process.cwd());
288
- const envRepoDir = nonEmptyString(process.env.ITERATE_REPO_DIR);
289
-
290
- let repoDir;
291
- /** @type {RepoDirSource} */
292
- let repoDirSource;
293
-
294
- if (envRepoDir) {
295
- repoDir = normalizePath(envRepoDir);
296
- repoDirSource = "env";
297
- } else if (launcherConfig.repoPath) {
298
- repoDir = normalizePath(launcherConfig.repoPath);
299
- repoDirSource = "config";
300
- } else if (cwdRepoDir) {
301
- repoDir = cwdRepoDir;
302
- repoDirSource = "cwd";
303
- } else {
304
- repoDir = DEFAULT_REPO_DIR;
305
- repoDirSource = "default";
206
+ if (scope === "workspace" && workspacePath) {
207
+ cloned.workspaces ||= {};
208
+ // @ts-expect-error - we know it's a string
209
+ cloned.workspaces[workspacePath] = {
210
+ ...configFile.workspaces?.[workspacePath],
211
+ ...patch,
212
+ };
306
213
  }
307
214
 
308
- const repoRef = nonEmptyString(process.env.ITERATE_REPO_REF) ?? launcherConfig.repoRef;
309
- const repoUrl =
310
- nonEmptyString(process.env.ITERATE_REPO_URL) ?? launcherConfig.repoUrl ?? DEFAULT_REPO_URL;
311
- const autoInstall =
312
- parseBoolean(process.env.ITERATE_AUTO_INSTALL) ??
313
- launcherConfig.autoInstall ??
314
- (repoDirSource === "cwd" ? false : true);
215
+ const parsed = ConfigFile.safeParse(cloned);
216
+ if (!parsed.success) {
217
+ throw new Error(`Invalid config file: ${z.prettifyError(parsed.error)}`);
218
+ }
315
219
 
316
- return {
317
- repoDir,
318
- repoDirSource,
319
- repoRef,
320
- repoUrl,
321
- autoInstall,
322
- cwdRepoDir,
323
- launcherConfig,
324
- };
220
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
221
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(parsed.data, null, 2)}\n`);
222
+ return cloned;
325
223
  };
326
224
 
327
225
  /** @param {string} workspacePath */
@@ -330,8 +228,8 @@ const readAuthConfig = (workspacePath) => {
330
228
  const mergedConfig = getMergedWorkspaceConfig(configFile, workspacePath);
331
229
  const parsed = AuthConfig.safeParse(mergedConfig);
332
230
  if (!parsed.success) {
333
- throw new Error(
334
- `Config file ${CONFIG_PATH} is missing auth config for ${workspacePath}. Have you run \`iterate setup\`?\n${z.prettifyError(parsed.error)}`,
231
+ return new Error(
232
+ `Invalid auth config for ${workspacePath} (in config file ${CONFIG_PATH}). Have you run \`iterate setup\`?\n${z.prettifyError(parsed.error)}`,
335
233
  );
336
234
  }
337
235
  return parsed.data;
@@ -421,11 +319,12 @@ const resolveImpersonationUserId = async ({ superadminAuthClient, userEmail, bas
421
319
  return resolvedUserId;
422
320
  };
423
321
 
424
- /** @param {z.infer<typeof AuthConfig>} authConfig */
425
- const authDance = async (authConfig) => {
322
+ /** @param {import('zod').infer<typeof AuthConfig>} authConfig */
323
+ const osAuthDance = async (authConfig) => {
324
+ /** @type {string[] | undefined} */
426
325
  let superadminSetCookie;
427
326
  const authClient = createAuthClient({
428
- baseURL: authConfig.baseUrl,
327
+ baseURL: authConfig.osBaseUrl,
429
328
  fetchOptions: {
430
329
  throw: true,
431
330
  },
@@ -447,11 +346,11 @@ const authDance = async (authConfig) => {
447
346
  });
448
347
 
449
348
  const superadminAuthClient = createAuthClient({
450
- baseURL: authConfig.baseUrl,
349
+ baseURL: authConfig.osBaseUrl,
451
350
  fetchOptions: {
452
351
  throw: true,
453
352
  onRequest: (ctx) => {
454
- ctx.headers.set("origin", authConfig.baseUrl);
353
+ ctx.headers.set("origin", authConfig.osBaseUrl);
455
354
  ctx.headers.set("cookie", setCookiesToCookieHeader(superadminSetCookie));
456
355
  },
457
356
  },
@@ -461,7 +360,7 @@ const authDance = async (authConfig) => {
461
360
  const userId = await resolveImpersonationUserId({
462
361
  superadminAuthClient,
463
362
  userEmail: authConfig.userEmail,
464
- baseUrl: authConfig.baseUrl,
363
+ baseUrl: authConfig.osBaseUrl,
465
364
  });
466
365
 
467
366
  let impersonateSetCookie;
@@ -478,11 +377,11 @@ const authDance = async (authConfig) => {
478
377
  const userCookies = setCookiesToCookieHeader(impersonateSetCookie);
479
378
 
480
379
  const userClient = createAuthClient({
481
- baseURL: authConfig.baseUrl,
380
+ baseURL: authConfig.osBaseUrl,
482
381
  fetchOptions: {
483
382
  throw: true,
484
383
  onRequest: (ctx) => {
485
- ctx.headers.set("origin", authConfig.baseUrl);
384
+ ctx.headers.set("origin", authConfig.osBaseUrl);
486
385
  ctx.headers.set("cookie", userCookies);
487
386
  },
488
387
  },
@@ -491,139 +390,38 @@ const authDance = async (authConfig) => {
491
390
  return { userCookies, userClient };
492
391
  };
493
392
 
494
- /** @param {string} repoDir */
495
- const loadAppRouter = async (repoDir) => {
496
- const appRouterPath = join(repoDir, APP_ROUTER_PATH);
497
- if (!existsSync(appRouterPath)) {
498
- throw new Error(`Could not find ${APP_ROUTER_PATH} under ${repoDir}.`);
393
+ /** @param {{baseUrl: string}} params */
394
+ const loadAppRouter = async (params) => {
395
+ const url = `${params.baseUrl}/api/trpc-cli-procedures`;
396
+ const response = await fetch(url);
397
+ if (!response.ok) {
398
+ throw new Error(`${url} got ${response.status}: ${await response.text()}`);
499
399
  }
500
- const rootModule = await import(pathToFileURL(appRouterPath).href);
501
- if (!rootModule || typeof rootModule !== "object" || !("appRouter" in rootModule)) {
502
- throw new Error(`Failed to load appRouter from ${appRouterPath}`);
503
- }
504
- return rootModule.appRouter;
505
- };
506
-
507
- /** @param {unknown} error */
508
- const commandMissing = (error) => {
509
- return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
510
- };
511
-
512
- /** @param {SpawnOptions} options */
513
- const run = ({ command, args, cwd, env }) => {
514
- return new Promise((resolvePromise, rejectPromise) => {
515
- const child = spawn(command, args, {
516
- cwd,
517
- env,
518
- stdio: "inherit",
519
- });
520
-
521
- child.on("error", (error) => {
522
- rejectPromise(error);
523
- });
524
400
 
525
- child.on("close", (code, signal) => {
526
- if (signal) {
527
- rejectPromise(new Error(`${command} exited with signal ${signal}`));
528
- return;
529
- }
530
- resolvePromise(code ?? 0);
531
- });
401
+ const router = await response.json().catch((e) => {
402
+ throw new Error(`${url} returned invalid router: ${e.message}`);
532
403
  });
533
- };
534
-
535
- /** @param {SpawnOptions} options */
536
- const runChecked = async ({ command, args, cwd, env }) => {
537
- const code = await run({ command, args, cwd, env });
538
- if (code !== 0) {
539
- throw new Error(`Command failed (${code}): ${command} ${args.join(" ")}`);
540
- }
541
- };
542
-
543
- /** @param {CheckoutOptions} options */
544
- const ensureRepoCheckout = async ({ repoDir, repoRef, repoUrl }) => {
545
- if (existsSync(repoDir)) {
546
- if (!existsSync(join(repoDir, APP_ROUTER_PATH))) {
547
- throw new Error(`Expected ${APP_ROUTER_PATH} in ${repoDir}.`);
548
- }
549
- if (!existsSync(join(repoDir, ".git"))) {
550
- throw new Error(`Expected git checkout at ${repoDir}, but .git is missing.`);
551
- }
552
- return;
553
- }
554
-
555
- mkdirSync(dirname(repoDir), { recursive: true });
556
- const cloneArgs = ["clone", "--depth", "1"];
557
- if (repoRef) {
558
- cloneArgs.push("--branch", repoRef, "--single-branch");
559
- }
560
- cloneArgs.push(repoUrl, repoDir);
561
-
562
- log(`cloning iterate repo into ${repoDir}`);
563
- try {
564
- await runChecked({ command: "git", args: cloneArgs });
565
- const envClientPath = join(repoDir, "apps/os/env-client.ts");
566
- // todo: remove this as soon as this branch is merged into main
567
- writeFileSync(
568
- envClientPath,
569
- readFileSync(envClientPath, "utf8").replace("import.meta.env.", "import.meta.env?."),
570
- );
571
- } catch (error) {
572
- if (commandMissing(error)) {
573
- throw new Error("git is required but was not found on PATH.");
574
- }
575
- throw error;
576
- }
577
- };
578
-
579
- /** @param {string} repoDir */
580
- const hasInstalledDependencies = (repoDir) => {
581
- return existsSync(join(repoDir, "node_modules", ".modules.yaml"));
582
- };
583
-
584
- /** @param {{ repoDir: string }} options */
585
- const installDependencies = async ({ repoDir }) => {
586
- log("installing dependencies with pnpm");
587
- const installArgs = ["install", "--frozen-lockfile"];
588
- try {
589
- await runChecked({
590
- command: "corepack",
591
- args: ["pnpm", ...installArgs],
592
- cwd: repoDir,
593
- });
594
- return;
595
- } catch (error) {
596
- if (!commandMissing(error)) {
597
- throw error;
598
- }
599
- }
600
-
601
- try {
602
- await runChecked({
603
- command: "pnpm",
604
- args: installArgs,
605
- cwd: repoDir,
606
- });
607
- } catch (error) {
608
- if (commandMissing(error)) {
609
- throw new Error("pnpm/corepack is required but was not found on PATH.");
610
- }
611
- throw error;
404
+ if (!Array.isArray(router?.procedures)) {
405
+ throw new Error(`${url} returned invalid router: ${JSON.stringify(router)}`);
612
406
  }
407
+ /** @type {{procedures: any[]}} */
408
+ return router;
613
409
  };
614
410
 
615
- /** @param {string} repoDir */
616
- const getRuntimeProcedures = async (repoDir) => {
617
- const appRouter = await loadAppRouter(repoDir);
618
- const proxiedRouter = proxify(appRouter, async () => {
411
+ /** @param {{ baseUrl: string }} params */
412
+ const getOsProcedures = async (params) => {
413
+ const appRouter = await loadAppRouter(params);
414
+ /** @type {{}} */
415
+ const proxiedRouter = proxify(appRouter.procedures, async () => {
619
416
  return createTRPCClient({
620
417
  links: [
621
418
  httpLink({
622
- url: `${readAuthConfig(process.cwd()).baseUrl}/api/trpc/`,
419
+ url: `${params.baseUrl}/api/trpc/`,
623
420
  transformer: superjson,
624
421
  fetch: async (request, init) => {
625
422
  const authConfig = readAuthConfig(process.cwd());
626
- const { userCookies } = await authDance(authConfig);
423
+ if (authConfig instanceof Error) throw authConfig;
424
+ const { userCookies } = await osAuthDance(authConfig);
627
425
  const headers = new Headers(init?.headers);
628
426
  headers.set("cookie", userCookies);
629
427
  return fetch(request, { ...init, headers });
@@ -633,92 +431,176 @@ const getRuntimeProcedures = async (repoDir) => {
633
431
  });
634
432
  });
635
433
 
636
- return {
637
- whoami: t.procedure.mutation(async () => {
638
- const authConfig = readAuthConfig(process.cwd());
639
- const { userClient } = await authDance(authConfig);
640
- return await userClient.getSession();
641
- }),
642
- os: proxiedRouter,
434
+ return proxiedRouter;
435
+ };
436
+
437
+ /**
438
+ * Creates a fetch wrapper that calls /api/trpc-stream/* instead of /api/trpc/*.
439
+ * The streaming endpoint returns SSE: log lines as `event: log` and the final
440
+ * tRPC response as `event: response`, which we reassemble into a normal Response.
441
+ * @param {string} daemonBaseUrl
442
+ * @returns {typeof globalThis.fetch}
443
+ */
444
+ const streamingFetch = (daemonBaseUrl) => {
445
+ return async (/** @type {any} */ input, /** @type {any} */ init) => {
446
+ // Rewrite URL from /api/trpc/X to /api/trpc-stream/X
447
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
448
+ const rewritten = url.replace(
449
+ `${daemonBaseUrl}/api/trpc/`,
450
+ `${daemonBaseUrl}/api/trpc-stream/`,
451
+ );
452
+ const res = await fetch(rewritten, init);
453
+ if (rewritten === url) return res;
454
+
455
+ const contentType = res.headers.get("content-type") || "";
456
+ // If the daemon didn't respond with SSE, pass through as-is (non-streaming endpoint)
457
+ if (!contentType.includes("text/event-stream")) return res;
458
+ // Parse SSE stream: print log events to stderr, collect the final response
459
+ const reader = res.body?.getReader();
460
+ if (!reader) return res;
461
+ const decoder = new TextDecoder();
462
+ let buffer = "";
463
+ /** @type {string | null} */
464
+ let responseBody = null;
465
+ while (true) {
466
+ const { done, value } = await reader.read();
467
+ if (done) break;
468
+ buffer += decoder.decode(value, { stream: true });
469
+ // Process complete SSE messages (double newline delimited)
470
+ const parts = buffer.split("\n\n");
471
+ buffer = parts.pop() || "";
472
+ for (const part of parts) {
473
+ const lines = part.split("\n");
474
+ const event = lines[0].split(": ")[1];
475
+ const data = lines[1].split(": ").slice(1).join(": ");
476
+ if (event === "log") {
477
+ /** @type {{level: "debug" | "info" | "warn" | "error"; args: unknown[]}} */
478
+ const detail = JSON.parse(data);
479
+ console[detail.level](...detail.args);
480
+ } else if (event === "response") {
481
+ responseBody = data;
482
+ }
483
+ }
484
+ }
485
+ // Reconstruct a normal Response from the final payload so tRPC client is happy
486
+ return new Response(responseBody, {
487
+ status: res.status,
488
+ headers: { "content-type": "application/json" },
489
+ });
643
490
  };
644
491
  };
645
492
 
493
+ /** @param {{ daemonBaseUrl: string }} params */
494
+ const getDaemonProcedures = async (params) => {
495
+ const daemonRouter = await loadAppRouter({ baseUrl: params.daemonBaseUrl });
496
+ const proxiedRouter = proxify(daemonRouter.procedures, async () => {
497
+ return createTRPCClient({
498
+ links: [
499
+ httpLink({
500
+ url: `${params.daemonBaseUrl}/api/trpc/`,
501
+ fetch: streamingFetch(params.daemonBaseUrl),
502
+ }),
503
+ ],
504
+ });
505
+ });
506
+
507
+ return proxiedRouter;
508
+ };
509
+
646
510
  const launcherProcedures = {
647
511
  doctor: t.procedure
648
512
  .meta({ description: "Show launcher config and resolved runtime options" })
649
513
  .mutation(async () => {
650
- const runtime = resolveRuntimeOptions();
651
- return {
652
- configPath: CONFIG_PATH,
653
- repoDir: runtime.repoDir,
654
- repoDirSource: runtime.repoDirSource,
655
- autoInstall: runtime.autoInstall,
656
- repoRef: runtime.repoRef ?? null,
657
- repoUrl: runtime.repoUrl,
658
- cwdRepoDir: runtime.cwdRepoDir ?? null,
659
- repoExists: existsSync(runtime.repoDir),
660
- dependenciesInstalled: hasInstalledDependencies(runtime.repoDir),
661
- };
514
+ const configFile = readConfigFile();
515
+ const parsed = ConfigFile.safeParse(configFile);
516
+ if (!parsed.success) {
517
+ throw new Error(`Invalid config file ${CONFIG_PATH}: ${z.prettifyError(parsed.error)}`);
518
+ }
519
+ const current = readAuthConfig(process.cwd());
520
+ if (current instanceof Error) throw current;
521
+ return { configPath: CONFIG_PATH, current };
662
522
  }),
663
523
  setup: t.procedure
664
- .input(SetupInput)
665
- .meta({ description: "Configure auth + launcher defaults for current workspace" })
524
+ .input(SetupInput.partial())
525
+ .meta({ prompt: true, description: "Configure auth + launcher defaults for current workspace" })
666
526
  .mutation(async ({ input }) => {
667
- const runtime = resolveRuntimeOptions();
668
-
669
- const rawRepoPath = input.repoPath.trim().toLowerCase();
670
- let repoPath = normalizePath(input.repoPath);
671
- if (rawRepoPath === "managed") {
672
- repoPath = DEFAULT_REPO_DIR;
673
- } else if (rawRepoPath === "local") {
674
- if (!runtime.cwdRepoDir) {
675
- throw new Error(
676
- "'local' repoPath was selected but current directory is not inside an iterate repo",
677
- );
678
- }
679
- repoPath = runtime.cwdRepoDir;
680
- }
681
-
682
- const next = writeLauncherConfig({
683
- launcherPatch: { repoPath, autoInstall: input.autoInstall },
684
- workspacePatch: {
685
- baseUrl: input.baseUrl,
527
+ writeNewConfig({
528
+ scope: input.scope || "workspace",
529
+ patch: {
530
+ osBaseUrl: input.osBaseUrl,
531
+ daemonBaseUrl: input.daemonBaseUrl,
686
532
  adminPasswordEnvVarName: input.adminPasswordEnvVarName,
687
533
  userEmail: input.userEmail,
688
534
  },
689
- scope: input.scope,
690
535
  workspacePath: process.cwd(),
691
536
  });
692
- return {
693
- configPath: CONFIG_PATH,
694
- launcher: sanitizeLauncherConfig(getMergedWorkspaceConfig(next, process.cwd())),
695
- scope: input.scope,
696
- };
697
- }),
698
- install: t.procedure
699
- .meta({ description: "Clone repo if needed, then run pnpm install" })
700
- .mutation(async () => {
701
- const runtime = resolveRuntimeOptions();
702
- await ensureRepoCheckout(runtime);
703
- await installDependencies({ repoDir: runtime.repoDir });
704
- return { repoDir: runtime.repoDir };
537
+
538
+ const current = readAuthConfig(process.cwd());
539
+ if (current instanceof Error) throw current;
540
+ return { configPath: CONFIG_PATH, current };
705
541
  }),
542
+
543
+ whoami: t.procedure.mutation(async () => {
544
+ const authConfig = readAuthConfig(process.cwd());
545
+ if (authConfig instanceof Error) throw authConfig;
546
+ const { userClient } = await osAuthDance(authConfig);
547
+ return await userClient.getSession();
548
+ }),
706
549
  };
707
550
 
708
- /** @param {string[]} args */
709
- const runCli = async (args) => {
710
- const runtime = resolveRuntimeOptions();
711
- await ensureRepoCheckout(runtime);
551
+ const runCli = async () => {
552
+ const authConfig = readAuthConfig(process.cwd());
553
+
554
+ /** @type {(problem: string) => (e: Error) => {}} */
555
+ const errorProcedure = (problem) => (e) => {
556
+ const message = `${problem}: ${e.message}`;
557
+ return t.procedure.meta({ description: message }).mutation(() => {
558
+ throw new Error(problem, { cause: e });
559
+ });
560
+ };
561
+
562
+ /** @type {import("@trpc/server").AnyRouter[]} */
563
+ const routers = [t.router(launcherProcedures)];
712
564
 
713
- if (runtime.autoInstall && !hasInstalledDependencies(runtime.repoDir)) {
714
- await installDependencies({ repoDir: runtime.repoDir });
565
+ if (authConfig instanceof Error) {
566
+ routers.push(
567
+ t.router({
568
+ os: errorProcedure(`Invalid auth config`)(authConfig),
569
+ daemon: errorProcedure(`Invalid auth config`)(authConfig),
570
+ }),
571
+ );
572
+ } else {
573
+ const [osProcedures, daemonProcedures] = await Promise.allSettled([
574
+ getOsProcedures({ baseUrl: authConfig.osBaseUrl }),
575
+ getDaemonProcedures({ daemonBaseUrl: authConfig.daemonBaseUrl }),
576
+ ]);
577
+
578
+ if (osProcedures.status === "fulfilled") {
579
+ routers.push(t.router({ os: osProcedures.value }));
580
+ } else {
581
+ routers.push(
582
+ t.router({
583
+ os: errorProcedure(`Couldn't connect to os at ${authConfig.osBaseUrl}`)(
584
+ osProcedures.reason,
585
+ ),
586
+ }),
587
+ );
588
+ }
589
+ if (daemonProcedures.status === "fulfilled") {
590
+ // don't nest daemon procedures under "daemon"
591
+ routers.push(daemonProcedures.value);
592
+ } else {
593
+ routers.push(
594
+ t.router({
595
+ daemon: errorProcedure(`Couldn't connect to daemon at ${authConfig.daemonBaseUrl}`)(
596
+ daemonProcedures.reason,
597
+ ),
598
+ }),
599
+ );
600
+ }
715
601
  }
716
602
 
717
- const runtimeProcedures = await getRuntimeProcedures(runtime.repoDir);
718
- const router = t.router({
719
- ...launcherProcedures,
720
- ...runtimeProcedures,
721
- });
603
+ const router = t.mergeRouters(...routers);
722
604
 
723
605
  const cli = createCli({
724
606
  router,
@@ -727,15 +609,13 @@ const runCli = async (args) => {
727
609
  description: "Iterate CLI",
728
610
  });
729
611
 
730
- process.argv = [process.argv[0], process.argv[1], ...args];
731
612
  await cli.run({
732
613
  prompts: isAgent ? undefined : prompts,
733
614
  });
734
615
  };
735
616
 
736
617
  const main = async () => {
737
- const args = process.argv.slice(2);
738
- await runCli(args);
618
+ await runCli();
739
619
  };
740
620
 
741
621
  main().catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "CLI for iterate",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "@trpc/server": "^11.7.2",
41
41
  "better-auth": "1.4.3",
42
42
  "superjson": "^2.2.2",
43
- "trpc-cli": "^0.12.2",
43
+ "trpc-cli": "0.12.4",
44
44
  "zod": "4.1.12"
45
45
  }
46
46
  }