otavia 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/bun.lock +589 -0
  2. package/package.json +35 -0
  3. package/src/cli.ts +153 -0
  4. package/src/commands/__tests__/aws-auth.test.ts +32 -0
  5. package/src/commands/__tests__/cell.test.ts +44 -0
  6. package/src/commands/__tests__/dev.test.ts +49 -0
  7. package/src/commands/__tests__/init.test.ts +47 -0
  8. package/src/commands/__tests__/setup.test.ts +263 -0
  9. package/src/commands/aws-auth.ts +32 -0
  10. package/src/commands/aws.ts +59 -0
  11. package/src/commands/cell.ts +33 -0
  12. package/src/commands/clean.ts +32 -0
  13. package/src/commands/deploy.ts +508 -0
  14. package/src/commands/dev/__tests__/fixtures/gateway-cell/cell.yaml +8 -0
  15. package/src/commands/dev/__tests__/gateway-backend-routes.test.ts +13 -0
  16. package/src/commands/dev/__tests__/gateway-forward-url.test.ts +20 -0
  17. package/src/commands/dev/__tests__/gateway-sso-base-url.test.ts +93 -0
  18. package/src/commands/dev/__tests__/tunnel.test.ts +93 -0
  19. package/src/commands/dev/__tests__/vite-dev-proxy-rules.test.ts +220 -0
  20. package/src/commands/dev/__tests__/well-known.test.ts +88 -0
  21. package/src/commands/dev/forward-url.ts +7 -0
  22. package/src/commands/dev/gateway.ts +421 -0
  23. package/src/commands/dev/main-frontend-runtime/main-entry.ts +35 -0
  24. package/src/commands/dev/main-frontend-runtime/vite-config.ts +210 -0
  25. package/src/commands/dev/mount-selection.ts +9 -0
  26. package/src/commands/dev/tunnel.ts +176 -0
  27. package/src/commands/dev/vite-dev.ts +382 -0
  28. package/src/commands/dev/well-known.ts +76 -0
  29. package/src/commands/dev.ts +107 -0
  30. package/src/commands/init.ts +69 -0
  31. package/src/commands/lint.ts +49 -0
  32. package/src/commands/setup.ts +887 -0
  33. package/src/commands/test.ts +331 -0
  34. package/src/commands/typecheck.ts +36 -0
  35. package/src/config/__tests__/load-cell-yaml.test.ts +248 -0
  36. package/src/config/__tests__/load-otavia-yaml.test.ts +492 -0
  37. package/src/config/__tests__/ports.test.ts +48 -0
  38. package/src/config/__tests__/resolve-cell-dir.test.ts +60 -0
  39. package/src/config/__tests__/resolve-params.test.ts +137 -0
  40. package/src/config/__tests__/resource-names.test.ts +62 -0
  41. package/src/config/cell-yaml-schema.ts +115 -0
  42. package/src/config/load-cell-yaml.ts +87 -0
  43. package/src/config/load-otavia-yaml.ts +256 -0
  44. package/src/config/otavia-yaml-schema.ts +49 -0
  45. package/src/config/ports.ts +57 -0
  46. package/src/config/resolve-cell-dir.ts +55 -0
  47. package/src/config/resolve-params.ts +160 -0
  48. package/src/config/resource-names.ts +60 -0
  49. package/src/deploy/__tests__/template.test.ts +137 -0
  50. package/src/deploy/api-gateway.ts +96 -0
  51. package/src/deploy/cloudflare-dns.ts +261 -0
  52. package/src/deploy/cloudfront.ts +228 -0
  53. package/src/deploy/dynamodb.ts +68 -0
  54. package/src/deploy/lambda.ts +121 -0
  55. package/src/deploy/s3.ts +57 -0
  56. package/src/deploy/template.ts +264 -0
  57. package/src/deploy/types.ts +16 -0
  58. package/src/local/docker.ts +175 -0
  59. package/src/local/dynamodb-local.ts +124 -0
  60. package/src/local/minio-local.ts +44 -0
  61. package/src/utils/env.test.ts +74 -0
  62. package/src/utils/env.ts +79 -0
  63. package/tsconfig.json +14 -0
