iterate 0.2.0 → 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 +12 -12
  2. package/bin/iterate.js +395 -438
  3. package/package.json +3 -3
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
- --user-id usr_... \
35
- --repo-path managed \
36
- --auto-install true \
35
+ --user-email dev-yourname@iterate.com \
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
- "userId": "usr_...",
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
- --user-id usr_... \
120
- --repo-path local \
121
- --auto-install false \
121
+ --user-email dev-yourname@iterate.com \
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
- userId: z.string().describe("User ID 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
- userId: 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;
@@ -350,11 +248,83 @@ const setCookiesToCookieHeader = (setCookies) => {
350
248
  return [...byName.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
351
249
  };
352
250
 
353
- /** @param {z.infer<typeof AuthConfig>} authConfig */
354
- const authDance = async (authConfig) => {
251
+ const impersonationUserIdCache = new Map();
252
+
253
+ /**
254
+ * @param {{
255
+ * superadminAuthClient: any;
256
+ * userEmail: string;
257
+ * baseUrl: string;
258
+ * }} options
259
+ */
260
+ const resolveImpersonationUserId = async ({ superadminAuthClient, userEmail, baseUrl }) => {
261
+ const normalizedEmail = userEmail.trim().toLowerCase();
262
+ const cacheKey = `${baseUrl}::${normalizedEmail}`;
263
+ const cachedUserId = impersonationUserIdCache.get(cacheKey);
264
+ if (cachedUserId) {
265
+ return cachedUserId;
266
+ }
267
+
268
+ /** @type {any[]} */
269
+ let users = [];
270
+
271
+ try {
272
+ const result = await superadminAuthClient.admin.listUsers({
273
+ query: {
274
+ filterField: "email",
275
+ filterOperator: "eq",
276
+ filterValue: normalizedEmail,
277
+ limit: 10,
278
+ },
279
+ fetchOptions: {
280
+ throw: true,
281
+ },
282
+ });
283
+ users = Array.isArray(result?.users) ? result.users : [];
284
+ } catch {
285
+ const result = await superadminAuthClient.admin.listUsers({
286
+ query: {
287
+ searchField: "email",
288
+ searchOperator: "contains",
289
+ searchValue: normalizedEmail,
290
+ limit: 100,
291
+ },
292
+ fetchOptions: {
293
+ throw: true,
294
+ },
295
+ });
296
+ users = Array.isArray(result?.users) ? result.users : [];
297
+ }
298
+
299
+ const exactMatches = users.filter(
300
+ (user) =>
301
+ user &&
302
+ typeof user === "object" &&
303
+ "email" in user &&
304
+ typeof user.email === "string" &&
305
+ user.email.toLowerCase() === normalizedEmail &&
306
+ "id" in user &&
307
+ typeof user.id === "string",
308
+ );
309
+
310
+ if (exactMatches.length === 0) {
311
+ throw new Error(`No user found with email ${userEmail}`);
312
+ }
313
+ if (exactMatches.length > 1) {
314
+ throw new Error(`Multiple users found with email ${userEmail}`);
315
+ }
316
+
317
+ const resolvedUserId = exactMatches[0].id;
318
+ impersonationUserIdCache.set(cacheKey, resolvedUserId);
319
+ return resolvedUserId;
320
+ };
321
+
322
+ /** @param {import('zod').infer<typeof AuthConfig>} authConfig */
323
+ const osAuthDance = async (authConfig) => {
324
+ /** @type {string[] | undefined} */
355
325
  let superadminSetCookie;
356
326
  const authClient = createAuthClient({
357
- baseURL: authConfig.baseUrl,
327
+ baseURL: authConfig.osBaseUrl,
358
328
  fetchOptions: {
359
329
  throw: true,
360
330
  },
@@ -376,20 +346,26 @@ const authDance = async (authConfig) => {
376
346
  });
377
347
 
378
348
  const superadminAuthClient = createAuthClient({
379
- baseURL: authConfig.baseUrl,
349
+ baseURL: authConfig.osBaseUrl,
380
350
  fetchOptions: {
381
351
  throw: true,
382
352
  onRequest: (ctx) => {
383
- ctx.headers.set("origin", authConfig.baseUrl);
353
+ ctx.headers.set("origin", authConfig.osBaseUrl);
384
354
  ctx.headers.set("cookie", setCookiesToCookieHeader(superadminSetCookie));
385
355
  },
386
356
  },
387
357
  plugins: [adminClient()],
388
358
  });
389
359
 
360
+ const userId = await resolveImpersonationUserId({
361
+ superadminAuthClient,
362
+ userEmail: authConfig.userEmail,
363
+ baseUrl: authConfig.osBaseUrl,
364
+ });
365
+
390
366
  let impersonateSetCookie;
391
367
  await superadminAuthClient.admin.impersonateUser({
392
- userId: authConfig.userId,
368
+ userId,
393
369
  fetchOptions: {
394
370
  throw: true,
395
371
  onResponse: (ctx) => {
@@ -401,11 +377,11 @@ const authDance = async (authConfig) => {
401
377
  const userCookies = setCookiesToCookieHeader(impersonateSetCookie);
402
378
 
403
379
  const userClient = createAuthClient({
404
- baseURL: authConfig.baseUrl,
380
+ baseURL: authConfig.osBaseUrl,
405
381
  fetchOptions: {
406
382
  throw: true,
407
383
  onRequest: (ctx) => {
408
- ctx.headers.set("origin", authConfig.baseUrl);
384
+ ctx.headers.set("origin", authConfig.osBaseUrl);
409
385
  ctx.headers.set("cookie", userCookies);
410
386
  },
411
387
  },
@@ -414,139 +390,38 @@ const authDance = async (authConfig) => {
414
390
  return { userCookies, userClient };
415
391
  };
416
392
 
417
- /** @param {string} repoDir */
418
- const loadAppRouter = async (repoDir) => {
419
- const appRouterPath = join(repoDir, APP_ROUTER_PATH);
420
- if (!existsSync(appRouterPath)) {
421
- 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()}`);
422
399
  }
423
- const rootModule = await import(pathToFileURL(appRouterPath).href);
424
- if (!rootModule || typeof rootModule !== "object" || !("appRouter" in rootModule)) {
425
- throw new Error(`Failed to load appRouter from ${appRouterPath}`);
426
- }
427
- return rootModule.appRouter;
428
- };
429
-
430
- /** @param {unknown} error */
431
- const commandMissing = (error) => {
432
- return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
433
- };
434
-
435
- /** @param {SpawnOptions} options */
436
- const run = ({ command, args, cwd, env }) => {
437
- return new Promise((resolvePromise, rejectPromise) => {
438
- const child = spawn(command, args, {
439
- cwd,
440
- env,
441
- stdio: "inherit",
442
- });
443
400
 
444
- child.on("error", (error) => {
445
- rejectPromise(error);
446
- });
447
-
448
- child.on("close", (code, signal) => {
449
- if (signal) {
450
- rejectPromise(new Error(`${command} exited with signal ${signal}`));
451
- return;
452
- }
453
- resolvePromise(code ?? 0);
454
- });
401
+ const router = await response.json().catch((e) => {
402
+ throw new Error(`${url} returned invalid router: ${e.message}`);
455
403
  });
456
- };
457
-
458
- /** @param {SpawnOptions} options */
459
- const runChecked = async ({ command, args, cwd, env }) => {
460
- const code = await run({ command, args, cwd, env });
461
- if (code !== 0) {
462
- throw new Error(`Command failed (${code}): ${command} ${args.join(" ")}`);
463
- }
464
- };
465
-
466
- /** @param {CheckoutOptions} options */
467
- const ensureRepoCheckout = async ({ repoDir, repoRef, repoUrl }) => {
468
- if (existsSync(repoDir)) {
469
- if (!existsSync(join(repoDir, APP_ROUTER_PATH))) {
470
- throw new Error(`Expected ${APP_ROUTER_PATH} in ${repoDir}.`);
471
- }
472
- if (!existsSync(join(repoDir, ".git"))) {
473
- throw new Error(`Expected git checkout at ${repoDir}, but .git is missing.`);
474
- }
475
- return;
476
- }
477
-
478
- mkdirSync(dirname(repoDir), { recursive: true });
479
- const cloneArgs = ["clone", "--depth", "1"];
480
- if (repoRef) {
481
- cloneArgs.push("--branch", repoRef, "--single-branch");
482
- }
483
- cloneArgs.push(repoUrl, repoDir);
484
-
485
- log(`cloning iterate repo into ${repoDir}`);
486
- try {
487
- await runChecked({ command: "git", args: cloneArgs });
488
- const envClientPath = join(repoDir, "apps/os/env-client.ts");
489
- // todo: remove this as soon as this branch is merged into main
490
- writeFileSync(
491
- envClientPath,
492
- readFileSync(envClientPath, "utf8").replace("import.meta.env.", "import.meta.env?."),
493
- );
494
- } catch (error) {
495
- if (commandMissing(error)) {
496
- throw new Error("git is required but was not found on PATH.");
497
- }
498
- throw error;
499
- }
500
- };
501
-
502
- /** @param {string} repoDir */
503
- const hasInstalledDependencies = (repoDir) => {
504
- return existsSync(join(repoDir, "node_modules", ".modules.yaml"));
505
- };
506
-
507
- /** @param {{ repoDir: string }} options */
508
- const installDependencies = async ({ repoDir }) => {
509
- log("installing dependencies with pnpm");
510
- const installArgs = ["install", "--frozen-lockfile"];
511
- try {
512
- await runChecked({
513
- command: "corepack",
514
- args: ["pnpm", ...installArgs],
515
- cwd: repoDir,
516
- });
517
- return;
518
- } catch (error) {
519
- if (!commandMissing(error)) {
520
- throw error;
521
- }
522
- }
523
-
524
- try {
525
- await runChecked({
526
- command: "pnpm",
527
- args: installArgs,
528
- cwd: repoDir,
529
- });
530
- } catch (error) {
531
- if (commandMissing(error)) {
532
- throw new Error("pnpm/corepack is required but was not found on PATH.");
533
- }
534
- throw error;
404
+ if (!Array.isArray(router?.procedures)) {
405
+ throw new Error(`${url} returned invalid router: ${JSON.stringify(router)}`);
535
406
  }
407
+ /** @type {{procedures: any[]}} */
408
+ return router;
536
409
  };
537
410
 
538
- /** @param {string} repoDir */
539
- const getRuntimeProcedures = async (repoDir) => {
540
- const appRouter = await loadAppRouter(repoDir);
541
- 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 () => {
542
416
  return createTRPCClient({
543
417
  links: [
544
418
  httpLink({
545
- url: `${readAuthConfig(process.cwd()).baseUrl}/api/trpc/`,
419
+ url: `${params.baseUrl}/api/trpc/`,
546
420
  transformer: superjson,
547
421
  fetch: async (request, init) => {
548
422
  const authConfig = readAuthConfig(process.cwd());
549
- const { userCookies } = await authDance(authConfig);
423
+ if (authConfig instanceof Error) throw authConfig;
424
+ const { userCookies } = await osAuthDance(authConfig);
550
425
  const headers = new Headers(init?.headers);
551
426
  headers.set("cookie", userCookies);
552
427
  return fetch(request, { ...init, headers });
@@ -556,92 +431,176 @@ const getRuntimeProcedures = async (repoDir) => {
556
431
  });
557
432
  });
558
433
 
559
- return {
560
- whoami: t.procedure.mutation(async () => {
561
- const authConfig = readAuthConfig(process.cwd());
562
- const { userClient } = await authDance(authConfig);
563
- return await userClient.getSession();
564
- }),
565
- 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
+ });
566
490
  };
567
491
  };
568
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
+
569
510
  const launcherProcedures = {
570
511
  doctor: t.procedure
571
512
  .meta({ description: "Show launcher config and resolved runtime options" })
572
513
  .mutation(async () => {
573
- const runtime = resolveRuntimeOptions();
574
- return {
575
- configPath: CONFIG_PATH,
576
- repoDir: runtime.repoDir,
577
- repoDirSource: runtime.repoDirSource,
578
- autoInstall: runtime.autoInstall,
579
- repoRef: runtime.repoRef ?? null,
580
- repoUrl: runtime.repoUrl,
581
- cwdRepoDir: runtime.cwdRepoDir ?? null,
582
- repoExists: existsSync(runtime.repoDir),
583
- dependenciesInstalled: hasInstalledDependencies(runtime.repoDir),
584
- };
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 };
585
522
  }),
586
523
  setup: t.procedure
587
- .input(SetupInput)
588
- .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" })
589
526
  .mutation(async ({ input }) => {
590
- const runtime = resolveRuntimeOptions();
591
-
592
- const rawRepoPath = input.repoPath.trim().toLowerCase();
593
- let repoPath = normalizePath(input.repoPath);
594
- if (rawRepoPath === "managed") {
595
- repoPath = DEFAULT_REPO_DIR;
596
- } else if (rawRepoPath === "local") {
597
- if (!runtime.cwdRepoDir) {
598
- throw new Error(
599
- "'local' repoPath was selected but current directory is not inside an iterate repo",
600
- );
601
- }
602
- repoPath = runtime.cwdRepoDir;
603
- }
604
-
605
- const next = writeLauncherConfig({
606
- launcherPatch: { repoPath, autoInstall: input.autoInstall },
607
- workspacePatch: {
608
- baseUrl: input.baseUrl,
527
+ writeNewConfig({
528
+ scope: input.scope || "workspace",
529
+ patch: {
530
+ osBaseUrl: input.osBaseUrl,
531
+ daemonBaseUrl: input.daemonBaseUrl,
609
532
  adminPasswordEnvVarName: input.adminPasswordEnvVarName,
610
- userId: input.userId,
533
+ userEmail: input.userEmail,
611
534
  },
612
- scope: input.scope,
613
535
  workspacePath: process.cwd(),
614
536
  });
615
- return {
616
- configPath: CONFIG_PATH,
617
- launcher: sanitizeLauncherConfig(getMergedWorkspaceConfig(next, process.cwd())),
618
- scope: input.scope,
619
- };
620
- }),
621
- install: t.procedure
622
- .meta({ description: "Clone repo if needed, then run pnpm install" })
623
- .mutation(async () => {
624
- const runtime = resolveRuntimeOptions();
625
- await ensureRepoCheckout(runtime);
626
- await installDependencies({ repoDir: runtime.repoDir });
627
- return { repoDir: runtime.repoDir };
537
+
538
+ const current = readAuthConfig(process.cwd());
539
+ if (current instanceof Error) throw current;
540
+ return { configPath: CONFIG_PATH, current };
628
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
+ }),
629
549
  };
630
550
 
631
- /** @param {string[]} args */
632
- const runCli = async (args) => {
633
- const runtime = resolveRuntimeOptions();
634
- await ensureRepoCheckout(runtime);
551
+ const runCli = async () => {
552
+ const authConfig = readAuthConfig(process.cwd());
635
553
 
636
- if (runtime.autoInstall && !hasInstalledDependencies(runtime.repoDir)) {
637
- await installDependencies({ repoDir: runtime.repoDir });
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)];
564
+
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
+ }
638
601
  }
639
602
 
640
- const runtimeProcedures = await getRuntimeProcedures(runtime.repoDir);
641
- const router = t.router({
642
- ...launcherProcedures,
643
- ...runtimeProcedures,
644
- });
603
+ const router = t.mergeRouters(...routers);
645
604
 
646
605
  const cli = createCli({
647
606
  router,
@@ -650,15 +609,13 @@ const runCli = async (args) => {
650
609
  description: "Iterate CLI",
651
610
  });
652
611
 
653
- process.argv = [process.argv[0], process.argv[1], ...args];
654
612
  await cli.run({
655
613
  prompts: isAgent ? undefined : prompts,
656
614
  });
657
615
  };
658
616
 
659
617
  const main = async () => {
660
- const args = process.argv.slice(2);
661
- await runCli(args);
618
+ await runCli();
662
619
  };
663
620
 
664
621
  main().catch((error) => {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "iterate",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "CLI for iterate",
5
- "license": "AGPL-3.0-only",
5
+ "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -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
  }