iterate 0.2.2 → 0.2.4

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 +328 -440
  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
@@ -208,120 +183,51 @@ const getWorkspaceConfig = (configFile, workspacePath) => {
208
183
  * @param {string} workspacePath
209
184
  */
210
185
  const getMergedWorkspaceConfig = (configFile, workspacePath) => {
211
- return {
212
- ...getGlobalConfig(configFile),
213
- ...getWorkspaceConfig(configFile, workspacePath),
214
- };
215
- };
216
-
217
- /** @param {string} workspacePath */
218
- const readLauncherConfig = (workspacePath) => {
219
- const configFile = readConfigFile();
220
- return sanitizeLauncherConfig(getMergedWorkspaceConfig(configFile, workspacePath));
186
+ const configs = [];
187
+ while (workspacePath && workspacePath !== "/") {
188
+ if (workspacePath in (configFile.workspaces || {})) {
189
+ configs.push(configFile.workspaces?.[workspacePath]);
190
+ }
191
+ workspacePath = dirname(workspacePath);
192
+ }
193
+ configs.push(configFile.global);
194
+ /** @type {AuthConfig} */
195
+ return configs.reverse().reduce((acc, config) => {
196
+ return { ...acc, ...config };
197
+ }, {});
221
198
  };
222
199
 
223
200
  /**
224
- * @param {{
225
- * launcherPatch: Partial<LauncherConfig>;
226
- * workspacePatch?: Record<string, unknown>;
227
- * scope: "workspace" | "global";
228
- * workspacePath: string;
229
- * }} options
201
+ * @param {{ patch?: Partial<AuthConfig>; scope: "workspace" | "global"; workspacePath: string; }} options
202
+ * @returns {ConfigFile}
230
203
  */
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))
204
+ const writeNewConfig = ({ patch, scope, workspacePath }) => {
205
+ patch = Object.fromEntries(
206
+ Object.entries(patch || {}).filter(([_key, value]) => value !== undefined),
266
207
  );
267
- };
208
+ const configFile = readConfigFile();
209
+ const cloned = structuredClone(configFile);
268
210
 
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;
211
+ if (scope === "global") {
212
+ cloned.global = { ...configFile.global, ...patch };
213
+ }
214
+ if (scope === "workspace" && workspacePath) {
215
+ cloned.workspaces ||= {};
216
+ // @ts-expect-error - we know it's a string
217
+ cloned.workspaces[workspacePath] = {
218
+ ...configFile.workspaces?.[workspacePath],
219
+ ...patch,
220
+ };
281
221
  }
282
- };
283
222
 
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";
223
+ const parsed = ConfigFile.safeParse(cloned);
224
+ if (!parsed.success) {
225
+ throw new Error(`Invalid config file: ${z.prettifyError(parsed.error)}`);
306
226
  }
307
227
 
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);
315
-
316
- return {
317
- repoDir,
318
- repoDirSource,
319
- repoRef,
320
- repoUrl,
321
- autoInstall,
322
- cwdRepoDir,
323
- launcherConfig,
324
- };
228
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
229
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(parsed.data, null, 2)}\n`);
230
+ return cloned;
325
231
  };
326
232
 
327
233
  /** @param {string} workspacePath */
@@ -330,8 +236,8 @@ const readAuthConfig = (workspacePath) => {
330
236
  const mergedConfig = getMergedWorkspaceConfig(configFile, workspacePath);
331
237
  const parsed = AuthConfig.safeParse(mergedConfig);
332
238
  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)}`,
239
+ return new Error(
240
+ `Invalid auth config for ${workspacePath} (in config file ${CONFIG_PATH}). Have you run \`iterate setup\`?\n${z.prettifyError(parsed.error)}`,
335
241
  );
336
242
  }
337
243
  return parsed.data;