@@ -0,0 +1,887 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir, hostname as osHostname } from "node:os";
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { loadOtaviaYaml } from "../config/load-otavia-yaml.js";
6
+ import { loadCellConfig } from "../config/load-cell-yaml.js";
7
+ import { resolveCellDir } from "../config/resolve-cell-dir.js";
8
+ import { assertDeclaredParamsProvided, mergeParams, resolveParams } from "../config/resolve-params.js";
9
+ import { isEnvRef, isSecretRef } from "../config/cell-yaml-schema.js";
10
+ import { loadEnvForCell } from "../utils/env.js";
11
+ import { resolvePortsFromEnv } from "../config/ports.js";
12
+
13
+ type CommandResult = { exitCode: number; stdout: string; stderr: string };
14
+ type CommandOptions = { inheritStdio?: boolean; env?: Record<string, string | undefined> };
15
+ export type CommandRunner = (args: string[], options?: CommandOptions) => Promise<CommandResult>;
16
+ type AskFn = (prompt: string) => Promise<string>;
17
+ type FetchFn = (input: string, init?: RequestInit) => Promise<Response>;
18
+ type CloudflareZone = { id: string; name: string };
19
+
20
+ const ANSI_RESET = "\x1b[0m";
21
+ const ANSI_BOLD = "\x1b[1m";
22
+ const ANSI_CYAN = "\x1b[36m";
23
+ const ANSI_YELLOW = "\x1b[33m";
24
+ const ANSI_DIM = "\x1b[2m";
25
+
26
+ const runCommand: CommandRunner = async (args, options) => {
27
+ const proc = Bun.spawn(args, {
28
+ stdout: options?.inheritStdio ? "inherit" : "pipe",
29
+ stderr: options?.inheritStdio ? "inherit" : "pipe",
30
+ env: options?.env ? { ...process.env, ...options.env } : process.env,
31
+ });
32
+ const exitCode = await proc.exited;
33
+ const stdout = options?.inheritStdio ? "" : await new Response(proc.stdout).text();
34
+ const stderr = options?.inheritStdio ? "" : await new Response(proc.stderr).text();
35
+ return { exitCode, stdout, stderr };
36
+ };
37
+
38
+ /**
39
+ * Collect all env var names referenced by !Env and !Secret in a params tree.
40
+ */
41
+ function collectRefKeys(params: Record<string, unknown>): string[] {
42
+ const keys = new Set<string>();
43
+
44
+ function walk(value: unknown): void {
45
+ if (value === null || value === undefined) return;
46
+ if (isEnvRef(value)) {
47
+ keys.add(value.env);
48
+ return;
49
+ }
50
+ if (isSecretRef(value)) {
51
+ keys.add(value.secret);
52
+ return;
53
+ }
54
+ if (typeof value === "object" && !Array.isArray(value)) {
55
+ for (const v of Object.values(value as Record<string, unknown>)) {
56
+ walk(v);
57
+ }
58
+ }
59
+ }
60
+
61
+ for (const v of Object.values(params)) {
62
+ walk(v);
63
+ }
64
+ return [...keys];
65
+ }
66
+
67
+ /**
68
+ * Setup command: check bun, otavia.yaml, each cell's cell.yaml; copy stack-host
69
+ * .env.example -> .env when missing (rootDir only); optionally warn on:
70
+ * - missing declared params (cell.yaml params not provided in otavia.yaml)
71
+ * - missing env vars referenced by !Env/!Secret in otavia.yaml params.
72
+ * options.tunnel: when true, write cloudflared tunnel config and print start instructions (no daemon).
73
+ */
74
+ export async function setupCommand(
75
+ rootDir: string,
76
+ options?: { tunnel?: boolean; tunnelSpecified?: boolean }
77
+ ): Promise<void> {
78
+ // 1. Check bun is available
79
+ try {
80
+ const proc = await Bun.spawn(["bun", "--version"], {
81
+ stdout: "pipe",
82
+ stderr: "pipe",
83
+ });
84
+ const exit = await proc.exited;
85
+ if (exit !== 0) {
86
+ console.error("bun --version failed (exit code ", exit, ")");
87
+ throw new Error("bun is not available");
88
+ }
89
+ } catch (err) {
90
+ if (err instanceof Error && err.message === "bun is not available") throw err;
91
+ console.error("Failed to run bun --version:", err);
92
+ throw new Error("bun is not available");
93
+ }
94
+
95
+ // 2. Load otavia.yaml (rethrow on error)
96
+ const otavia = loadOtaviaYaml(rootDir);
97
+
98
+ // 3. Stack-host env bootstrap: apps/main/.env.example -> apps/main/.env
99
+ const rootEnvPath = path.join(rootDir, ".env");
100
+ const rootEnvExamplePath = path.join(rootDir, ".env.example");
101
+ if (existsSync(rootEnvPath)) {
102
+ console.log("Skip .env: already exists (main)");
103
+ } else if (existsSync(rootEnvExamplePath)) {
104
+ copyFileSync(rootEnvExamplePath, rootEnvPath);
105
+ console.log("Created .env from .env.example (main)");
106
+ } else {
107
+ console.log("Skip .env: no .env.example (main)");
108
+ }
109
+
110
+ // 4. Validate cells and warn missing env refs
111
+ for (const entry of otavia.cellsList) {
112
+ const cellDir = resolveCellDir(rootDir, entry.package);
113
+ const cellYamlPath = path.join(cellDir, "cell.yaml");
114
+ if (!existsSync(cellYamlPath)) {
115
+ console.warn(`Warning: cell "${entry.mount}" (${entry.package}) not found, skipping.`);
116
+ continue;
117
+ }
118
+
119
+ // Optional: warn on missing !Env/!Secret in params
120
+ try {
121
+ const cellConfig = loadCellConfig(cellDir);
122
+ const merged = mergeParams(otavia.params as Record<string, unknown> | undefined, entry.params);
123
+ try {
124
+ assertDeclaredParamsProvided(cellConfig.params, merged, entry.mount);
125
+ } catch (err) {
126
+ if (err instanceof Error) {
127
+ console.warn(err.message);
128
+ }
129
+ }
130
+ const refKeys = collectRefKeys(merged);
131
+ if (refKeys.length === 0) continue;
132
+
133
+ const env = loadEnvForCell(rootDir, cellDir);
134
+ // Empty string is a valid value for optional refs (e.g. COGNITO_CLIENT_SECRET in SSO).
135
+ const missing = refKeys.filter((k) => env[k] === undefined);
136
+ if (missing.length > 0) {
137
+ console.warn(`Warning: missing env for ${entry.mount}: ${missing.join(", ")}`);
138
+ }
139
+ } catch {
140
+ // Do not block: if cell.yaml fails to load or merge fails, skip warning
141
+ }
142
+ }
143
+
144
+ const tunnelEnabled = await resolveTunnelSetupEnabled(options);
145
+ if (tunnelEnabled) {
146
+ const stageEnv = loadEnvForCell(rootDir, rootDir, { stage: "dev" });
147
+ const ports = resolvePortsFromEnv("dev", { ...stageEnv, ...process.env });
148
+ const configDir =
149
+ process.env.OTAVIA_CONFIG_DIR ?? path.join(homedir(), ".config", "otavia");
150
+ mkdirSync(configDir, { recursive: true });
151
+ const inputs = await resolveTunnelInputs({ configDir });
152
+ console.log("Checking cloudflared installation and authentication...");
153
+ await ensureCloudflaredInstalled();
154
+ await ensureCloudflaredLogin();
155
+ const tunnel = await bootstrapNamedTunnel({
156
+ configDir,
157
+ devRoot: inputs.devRoot,
158
+ machineName: inputs.machineName,
159
+ });
160
+
161
+ const tunnelConfigPath = path.join(configDir, "config.yml");
162
+ const tunnelLegacyPath = path.join(configDir, "tunnel.yaml");
163
+ const tunnelYaml = buildTunnelConfigYaml({
164
+ tunnelName: tunnel.tunnelName,
165
+ credentialsPath: tunnel.credentialsPath,
166
+ hostname: tunnel.hostname,
167
+ localPort: ports.frontend,
168
+ });
169
+ writeFileSync(tunnelConfigPath, tunnelYaml, "utf-8");
170
+ writeFileSync(tunnelLegacyPath, tunnelYaml, "utf-8");
171
+
172
+ const readmePath = path.join(configDir, "README.md");
173
+ const readmeContent = [
174
+ "Otavia tunnel is configured.",
175
+ "",
176
+ `Public host: https://${tunnel.hostname}`,
177
+ `Tunnel name: ${tunnel.tunnelName}`,
178
+ "",
179
+ `Start tunnel: cloudflared tunnel --config "${tunnelConfigPath}" run`,
180
+ "",
181
+ "Then start otavia dev with tunnel mode:",
182
+ "bun run otavia dev --tunnel --tunnel-config " + JSON.stringify(tunnelConfigPath),
183
+ "",
184
+ ].join("\n");
185
+ writeFileSync(readmePath, readmeContent, "utf-8");
186
+
187
+ console.log("Tunnel config written to", tunnelConfigPath);
188
+ console.log("Public host:", `https://${tunnel.hostname}`);
189
+ console.log("To start tunnel:");
190
+ console.log(` cloudflared tunnel --config "${tunnelConfigPath}" run`);
191
+
192
+ try {
193
+ await ensureOAuthCognitoCallback({
194
+ rootDir,
195
+ otavia,
196
+ tunnelHost: tunnel.hostname,
197
+ });
198
+ } catch (err) {
199
+ const msg = err instanceof Error ? err.message : String(err);
200
+ console.warn(`Warning: failed to ensure Cognito callback URL automatically: ${msg}`);
201
+ }
202
+ }
203
+ }
204
+
205
+ async function askYesNo(prompt: string): Promise<string> {
206
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
207
+ const answer = await new Promise<string>((resolve) => rl.question(prompt, resolve));
208
+ rl.close();
209
+ return answer.trim();
210
+ }
211
+
212
+ async function askText(prompt: string): Promise<string> {
213
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
214
+ const answer = await new Promise<string>((resolve) => rl.question(prompt, resolve));
215
+ rl.close();
216
+ return answer.trim();
217
+ }
218
+
219
+ export async function resolveTunnelSetupEnabled(
220
+ options?: { tunnel?: boolean; tunnelSpecified?: boolean },
221
+ deps?: { isTTY?: boolean; ask?: (prompt: string) => Promise<string> }
222
+ ): Promise<boolean> {
223
+ if (options?.tunnelSpecified) {
224
+ return Boolean(options.tunnel);
225
+ }
226
+ // Backward compatibility for call sites that pass { tunnel: true } without tunnelSpecified.
227
+ if (options?.tunnel === true) {
228
+ return true;
229
+ }
230
+ const isTTY = deps?.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
231
+ if (!isTTY) return false;
232
+ const ask = deps?.ask ?? askYesNo;
233
+ const answer = await ask("Configure Cloudflare tunnel for remote dev now? (y/N): ");
234
+ return /^y(es)?$/i.test(answer);
235
+ }
236
+
237
+ function hostnameToSegment(host: string): string {
238
+ let s = host
239
+ .toLowerCase()
240
+ .replace(/[^a-z0-9-]/g, "-")
241
+ .replace(/-+/g, "-")
242
+ .replace(/^-|-$/g, "");
243
+ if (s.length > 63) s = s.slice(0, 63).replace(/-$/, "");
244
+ if (!s || /^\d+$/.test(s)) return "dev";
245
+ return s;
246
+ }
247
+
248
+ function normalizeDomain(input: string): string {
249
+ return input.trim().replace(/^\.+/, "").replace(/\.+$/, "").toLowerCase();
250
+ }
251
+
252
+ function isValidDevRootDomain(domain: string): boolean {
253
+ if (!domain) return false;
254
+ // Basic hostname validation: labels 1-63 chars, letters/digits/hyphen, at least one dot.
255
+ if (!domain.includes(".")) return false;
256
+ const labels = domain.split(".");
257
+ if (labels.some((label) => label.length === 0 || label.length > 63)) return false;
258
+ for (const label of labels) {
259
+ if (!/^[a-z0-9-]+$/i.test(label)) return false;
260
+ if (label.startsWith("-") || label.endsWith("-")) return false;
261
+ }
262
+ return true;
263
+ }
264
+
265
+ function printCloudflareTokenInstructions(): void {
266
+ console.log("");
267
+ console.log(`${ANSI_BOLD}${ANSI_CYAN}=== Cloudflare API Token ===${ANSI_RESET}`);
268
+ console.log(`${ANSI_BOLD}${ANSI_YELLOW}[REQUIRED]${ANSI_RESET} Token is required for automatic DNS setup.`);
269
+ console.log("");
270
+ console.log(`${ANSI_BOLD}Step 1${ANSI_RESET}) Open: ${ANSI_CYAN}https://dash.cloudflare.com/profile/api-tokens${ANSI_RESET}`);
271
+ console.log(`${ANSI_BOLD}Step 2${ANSI_RESET}) Create token:`);
272
+ console.log(` - ${ANSI_BOLD}${ANSI_YELLOW}[RECOMMENDED]${ANSI_RESET} Use template ${ANSI_BOLD}'Edit zone DNS'${ANSI_RESET}`);
273
+ console.log(` - ${ANSI_BOLD}${ANSI_YELLOW}[FORM]${ANSI_RESET} In the next form (after selecting the template), set:`);
274
+ console.log(` * Permissions row #1: Zone / DNS / Edit`);
275
+ console.log(` * Click '+ Add more' and add row #2: Zone / Zone / Read`);
276
+ console.log(` * Zone Resources: default to Include -> All zones (or narrow to your dev zone)`);
277
+ console.log(` * Client IP filtering: leave empty`);
278
+ console.log(` * TTL: default no TTL (or set TTL per your security policy)`);
279
+ console.log(` ${ANSI_DIM}- [ADVANCED] If creating custom token, permissions:${ANSI_RESET}`);
280
+ console.log(` ${ANSI_DIM} * Zone -> Zone -> Read${ANSI_RESET}`);
281
+ console.log(` ${ANSI_DIM} * Zone -> DNS -> Edit${ANSI_RESET}`);
282
+ console.log(` ${ANSI_DIM} * Zone -> SSL and Certificates -> Edit (optional, future cert automation)${ANSI_RESET}`);
283
+ console.log("");
284
+ console.log(`${ANSI_BOLD}${ANSI_YELLOW}[IMPORTANT]${ANSI_RESET}`);
285
+ console.log(` - Zone Resources should include the dev zone you plan to use.`);
286
+ console.log(` - We will use this token to auto-list zones and configure DNS records.`);
287
+ }
288
+
289
+ async function resolveTunnelInputs(deps?: {
290
+ ask?: AskFn;
291
+ isTTY?: boolean;
292
+ hostName?: string;
293
+ configDir?: string;
294
+ fetchFn?: FetchFn;
295
+ }): Promise<{ devRoot: string; machineName: string }> {
296
+ const ask = deps?.ask ?? askText;
297
+ const isTTY = deps?.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
298
+ const defaultMachine = hostnameToSegment(deps?.hostName ?? osHostname());
299
+ const envRoot = normalizeDomain(process.env.OTAVIA_TUNNEL_DEV_ROOT ?? "");
300
+ const envMachine = hostnameToSegment(process.env.OTAVIA_TUNNEL_MACHINE_NAME ?? defaultMachine);
301
+ const configDir = deps?.configDir ?? process.env.OTAVIA_CONFIG_DIR ?? path.join(homedir(), ".config", "otavia");
302
+ const tokenFilePath = path.join(configDir, "cloudflare-api-token");
303
+ const envToken =
304
+ process.env.CLOUDFLARE_API_TOKEN?.trim() ||
305
+ process.env.CF_API_TOKEN?.trim() ||
306
+ (existsSync(tokenFilePath) ? readFileSync(tokenFilePath, "utf-8").trim() : "");
307
+
308
+ if (!isTTY) {
309
+ if (!envRoot) {
310
+ throw new Error(
311
+ "Tunnel setup in non-interactive mode requires OTAVIA_TUNNEL_DEV_ROOT (and optional OTAVIA_TUNNEL_MACHINE_NAME)."
312
+ );
313
+ }
314
+ if (!envToken) {
315
+ throw new Error(
316
+ "Tunnel setup requires Cloudflare API token. Set CLOUDFLARE_API_TOKEN/CF_API_TOKEN, or save token to ~/.config/otavia/cloudflare-api-token."
317
+ );
318
+ }
319
+ return { devRoot: envRoot, machineName: envMachine };
320
+ }
321
+
322
+ let token = envToken;
323
+ if (token) {
324
+ const change = await ask("Found saved Cloudflare API token. Change it? (y/N): ");
325
+ if (/^y(es)?$/i.test(change.trim())) {
326
+ token = "";
327
+ }
328
+ }
329
+ if (!token) {
330
+ printCloudflareTokenInstructions();
331
+ for (;;) {
332
+ const tokenInput = await ask("Cloudflare API token: ");
333
+ if (!tokenInput.trim()) {
334
+ console.warn("Cloudflare API token is required.");
335
+ continue;
336
+ }
337
+ token = tokenInput.trim();
338
+ mkdirSync(configDir, { recursive: true });
339
+ writeFileSync(tokenFilePath, token, "utf-8");
340
+ break;
341
+ }
342
+ }
343
+ if (!token) {
344
+ throw new Error(
345
+ "Cloudflare API token is required. Please create one and rerun setup."
346
+ );
347
+ }
348
+
349
+ let devRoot = envRoot;
350
+ const zones = await fetchCloudflareZonesWithToken(token, deps?.fetchFn);
351
+ if (!devRoot && zones.length === 0) {
352
+ console.warn(
353
+ "Warning: failed to auto-load Cloudflare zones."
354
+ );
355
+ console.warn(
356
+ "This can be caused by token permissions (Zone:Read + DNS:Edit), network timeout, or Cloudflare API transient 5xx."
357
+ );
358
+ console.warn(
359
+ "Please enter dev root domain manually now, then continue setup."
360
+ );
361
+ }
362
+ if (!devRoot && zones.length > 0) {
363
+ if (zones.length === 1) {
364
+ devRoot = zones[0]!.name;
365
+ console.log(`Auto-selected Cloudflare zone: ${devRoot}`);
366
+ } else {
367
+ for (;;) {
368
+ console.log("Available Cloudflare zones:");
369
+ zones.forEach((zone, idx) => console.log(` ${idx + 1}. ${zone.name}`));
370
+ const answer = await ask(
371
+ `Select dev root domain by number (1-${zones.length}) or enter domain manually: `
372
+ );
373
+ const maybeIndex = parseInt(answer, 10);
374
+ if (Number.isInteger(maybeIndex) && maybeIndex >= 1 && maybeIndex <= zones.length) {
375
+ devRoot = zones[maybeIndex - 1]!.name;
376
+ break;
377
+ }
378
+ if (!answer.trim()) {
379
+ if (zones.length === 1) {
380
+ devRoot = zones[0]!.name;
381
+ break;
382
+ }
383
+ console.warn("Input is required. Enter a zone number or a valid domain.");
384
+ continue;
385
+ }
386
+ const candidate = normalizeDomain(answer);
387
+ if (isValidDevRootDomain(candidate)) {
388
+ devRoot = candidate;
389
+ break;
390
+ }
391
+ console.warn("Invalid domain format. Use a valid domain like dev.example.com.");
392
+ }
393
+ }
394
+ }
395
+
396
+ if (!devRoot) {
397
+ for (;;) {
398
+ const rootInput = await ask(
399
+ `Dev root domain (e.g. dev.example.com)${envRoot ? ` [${envRoot}]` : ""}: `
400
+ );
401
+ const candidate = normalizeDomain(rootInput || envRoot);
402
+ if (!candidate) {
403
+ console.warn("Dev root domain is required.");
404
+ continue;
405
+ }
406
+ if (!isValidDevRootDomain(candidate)) {
407
+ console.warn("Invalid domain format. Use a valid domain like dev.example.com.");
408
+ continue;
409
+ }
410
+ devRoot = candidate;
411
+ break;
412
+ }
413
+ }
414
+
415
+ const machineInput = await ask(`Machine name [${envMachine}]: `);
416
+ const machineName = hostnameToSegment(machineInput || envMachine);
417
+ return { devRoot, machineName };
418
+ }
419
+
420
+ export async function fetchCloudflareZonesWithToken(
421
+ token: string,
422
+ fetchFn?: FetchFn
423
+ ): Promise<CloudflareZone[]> {
424
+ const doFetch = fetchFn ?? fetch;
425
+ const maxAttempts = 3;
426
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
427
+ const controller = new AbortController();
428
+ const timeout = setTimeout(() => controller.abort(), 8000);
429
+ try {
430
+ const res = await doFetch("https://api.cloudflare.com/client/v4/zones?per_page=100", {
431
+ method: "GET",
432
+ headers: {
433
+ Authorization: `Bearer ${token}`,
434
+ "Content-Type": "application/json",
435
+ },
436
+ signal: controller.signal,
437
+ });
438
+ clearTimeout(timeout);
439
+ if (!res.ok) {
440
+ // Retry transient edge/network failures.
441
+ if ((res.status >= 500 || res.status === 429) && attempt < maxAttempts) {
442
+ await Bun.sleep(500 * attempt);
443
+ continue;
444
+ }
445
+ return [];
446
+ }
447
+ const body = (await res.json()) as {
448
+ success?: boolean;
449
+ result?: Array<{ id?: string; name?: string }>;
450
+ };
451
+ if (!body.success || !Array.isArray(body.result)) return [];
452
+ return body.result
453
+ .filter((z) => typeof z.id === "string" && typeof z.name === "string")
454
+ .map((z) => ({ id: z.id as string, name: z.name as string }));
455
+ } catch {
456
+ clearTimeout(timeout);
457
+ if (attempt < maxAttempts) {
458
+ await Bun.sleep(500 * attempt);
459
+ continue;
460
+ }
461
+ return [];
462
+ }
463
+ }
464
+ return [];
465
+ }
466
+
467
+ export function buildTunnelConfigYaml(input: {
468
+ tunnelName: string;
469
+ credentialsPath: string;
470
+ hostname: string;
471
+ localPort: number;
472
+ }): string {
473
+ return [
474
+ `tunnel: ${input.tunnelName}`,
475
+ `credentials-file: ${input.credentialsPath}`,
476
+ "ingress:",
477
+ ` - hostname: ${JSON.stringify(input.hostname)}`,
478
+ ` service: http://127.0.0.1:${input.localPort}`,
479
+ " - service: http_status:404",
480
+ "",
481
+ ].join("\n");
482
+ }
483
+
484
+ export function buildOAuthCallbackUrl(host: string, cell: string, callbackPath: string): string {
485
+ const normalizedHost = host.trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
486
+ const normalizedCell = cell.trim().replace(/^\/+|\/+$/g, "");
487
+ const normalizedPath = callbackPath.startsWith("/") ? callbackPath : `/${callbackPath}`;
488
+ return `https://${normalizedHost}/${normalizedCell}${normalizedPath}`;
489
+ }
490
+
491
+ function asString(value: unknown): string {
492
+ return typeof value === "string" ? value.trim() : "";
493
+ }
494
+
495
+ export function isAwsSsoExpiredError(message: string): boolean {
496
+ return /token has expired and refresh failed|expiredtoken|token is expired|the sso session has expired/i.test(
497
+ message
498
+ );
499
+ }
500
+
501
+ async function ensureOAuthCognitoCallback(input: {
502
+ rootDir: string;
503
+ otavia: ReturnType<typeof loadOtaviaYaml>;
504
+ tunnelHost: string;
505
+ }): Promise<void> {
506
+ const callback = input.otavia.oauth?.callback;
507
+ if (!callback) return;
508
+
509
+ const cellEntry = input.otavia.cellsList.find((entry) => entry.mount === callback.cell);
510
+ if (!cellEntry) return;
511
+ const cellDir = resolveCellDir(input.rootDir, cellEntry.package);
512
+ const cellConfig = loadCellConfig(cellDir);
513
+ if (!cellConfig.cognito) {
514
+ console.warn(`Warning: oauth.callback cell "${callback.cell}" has no cognito config, skipping.`);
515
+ return;
516
+ }
517
+
518
+ const merged = mergeParams(input.otavia.params as Record<string, unknown> | undefined, cellEntry.params);
519
+ assertDeclaredParamsProvided(cellConfig.params, merged, cellEntry.mount);
520
+ const envMap = loadEnvForCell(input.rootDir, cellDir, { stage: "dev" });
521
+ if (!envMap.SSO_BASE_URL?.trim()) {
522
+ const ports = resolvePortsFromEnv("dev", { ...envMap, ...process.env });
523
+ envMap.SSO_BASE_URL = `http://localhost:${ports.backend}/${callback.cell}`;
524
+ }
525
+ const resolved = resolveParams(merged, envMap, { onMissingParam: "throw" });
526
+
527
+ const region = asString(resolved.COGNITO_REGION);
528
+ const userPoolId = asString(resolved.COGNITO_USER_POOL_ID);
529
+ const clientId = asString(resolved.COGNITO_CLIENT_ID);
530
+ if (!region || !userPoolId || !clientId) {
531
+ console.warn(
532
+ "Warning: COGNITO_REGION / COGNITO_USER_POOL_ID / COGNITO_CLIENT_ID is incomplete, skipping callback registration."
533
+ );
534
+ return;
535
+ }
536
+
537
+ const callbackUrl = buildOAuthCallbackUrl(input.tunnelHost, callback.cell, callback.path);
538
+ const profile = envMap.AWS_PROFILE ?? process.env.AWS_PROFILE;
539
+ const awsRegion = envMap.AWS_REGION ?? process.env.AWS_REGION ?? region;
540
+ try {
541
+ await ensureCognitoCallbackUrl({
542
+ region,
543
+ userPoolId,
544
+ clientId,
545
+ callbackUrl,
546
+ profile,
547
+ awsRegion,
548
+ });
549
+ } catch (err) {
550
+ const msg = err instanceof Error ? err.message : String(err);
551
+ if (!isAwsSsoExpiredError(msg)) {
552
+ throw err;
553
+ }
554
+ const resolvedProfile = profile ?? "default";
555
+ console.log(
556
+ `AWS SSO token appears expired for profile "${resolvedProfile}". Running aws sso login and retrying Cognito callback registration...`
557
+ );
558
+ const login = await runCommand(
559
+ ["aws", "sso", "login", "--profile", resolvedProfile],
560
+ {
561
+ inheritStdio: true,
562
+ env: {
563
+ AWS_PROFILE: resolvedProfile,
564
+ AWS_REGION: awsRegion,
565
+ AWS_DEFAULT_REGION: awsRegion,
566
+ },
567
+ }
568
+ );
569
+ if (login.exitCode !== 0) {
570
+ throw new Error(`aws sso login failed for profile "${resolvedProfile}"`);
571
+ }
572
+ await ensureCognitoCallbackUrl({
573
+ region,
574
+ userPoolId,
575
+ clientId,
576
+ callbackUrl,
577
+ profile: resolvedProfile,
578
+ awsRegion,
579
+ });
580
+ }
581
+ }
582
+
583
+ async function ensureCognitoCallbackUrl(input: {
584
+ region: string;
585
+ userPoolId: string;
586
+ clientId: string;
587
+ callbackUrl: string;
588
+ profile?: string;
589
+ awsRegion?: string;
590
+ }): Promise<void> {
591
+ const awsEnv: Record<string, string | undefined> = {
592
+ AWS_REGION: input.awsRegion ?? input.region,
593
+ AWS_DEFAULT_REGION: input.awsRegion ?? input.region,
594
+ AWS_PROFILE: input.profile,
595
+ };
596
+ const describe = await runCommand(
597
+ [
598
+ "aws",
599
+ "cognito-idp",
600
+ "describe-user-pool-client",
601
+ "--user-pool-id",
602
+ input.userPoolId,
603
+ "--client-id",
604
+ input.clientId,
605
+ "--output",
606
+ "json",
607
+ ],
608
+ { env: awsEnv }
609
+ );
610
+ if (describe.exitCode !== 0) {
611
+ throw new Error(describe.stderr || describe.stdout || "describe-user-pool-client failed");
612
+ }
613
+ const parsed = JSON.parse(describe.stdout || "{}") as {
614
+ UserPoolClient?: CognitoClientForUpdate;
615
+ };
616
+ const client = parsed.UserPoolClient;
617
+ if (!client) {
618
+ throw new Error("describe-user-pool-client returned no UserPoolClient");
619
+ }
620
+
621
+ const existingCallbacks = Array.isArray(client.CallbackURLs) ? client.CallbackURLs : [];
622
+ const localOrigin = new URL(input.callbackUrl).origin;
623
+ const existingLogouts = Array.isArray(client.LogoutURLs) ? client.LogoutURLs : [];
624
+ const nextCallbacks = Array.from(new Set([...existingCallbacks, input.callbackUrl]));
625
+ const nextLogouts = Array.from(new Set([...existingLogouts, localOrigin]));
626
+ const oauthNeedsRepair = client.AllowedOAuthFlowsUserPoolClient !== true;
627
+ const discoveredIdentityProviders = await listCognitoIdentityProviders(
628
+ runCommand,
629
+ awsEnv,
630
+ input.userPoolId
631
+ );
632
+ const mergedIdentityProviders = mergeSupportedIdentityProviders(
633
+ client.SupportedIdentityProviders,
634
+ discoveredIdentityProviders
635
+ );
636
+ const unchanged =
637
+ nextCallbacks.length === existingCallbacks.length &&
638
+ nextLogouts.length === existingLogouts.length &&
639
+ !oauthNeedsRepair &&
640
+ sameStringSet(client.SupportedIdentityProviders, mergedIdentityProviders);
641
+ if (unchanged) {
642
+ console.log(`Cognito callback already configured: ${input.callbackUrl}`);
643
+ return;
644
+ }
645
+
646
+ const updateArgs = buildCognitoUserPoolClientUpdateArgs(
647
+ client,
648
+ nextCallbacks,
649
+ nextLogouts,
650
+ mergedIdentityProviders
651
+ );
652
+ const updated = await runCommand(
653
+ [
654
+ "aws",
655
+ "cognito-idp",
656
+ "update-user-pool-client",
657
+ "--user-pool-id",
658
+ input.userPoolId,
659
+ "--client-id",
660
+ input.clientId,
661
+ ...updateArgs,
662
+ "--output",
663
+ "json",
664
+ ],
665
+ { env: awsEnv }
666
+ );
667
+ if (updated.exitCode !== 0) {
668
+ throw new Error(updated.stderr || updated.stdout || "update-user-pool-client failed");
669
+ }
670
+ console.log(`Added Cognito callback URL: ${input.callbackUrl}`);
671
+ }
672
+
673
+ type CognitoClientForUpdate = {
674
+ CallbackURLs?: string[];
675
+ LogoutURLs?: string[];
676
+ AllowedOAuthFlowsUserPoolClient?: boolean;
677
+ AllowedOAuthFlows?: string[];
678
+ AllowedOAuthScopes?: string[];
679
+ SupportedIdentityProviders?: string[];
680
+ };
681
+
682
+ export function buildCognitoUserPoolClientUpdateArgs(
683
+ client: CognitoClientForUpdate,
684
+ nextCallbacks: string[],
685
+ nextLogouts: string[],
686
+ supportedIdentityProviders?: string[]
687
+ ): string[] {
688
+ const nextFlows = uniqueNonEmpty(client.AllowedOAuthFlows, ["code"]);
689
+ const nextScopes = uniqueNonEmpty(client.AllowedOAuthScopes, ["openid", "email", "profile"]);
690
+ const nextProviders = uniqueNonEmpty(
691
+ supportedIdentityProviders ?? client.SupportedIdentityProviders,
692
+ ["COGNITO"]
693
+ );
694
+ return [
695
+ "--callback-urls",
696
+ ...nextCallbacks,
697
+ "--logout-urls",
698
+ ...nextLogouts,
699
+ "--allowed-o-auth-flows-user-pool-client",
700
+ "--allowed-o-auth-flows",
701
+ ...nextFlows,
702
+ "--allowed-o-auth-scopes",
703
+ ...nextScopes,
704
+ "--supported-identity-providers",
705
+ ...nextProviders,
706
+ ];
707
+ }
708
+
709
+ function uniqueNonEmpty(input: string[] | undefined, fallback: string[]): string[] {
710
+ const cleaned = (input ?? []).map((v) => v.trim()).filter(Boolean);
711
+ const unique = Array.from(new Set(cleaned));
712
+ return unique.length > 0 ? unique : fallback;
713
+ }
714
+
715
+ async function listCognitoIdentityProviders(
716
+ run: CommandRunner,
717
+ awsEnv: Record<string, string | undefined>,
718
+ userPoolId: string
719
+ ): Promise<string[]> {
720
+ const listed = await run(
721
+ [
722
+ "aws",
723
+ "cognito-idp",
724
+ "list-identity-providers",
725
+ "--user-pool-id",
726
+ userPoolId,
727
+ "--output",
728
+ "json",
729
+ ],
730
+ { env: awsEnv }
731
+ );
732
+ if (listed.exitCode !== 0) return [];
733
+ try {
734
+ const parsed = JSON.parse(listed.stdout || "{}") as {
735
+ Providers?: Array<{ ProviderName?: string }>;
736
+ };
737
+ return (parsed.Providers ?? [])
738
+ .map((p) => p.ProviderName?.trim() ?? "")
739
+ .filter(Boolean);
740
+ } catch {
741
+ return [];
742
+ }
743
+ }
744
+
745
+ function mergeSupportedIdentityProviders(fromClient?: string[], fromUserPool?: string[]): string[] {
746
+ return uniqueNonEmpty([...(fromClient ?? []), ...(fromUserPool ?? []), "COGNITO"], ["COGNITO"]);
747
+ }
748
+
749
+ function sameStringSet(left?: string[], right?: string[]): boolean {
750
+ const a = Array.from(new Set((left ?? []).map((v) => v.trim()).filter(Boolean))).sort();
751
+ const b = Array.from(new Set((right ?? []).map((v) => v.trim()).filter(Boolean))).sort();
752
+ if (a.length !== b.length) return false;
753
+ for (let i = 0; i < a.length; i += 1) {
754
+ if (a[i] !== b[i]) return false;
755
+ }
756
+ return true;
757
+ }
758
+
759
+ export async function bootstrapNamedTunnel(deps: {
760
+ configDir: string;
761
+ devRoot: string;
762
+ machineName: string;
763
+ run?: CommandRunner;
764
+ log?: (msg: string) => void;
765
+ }): Promise<{ tunnelName: string; hostname: string; credentialsPath: string }> {
766
+ const run = deps.run ?? runCommand;
767
+ const log = deps.log ?? console.log;
768
+ const machineName = hostnameToSegment(deps.machineName);
769
+ const devRoot = normalizeDomain(deps.devRoot);
770
+ if (!machineName) throw new Error("Invalid machine name for tunnel.");
771
+ if (!devRoot) throw new Error("Invalid dev root domain for tunnel.");
772
+
773
+ const tunnelName = `otavia-dev-${machineName}`;
774
+ const hostname = `${machineName}.${devRoot}`;
775
+ const credentialsPath = path.join(deps.configDir, "credentials.json");
776
+
777
+ log(`Creating or reusing tunnel: ${tunnelName}`);
778
+ const created = await run([
779
+ "cloudflared",
780
+ "tunnel",
781
+ "create",
782
+ "--credentials-file",
783
+ credentialsPath,
784
+ tunnelName,
785
+ ]);
786
+ const createErr = `${created.stdout}\n${created.stderr}`;
787
+ if (
788
+ created.exitCode !== 0 &&
789
+ !/already exists/i.test(createErr)
790
+ ) {
791
+ throw new Error(`Failed to create tunnel ${tunnelName}: ${createErr.trim()}`);
792
+ }
793
+
794
+ log(`Routing DNS: ${hostname} -> ${tunnelName}`);
795
+ const routed = await run([
796
+ "cloudflared",
797
+ "tunnel",
798
+ "route",
799
+ "dns",
800
+ "--overwrite-dns",
801
+ tunnelName,
802
+ hostname,
803
+ ]);
804
+ const routeErr = `${routed.stdout}\n${routed.stderr}`;
805
+ if (
806
+ routed.exitCode !== 0 &&
807
+ !/already exists|already registered/i.test(routeErr)
808
+ ) {
809
+ throw new Error(`Failed to route DNS for ${hostname}: ${routeErr.trim()}`);
810
+ }
811
+
812
+ return { tunnelName, hostname, credentialsPath };
813
+ }
814
+
815
+ export async function ensureCloudflaredInstalled(deps?: {
816
+ run?: CommandRunner;
817
+ platform?: NodeJS.Platform;
818
+ log?: (msg: string) => void;
819
+ }): Promise<void> {
820
+ const run = deps?.run ?? runCommand;
821
+ const log = deps?.log ?? console.log;
822
+ const platform = deps?.platform ?? process.platform;
823
+
824
+ const version = await run(["cloudflared", "--version"]);
825
+ if (version.exitCode === 0) return;
826
+
827
+ log("cloudflared not found, attempting automatic installation...");
828
+ if (platform !== "darwin") {
829
+ throw new Error(
830
+ "cloudflared is not installed. Automatic install is currently supported on macOS only. Please install cloudflared manually and rerun setup."
831
+ );
832
+ }
833
+
834
+ const brew = await run(["brew", "--version"]);
835
+ if (brew.exitCode !== 0) {
836
+ throw new Error(
837
+ "cloudflared is not installed and Homebrew is unavailable. Install Homebrew first or install cloudflared manually."
838
+ );
839
+ }
840
+
841
+ log("Installing cloudflared via Homebrew...");
842
+ const install = await run(["brew", "install", "cloudflared"], { inheritStdio: true });
843
+ if (install.exitCode !== 0) {
844
+ throw new Error("Automatic install failed: brew install cloudflared");
845
+ }
846
+
847
+ const verify = await run(["cloudflared", "--version"]);
848
+ if (verify.exitCode !== 0) {
849
+ throw new Error("cloudflared install verification failed. Please install it manually.");
850
+ }
851
+ }
852
+
853
+ export async function ensureCloudflaredLogin(deps?: {
854
+ run?: CommandRunner;
855
+ log?: (msg: string) => void;
856
+ hasExistingCert?: boolean;
857
+ }): Promise<void> {
858
+ const run = deps?.run ?? runCommand;
859
+ const log = deps?.log ?? console.log;
860
+ const hasExistingCert =
861
+ deps?.hasExistingCert ?? existsSync(path.join(homedir(), ".cloudflared", "cert.pem"));
862
+
863
+ const list = await run(["cloudflared", "tunnel", "list"]);
864
+ if (list.exitCode === 0) return;
865
+
866
+ if (hasExistingCert) {
867
+ log(
868
+ "Found existing cloudflared cert.pem. Skipping forced login and retrying tunnel list..."
869
+ );
870
+ const retryList = await run(["cloudflared", "tunnel", "list"]);
871
+ if (retryList.exitCode === 0) return;
872
+ throw new Error(
873
+ "cloudflared appears to have existing cert.pem, but tunnel list still failed. Check cloudflared auth/network and retry."
874
+ );
875
+ }
876
+
877
+ log("Cloudflare login required, opening browser for cloudflared authentication...");
878
+ const login = await run(["cloudflared", "tunnel", "login"], { inheritStdio: true });
879
+ if (login.exitCode !== 0) {
880
+ throw new Error("cloudflared tunnel login failed.");
881
+ }
882
+
883
+ const verify = await run(["cloudflared", "tunnel", "list"]);
884
+ if (verify.exitCode !== 0) {
885
+ throw new Error("cloudflared login verification failed (cloudflared tunnel list).");
886
+ }
887
+ }