@@ -421,11 +327,12 @@ const resolveImpersonationUserId = async ({ superadminAuthClient, userEmail, bas
421
327
  return resolvedUserId;
422
328
  };
423
329
 
424
- /** @param {z.infer<typeof AuthConfig>} authConfig */
425
- const authDance = async (authConfig) => {
330
+ /** @param {import('zod').infer<typeof AuthConfig>} authConfig */
331
+ const osAuthDance = async (authConfig) => {
332
+ /** @type {string[] | undefined} */
426
333
  let superadminSetCookie;
427
334
  const authClient = createAuthClient({
428
- baseURL: authConfig.baseUrl,
335
+ baseURL: authConfig.osBaseUrl,
429
336
  fetchOptions: {
430
337
  throw: true,
431
338
  },
@@ -447,11 +354,11 @@ const authDance = async (authConfig) => {
447
354
  });
448
355
 
449
356
  const superadminAuthClient = createAuthClient({
450
- baseURL: authConfig.baseUrl,
357
+ baseURL: authConfig.osBaseUrl,
451
358
  fetchOptions: {
452
359
  throw: true,
453
360
  onRequest: (ctx) => {
454
- ctx.headers.set("origin", authConfig.baseUrl);
361
+ ctx.headers.set("origin", authConfig.osBaseUrl);
455
362
  ctx.headers.set("cookie", setCookiesToCookieHeader(superadminSetCookie));
456
363
  },
457
364
  },
@@ -461,7 +368,7 @@ const authDance = async (authConfig) => {
461
368
  const userId = await resolveImpersonationUserId({
462
369
  superadminAuthClient,
463
370
  userEmail: authConfig.userEmail,
464
- baseUrl: authConfig.baseUrl,
371
+ baseUrl: authConfig.osBaseUrl,
465
372
  });
466
373
 
467
374
  let impersonateSetCookie;
@@ -478,11 +385,11 @@ const authDance = async (authConfig) => {
478
385
  const userCookies = setCookiesToCookieHeader(impersonateSetCookie);
479
386
 
480
387
  const userClient = createAuthClient({
481
- baseURL: authConfig.baseUrl,
388
+ baseURL: authConfig.osBaseUrl,
482
389
  fetchOptions: {
483
390
  throw: true,
484
391
  onRequest: (ctx) => {
485
- ctx.headers.set("origin", authConfig.baseUrl);
392
+ ctx.headers.set("origin", authConfig.osBaseUrl);
486
393
  ctx.headers.set("cookie", userCookies);
487
394
  },
488
395
  },
@@ -491,139 +398,38 @@ const authDance = async (authConfig) => {
491
398
  return { userCookies, userClient };
492
399
  };
493
400
 
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}.`);
401
+ /** @param {{baseUrl: string}} params */
402
+ const loadAppRouter = async (params) => {
403
+ const url = `${params.baseUrl}/api/trpc-cli-procedures`;
404
+ const response = await fetch(url);
405
+ if (!response.ok) {
406
+ throw new Error(`${url} got ${response.status}: ${await response.text()}`);
499
407
  }
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
408
 
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
- });
409
+ const router = await response.json().catch((e) => {
410
+ throw new Error(`${url} returned invalid router: ${e.message}`);
532
411
  });
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;
412
+ if (!Array.isArray(router?.procedures)) {
413
+ throw new Error(`${url} returned invalid router: ${JSON.stringify(router)}`);
576
414
  }
415
+ /** @type {{procedures: any[]}} */
416
+ return router;
577
417
  };
578
418
 
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;
612
- }
613
- };
614
-
615
- /** @param {string} repoDir */
616
- const getRuntimeProcedures = async (repoDir) => {
617
- const appRouter = await loadAppRouter(repoDir);
618
- const proxiedRouter = proxify(appRouter, async () => {
419
+ /** @param {{ baseUrl: string }} params */
420
+ const getOsProcedures = async (params) => {
421
+ const appRouter = await loadAppRouter(params);
422
+ /** @type {{}} */
423
+ const proxiedRouter = proxify(appRouter.procedures, async () => {
619
424
  return createTRPCClient({
620
425
  links: [
621
426
  httpLink({
622
- url: `${readAuthConfig(process.cwd()).baseUrl}/api/trpc/`,
427
+ url: `${params.baseUrl}/api/trpc/`,
623
428
  transformer: superjson,
624
429
  fetch: async (request, init) => {
625
430
  const authConfig = readAuthConfig(process.cwd());
626
- const { userCookies } = await authDance(authConfig);
431
+ if (authConfig instanceof Error) throw authConfig;
432
+ const { userCookies } = await osAuthDance(authConfig);
627
433
  const headers = new Headers(init?.headers);
628
434
  headers.set("cookie", userCookies);
629
435
  return fetch(request, { ...init, headers });
@@ -633,92 +439,176 @@ const getRuntimeProcedures = async (repoDir) => {
633
439
  });
634
440
  });
635
441
 
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,
442
+ return proxiedRouter;
443
+ };
444
+
445
+ /**
446
+ * Creates a fetch wrapper that calls /api/trpc-stream/* instead of /api/trpc/*.
447
+ * The streaming endpoint returns SSE: log lines as `event: log` and the final
448
+ * tRPC response as `event: response`, which we reassemble into a normal Response.
449
+ * @param {string} daemonBaseUrl
450
+ * @returns {typeof globalThis.fetch}
451
+ */
452
+ const streamingFetch = (daemonBaseUrl) => {
453
+ return async (/** @type {any} */ input, /** @type {any} */ init) => {
454
+ // Rewrite URL from /api/trpc/X to /api/trpc-stream/X
455
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
456
+ const rewritten = url.replace(
457
+ `${daemonBaseUrl}/api/trpc/`,
458
+ `${daemonBaseUrl}/api/trpc-stream/`,
459
+ );
460
+ const res = await fetch(rewritten, init);
461
+ if (rewritten === url) return res;
462
+
463
+ const contentType = res.headers.get("content-type") || "";
464
+ // If the daemon didn't respond with SSE, pass through as-is (non-streaming endpoint)
465
+ if (!contentType.includes("text/event-stream")) return res;
466
+ // Parse SSE stream: print log events to stderr, collect the final response
467
+ const reader = res.body?.getReader();
468
+ if (!reader) return res;
469
+ const decoder = new TextDecoder();
470
+ let buffer = "";
471
+ /** @type {string | null} */
472
+ let responseBody = null;
473
+ while (true) {
474
+ const { done, value } = await reader.read();
475
+ if (done) break;
476
+ buffer += decoder.decode(value, { stream: true });
477
+ // Process complete SSE messages (double newline delimited)
478
+ const parts = buffer.split("\n\n");
479
+ buffer = parts.pop() || "";
480
+ for (const part of parts) {
481
+ const lines = part.split("\n");
482
+ const event = lines[0].split(": ")[1];
483
+ const data = lines[1].split(": ").slice(1).join(": ");
484
+ if (event === "log") {
485
+ /** @type {{level: "debug" | "info" | "warn" | "error"; args: unknown[]}} */
486
+ const detail = JSON.parse(data);
487
+ console[detail.level](...detail.args);
488
+ } else if (event === "response") {
489
+ responseBody = data;
490
+ }
491
+ }
492
+ }
493
+ // Reconstruct a normal Response from the final payload so tRPC client is happy
494
+ return new Response(responseBody, {
495
+ status: res.status,
496
+ headers: { "content-type": "application/json" },
497
+ });
643
498
  };
644
499
  };
645
500
 
501
+ /** @param {{ daemonBaseUrl: string }} params */
502
+ const getDaemonProcedures = async (params) => {
503
+ const daemonRouter = await loadAppRouter({ baseUrl: params.daemonBaseUrl });
504
+ const proxiedRouter = proxify(daemonRouter.procedures, async () => {
505
+ return createTRPCClient({
506
+ links: [
507
+ httpLink({
508
+ url: `${params.daemonBaseUrl}/api/trpc/`,
509
+ fetch: streamingFetch(params.daemonBaseUrl),
510
+ }),
511
+ ],
512
+ });
513
+ });
514
+
515
+ return proxiedRouter;
516
+ };
517
+
646
518
  const launcherProcedures = {
647
519
  doctor: t.procedure
648
520
  .meta({ description: "Show launcher config and resolved runtime options" })
649
521
  .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
- };
522
+ const configFile = readConfigFile();
523
+ const parsed = ConfigFile.safeParse(configFile);
524
+ if (!parsed.success) {
525
+ throw new Error(`Invalid config file ${CONFIG_PATH}: ${z.prettifyError(parsed.error)}`);
526
+ }
527
+ const current = readAuthConfig(process.cwd());
528
+ if (current instanceof Error) throw current;
529
+ return { configPath: CONFIG_PATH, current };
662
530
  }),
663
531
  setup: t.procedure
664
- .input(SetupInput)
665
- .meta({ description: "Configure auth + launcher defaults for current workspace" })
532
+ .input(SetupInput.partial())
533
+ .meta({ prompt: true, description: "Configure auth + launcher defaults for current workspace" })
666
534
  .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,
535
+ writeNewConfig({
536
+ scope: input.scope || "workspace",
537
+ patch: {
538
+ osBaseUrl: input.osBaseUrl,
539
+ daemonBaseUrl: input.daemonBaseUrl,
686
540
  adminPasswordEnvVarName: input.adminPasswordEnvVarName,
687
541
  userEmail: input.userEmail,
688
542
  },
689
- scope: input.scope,
690
543
  workspacePath: process.cwd(),
691
544
  });
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 };
545
+
546
+ const current = readAuthConfig(process.cwd());
547
+ if (current instanceof Error) throw current;
548
+ return { configPath: CONFIG_PATH, current };
705
549
  }),
550
+
551
+ whoami: t.procedure.mutation(async () => {
552
+ const authConfig = readAuthConfig(process.cwd());
553
+ if (authConfig instanceof Error) throw authConfig;
554
+ const { userClient } = await osAuthDance(authConfig);
555
+ return await userClient.getSession();
556
+ }),
706
557
  };
707
558
 
708
- /** @param {string[]} args */
709
- const runCli = async (args) => {
710
- const runtime = resolveRuntimeOptions();
711
- await ensureRepoCheckout(runtime);
559
+ const runCli = async () => {
560
+ const authConfig = readAuthConfig(process.cwd());
712
561
 
713
- if (runtime.autoInstall && !hasInstalledDependencies(runtime.repoDir)) {
714
- await installDependencies({ repoDir: runtime.repoDir });
562
+ /** @type {(problem: string) => (e: Error) => {}} */
563
+ const errorProcedure = (problem) => (e) => {
564
+ const message = `${problem}: ${e.message}`;
565
+ return t.procedure.meta({ description: message }).mutation(() => {
566
+ throw new Error(problem, { cause: e });
567
+ });
568
+ };
569
+
570
+ /** @type {import("@trpc/server").AnyRouter[]} */
571
+ const routers = [t.router(launcherProcedures)];
572
+
573
+ if (authConfig instanceof Error) {
574
+ routers.push(
575
+ t.router({
576
+ os: errorProcedure(`Invalid auth config`)(authConfig),
577
+ daemon: errorProcedure(`Invalid auth config`)(authConfig),
578
+ }),
579
+ );
580
+ } else {
581
+ const [osProcedures, daemonProcedures] = await Promise.allSettled([
582
+ getOsProcedures({ baseUrl: authConfig.osBaseUrl }),
583
+ getDaemonProcedures({ daemonBaseUrl: authConfig.daemonBaseUrl }),
584
+ ]);
585
+
586
+ if (osProcedures.status === "fulfilled") {
587
+ routers.push(t.router({ os: osProcedures.value }));
588
+ } else {
589
+ routers.push(
590
+ t.router({
591
+ os: errorProcedure(`Couldn't connect to os at ${authConfig.osBaseUrl}`)(
592
+ osProcedures.reason,
593
+ ),
594
+ }),
595
+ );
596
+ }
597
+ if (daemonProcedures.status === "fulfilled") {
598
+ // don't nest daemon procedures under "daemon"
599
+ routers.push(daemonProcedures.value);
600
+ } else {
601
+ routers.push(
602
+ t.router({
603
+ daemon: errorProcedure(`Couldn't connect to daemon at ${authConfig.daemonBaseUrl}`)(
604
+ daemonProcedures.reason,
605
+ ),
606
+ }),
607
+ );
608
+ }
715
609
  }
716
610
 
717
- const runtimeProcedures = await getRuntimeProcedures(runtime.repoDir);
718
- const router = t.router({
719
- ...launcherProcedures,
720
- ...runtimeProcedures,
721
- });
611
+ const router = t.mergeRouters(...routers);
722
612
 
723
613
  const cli = createCli({
724
614
  router,
@@ -727,15 +617,13 @@ const runCli = async (args) => {
727
617
  description: "Iterate CLI",
728
618
  });
729
619
 
730
- process.argv = [process.argv[0], process.argv[1], ...args];
731
620
  await cli.run({
732
621
  prompts: isAgent ? undefined : prompts,
733
622
  });
734
623
  };
735
624
 
736
625
  const main = async () => {
737
- const args = process.argv.slice(2);
738
- await runCli(args);
626
+ await runCli();
739
627
  };
740
628
 
741
629
  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.4",
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
  }