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,59 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ /** Parse .env file content into a key-value map. */
6
+ function parseEnvFile(content: string): Record<string, string> {
7
+ const result: Record<string, string> = {};
8
+ for (const line of content.split("\n")) {
9
+ const trimmed = line.trim();
10
+ if (!trimmed || trimmed.startsWith("#")) continue;
11
+ const eqIdx = trimmed.indexOf("=");
12
+ if (eqIdx === -1) continue;
13
+ const key = trimmed.slice(0, eqIdx).trim();
14
+ let value = trimmed.slice(eqIdx + 1).trim();
15
+ if (
16
+ (value.startsWith('"') && value.endsWith('"')) ||
17
+ (value.startsWith("'") && value.endsWith("'"))
18
+ ) {
19
+ value = value.slice(1, -1);
20
+ }
21
+ result[key] = value;
22
+ }
23
+ return result;
24
+ }
25
+
26
+ /**
27
+ * Load AWS_PROFILE from rootDir/.env only.
28
+ * Returns process.env.AWS_PROFILE ?? envMap.AWS_PROFILE ?? "default".
29
+ */
30
+ export function getAwsProfile(rootDir: string): string {
31
+ const envPath = resolve(rootDir, ".env");
32
+ let envMap: Record<string, string> = {};
33
+ if (existsSync(envPath)) {
34
+ envMap = parseEnvFile(readFileSync(envPath, "utf-8"));
35
+ }
36
+ return process.env.AWS_PROFILE ?? envMap.AWS_PROFILE ?? "default";
37
+ }
38
+
39
+ /**
40
+ * Run `aws sso login --profile <profile>` with stdio inherit; exit with same code as aws.
41
+ */
42
+ export async function awsLoginCommand(rootDir: string): Promise<void> {
43
+ const profile = getAwsProfile(rootDir);
44
+ const result = spawnSync("aws", ["sso", "login", "--profile", profile], {
45
+ stdio: "inherit",
46
+ });
47
+ process.exit(result.status ?? 1);
48
+ }
49
+
50
+ /**
51
+ * Run `aws sso logout --profile <profile>` with stdio inherit; exit with same code as aws.
52
+ */
53
+ export async function awsLogoutCommand(rootDir: string): Promise<void> {
54
+ const profile = getAwsProfile(rootDir);
55
+ const result = spawnSync("aws", ["sso", "logout", "--profile", profile], {
56
+ stdio: "inherit",
57
+ });
58
+ process.exit(result.status ?? 1);
59
+ }
@@ -0,0 +1,33 @@
1
+ import { existsSync } from "node:fs";
2
+ import { relative, resolve } from "node:path";
3
+ import { loadOtaviaYaml } from "../config/load-otavia-yaml.js";
4
+ import { resolveCellDir } from "../config/resolve-cell-dir.js";
5
+
6
+ export function listCellsCommand(rootDir: string): void {
7
+ const root = resolve(rootDir);
8
+ const otavia = loadOtaviaYaml(root);
9
+ const rows: { mount: string; packageName: string; path: string; ok: boolean }[] = [];
10
+
11
+ for (const cell of otavia.cellsList) {
12
+ const dir = resolveCellDir(root, cell.package);
13
+ const cellYaml = resolve(dir, "cell.yaml");
14
+ rows.push({
15
+ mount: cell.mount,
16
+ packageName: cell.package,
17
+ path: dir,
18
+ ok: existsSync(cellYaml),
19
+ });
20
+ }
21
+
22
+ const mountW = Math.max(5, ...rows.map((r) => r.mount.length), "mount".length);
23
+ const pkgW = Math.max(8, ...rows.map((r) => r.packageName.length), "package".length);
24
+
25
+ console.log(
26
+ `${"mount".padEnd(mountW)} ${"package".padEnd(pkgW)} path`
27
+ );
28
+ for (const r of rows) {
29
+ const rel = relative(root, r.path) || ".";
30
+ const suffix = r.ok ? "" : " (no cell.yaml)";
31
+ console.log(`${r.mount.padEnd(mountW)} ${r.packageName.padEnd(pkgW)} ${rel}${suffix}`);
32
+ }
33
+ }
@@ -0,0 +1,32 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { loadOtaviaYaml } from "../config/load-otavia-yaml.js";
4
+ import { resolveCellDir } from "../config/resolve-cell-dir.js";
5
+
6
+ function removeDirIfExists(dirPath: string): void {
7
+ if (fs.existsSync(dirPath)) {
8
+ fs.rmSync(dirPath, { recursive: true, force: true });
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Clean command: remove temp dirs (.cell, .esbuild, .otavia) from root and from each cell directory.
14
+ * Does NOT delete .env or .env.local.
15
+ */
16
+ export function cleanCommand(rootDir: string): void {
17
+ const otavia = loadOtaviaYaml(rootDir);
18
+
19
+ // Root-level temp dirs
20
+ removeDirIfExists(path.join(rootDir, ".cell"));
21
+ removeDirIfExists(path.join(rootDir, ".esbuild"));
22
+ removeDirIfExists(path.join(rootDir, ".otavia"));
23
+
24
+ // Per-cell temp dirs
25
+ for (const entry of otavia.cellsList) {
26
+ const cellDir = resolveCellDir(rootDir, entry.package);
27
+ removeDirIfExists(path.join(cellDir, ".cell"));
28
+ removeDirIfExists(path.join(cellDir, ".esbuild"));
29
+ }
30
+
31
+ console.log("Cleaned .cell, .esbuild, .otavia");
32
+ }
@@ -0,0 +1,508 @@
1
+ import { createInterface } from "node:readline";
2
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
3
+ import { resolve, dirname } from "node:path";
4
+ import { build } from "esbuild";
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 { loadEnvForCell } from "../utils/env.js";
10
+ import { generateTemplate } from "../deploy/template.js";
11
+ import { ensureAcmCertificateWithCloudflare, createCloudFrontDnsRecord } from "../deploy/cloudflare-dns.js";
12
+
13
+ const OTAVIA_BUILD = ".otavia/build";
14
+ const OTAVIA_DIST = ".otavia/dist";
15
+
16
+ interface AwsCliResult {
17
+ exitCode: number;
18
+ stdout: string;
19
+ }
20
+
21
+ async function awsCli(
22
+ args: string[],
23
+ env: Record<string, string | undefined>,
24
+ opts?: { cwd?: string; inheritStdio?: boolean; pipeStderr?: boolean }
25
+ ): Promise<AwsCliResult> {
26
+ const proc = Bun.spawn(["aws", ...args], {
27
+ cwd: opts?.cwd ?? process.cwd(),
28
+ env: { ...process.env, ...env },
29
+ stdout: opts?.inheritStdio ? "inherit" : "pipe",
30
+ stderr: opts?.pipeStderr ? "pipe" : "inherit",
31
+ });
32
+ const stdout = opts?.inheritStdio ? "" : await new Response(proc.stdout).text();
33
+ const exitCode = await proc.exited;
34
+ return { exitCode, stdout: stdout.trim() };
35
+ }
36
+
37
+ async function ensureS3Bucket(
38
+ bucketName: string,
39
+ env: Record<string, string | undefined>
40
+ ): Promise<void> {
41
+ const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION;
42
+ const regionArgs = region ? ["--region", region] : [];
43
+ const { exitCode } = await awsCli(
44
+ ["s3api", "head-bucket", "--bucket", bucketName, ...regionArgs],
45
+ env
46
+ );
47
+ if (exitCode !== 0) {
48
+ console.log(`Creating deploy artifacts bucket: ${bucketName}`);
49
+ const { exitCode: createCode } = await awsCli(
50
+ ["s3", "mb", `s3://${bucketName}`, ...regionArgs],
51
+ env
52
+ );
53
+ if (createCode !== 0) {
54
+ throw new Error(`Failed to create S3 bucket: ${bucketName}`);
55
+ }
56
+ }
57
+ }
58
+
59
+ async function zipDirectory(sourceDir: string, outputPath: string): Promise<void> {
60
+ mkdirSync(resolve(outputPath, ".."), { recursive: true });
61
+ const proc = Bun.spawn(["zip", "-r", "-j", outputPath, sourceDir], {
62
+ stdout: "pipe",
63
+ stderr: "pipe",
64
+ });
65
+ const exitCode = await proc.exited;
66
+ if (exitCode !== 0) {
67
+ const stderr = await new Response(proc.stderr).text();
68
+ throw new Error(`zip failed: ${stderr}`);
69
+ }
70
+ }
71
+
72
+ async function fileHash(filePath: string): Promise<string> {
73
+ const file = Bun.file(filePath);
74
+ const hasher = new Bun.CryptoHasher("sha256");
75
+ hasher.update(await file.arrayBuffer());
76
+ return hasher.digest("hex").slice(0, 12);
77
+ }
78
+
79
+ async function stackExists(
80
+ stackName: string,
81
+ awsEnv: Record<string, string | undefined>
82
+ ): Promise<boolean> {
83
+ const { exitCode } = await awsCli(
84
+ ["cloudformation", "describe-stacks", "--stack-name", stackName, "--max-items", "1"],
85
+ awsEnv,
86
+ { pipeStderr: true }
87
+ );
88
+ return exitCode === 0;
89
+ }
90
+
91
+ /** Load otavia + all cells and resolve params for cloud; throws if missing !Env/!Secret. */
92
+ function loadOtaviaAndResolveParams(rootDir: string) {
93
+ const otavia = loadOtaviaYaml(rootDir);
94
+ const cells: { mount: string; cellDir: string; config: ReturnType<typeof loadCellConfig> }[] = [];
95
+
96
+ for (const entry of otavia.cellsList) {
97
+ const cellDir = resolveCellDir(rootDir, entry.package);
98
+ if (!existsSync(resolve(cellDir, "cell.yaml"))) continue;
99
+ const config = loadCellConfig(cellDir);
100
+ const envMap = loadEnvForCell(rootDir, cellDir, { stage: "deploy" });
101
+ const merged = mergeParams(otavia.params, entry.params) as Record<string, unknown>;
102
+ assertDeclaredParamsProvided(config.params, merged, entry.mount);
103
+ resolveParams(merged, envMap, { onMissingParam: "throw" });
104
+ cells.push({ mount: entry.mount, cellDir, config });
105
+ }
106
+
107
+ return { otavia, cells };
108
+ }
109
+
110
+ /**
111
+ * Build backend: for each cell with backend.entries, esbuild handler to
112
+ * .otavia/build/<mount>/<entryKey>/index.js, then zip to .otavia/build/<mount>-<entryKey>.zip.
113
+ * Returns map: "mount/entryKey" -> hash (first 12 chars SHA256 of zip).
114
+ */
115
+ async function buildBackends(
116
+ rootDir: string,
117
+ cells: { mount: string; cellDir: string; config: ReturnType<typeof loadCellConfig> }[]
118
+ ): Promise<Map<string, string>> {
119
+ const hashes = new Map<string, string>();
120
+ const buildRoot = resolve(rootDir, OTAVIA_BUILD);
121
+
122
+ for (const { mount, cellDir, config } of cells) {
123
+ if (!config.backend?.entries) continue;
124
+ const backendDir = resolve(cellDir, config.backend.dir ?? "backend");
125
+
126
+ for (const [entryKey, entry] of Object.entries(config.backend.entries)) {
127
+ const handlerPath = resolve(backendDir, entry.handler);
128
+ const outDir = resolve(buildRoot, mount, entryKey);
129
+ const outfile = resolve(outDir, "index.js");
130
+ mkdirSync(dirname(outfile), { recursive: true });
131
+
132
+ console.log(` Building backend [${mount}/${entryKey}]...`);
133
+ await build({
134
+ entryPoints: [handlerPath],
135
+ bundle: true,
136
+ platform: "node",
137
+ target: "node20",
138
+ format: "cjs",
139
+ outfile,
140
+ sourcemap: true,
141
+ external: ["@aws-sdk/*"],
142
+ loader: { ".md": "text" },
143
+ });
144
+
145
+ const zipPath = resolve(buildRoot, `${mount}-${entryKey}.zip`);
146
+ await zipDirectory(outDir, zipPath);
147
+ const hash = await fileHash(zipPath);
148
+ hashes.set(`${mount}/${entryKey}`, hash);
149
+ }
150
+ }
151
+
152
+ return hashes;
153
+ }
154
+
155
+ /**
156
+ * Build frontend: for each cell with frontend, run vite build in cellDir
157
+ * with outDir .otavia/dist/<mount> and base /<mount>/.
158
+ */
159
+ async function buildFrontends(
160
+ rootDir: string,
161
+ cells: { mount: string; cellDir: string; config: ReturnType<typeof loadCellConfig> }[]
162
+ ): Promise<void> {
163
+ for (const { mount, cellDir, config } of cells) {
164
+ if (!config.frontend) continue;
165
+ const outDir = resolve(rootDir, OTAVIA_DIST, mount);
166
+ const frontendDir = resolve(cellDir, config.frontend.dir ?? "frontend");
167
+ mkdirSync(outDir, { recursive: true });
168
+
169
+ console.log(` Building frontend [${mount}]...`);
170
+ const proc = Bun.spawn(
171
+ ["bun", "x", "vite", "build", "--logLevel", "error", "--outDir", outDir, "--base", `/${mount}/`],
172
+ {
173
+ cwd: frontendDir,
174
+ stdout: "inherit",
175
+ stderr: "inherit",
176
+ env: { ...process.env },
177
+ }
178
+ );
179
+ const exitCode = await proc.exited;
180
+ if (exitCode !== 0) {
181
+ throw new Error(`Vite build failed for ${mount} (exit code ${exitCode})`);
182
+ }
183
+
184
+ // Build non-HTML frontend entries (e.g. service worker) to explicit route targets.
185
+ for (const [entryKey, frontendEntry] of Object.entries(config.frontend.entries ?? {})) {
186
+ const entryFile = frontendEntry.entry ?? "";
187
+ if (entryFile.endsWith(".html")) continue;
188
+ const route = frontendEntry.routes?.[0];
189
+ if (!route || !route.startsWith("/") || route.includes("*")) continue;
190
+ const outFile = resolve(outDir, route.slice(1));
191
+ mkdirSync(dirname(outFile), { recursive: true });
192
+ console.log(` Building frontend entry [${mount}/${entryKey}] -> ${route}`);
193
+ await build({
194
+ entryPoints: [resolve(frontendDir, entryFile)],
195
+ bundle: true,
196
+ platform: "browser",
197
+ format: "esm",
198
+ target: "es2020",
199
+ outfile: outFile,
200
+ sourcemap: true,
201
+ loader: { ".md": "text" },
202
+ });
203
+ }
204
+ }
205
+ }
206
+
207
+ export async function deployCommand(
208
+ rootDir: string,
209
+ options?: { yes?: boolean }
210
+ ): Promise<void> {
211
+ const awsEnv: Record<string, string | undefined> = {};
212
+ try {
213
+ const rootEnv = resolve(rootDir, ".env");
214
+ if (existsSync(rootEnv)) {
215
+ const content = await Bun.file(rootEnv).text();
216
+ for (const line of content.split("\n")) {
217
+ const trimmed = line.trim();
218
+ if (!trimmed || trimmed.startsWith("#")) continue;
219
+ const eq = trimmed.indexOf("=");
220
+ if (eq === -1) continue;
221
+ const key = trimmed.slice(0, eq).trim();
222
+ let val = trimmed.slice(eq + 1).trim();
223
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))
224
+ val = val.slice(1, -1);
225
+ awsEnv[key] = val;
226
+ }
227
+ }
228
+ } catch {
229
+ // ignore
230
+ }
231
+ if (awsEnv.AWS_PROFILE) awsEnv.AWS_PROFILE = awsEnv.AWS_PROFILE;
232
+ if (awsEnv.AWS_REGION) awsEnv.AWS_REGION = awsEnv.AWS_REGION;
233
+
234
+ const { exitCode: stsCode } = await awsCli(
235
+ ["sts", "get-caller-identity", "--output", "json"],
236
+ awsEnv,
237
+ { pipeStderr: true }
238
+ );
239
+ if (stsCode !== 0) {
240
+ console.error("AWS credentials are not valid. Run: aws sso login (or set AWS_PROFILE/AWS_REGION in .env)");
241
+ process.exit(1);
242
+ }
243
+
244
+ let otavia: ReturnType<typeof loadOtaviaYaml>;
245
+ let cells: { mount: string; cellDir: string; config: ReturnType<typeof loadCellConfig> }[];
246
+
247
+ try {
248
+ const loaded = loadOtaviaAndResolveParams(rootDir);
249
+ otavia = loaded.otavia;
250
+ cells = loaded.cells;
251
+ } catch (err) {
252
+ console.error(err instanceof Error ? err.message : String(err));
253
+ process.exit(1);
254
+ }
255
+
256
+ const stackName = otavia.stackName;
257
+ const deployBucketName = `${stackName}-deploy-artifacts`;
258
+
259
+ console.log("\n=== Building backend ===");
260
+ let lambdaHashes: Map<string, string>;
261
+ try {
262
+ lambdaHashes = await buildBackends(rootDir, cells);
263
+ } catch (err) {
264
+ console.error(err instanceof Error ? err.message : String(err));
265
+ process.exit(1);
266
+ }
267
+
268
+ const hasFrontend = cells.some((c) => c.config.frontend);
269
+ if (hasFrontend) {
270
+ console.log("\n=== Building frontend ===");
271
+ try {
272
+ await buildFrontends(rootDir, cells);
273
+ } catch (err) {
274
+ console.error(err instanceof Error ? err.message : String(err));
275
+ process.exit(1);
276
+ }
277
+ }
278
+
279
+ console.log("\n=== Ensuring deploy bucket ===");
280
+ try {
281
+ await ensureS3Bucket(deployBucketName, awsEnv);
282
+ } catch (err) {
283
+ console.error(err instanceof Error ? err.message : String(err));
284
+ process.exit(1);
285
+ }
286
+
287
+ console.log("\n=== Uploading Lambda zips ===");
288
+ const s3KeyReplacements: { placeholder: string; s3Key: string }[] = [];
289
+ const buildRoot = resolve(rootDir, OTAVIA_BUILD);
290
+
291
+ for (const { mount, config } of cells) {
292
+ if (!config.backend?.entries) continue;
293
+ for (const entryKey of Object.keys(config.backend.entries)) {
294
+ const key = `${mount}/${entryKey}`;
295
+ const hash = lambdaHashes.get(key);
296
+ if (!hash) continue;
297
+ const zipPath = resolve(buildRoot, `${mount}-${entryKey}.zip`);
298
+ const s3Key = `lambda/${mount}/${entryKey}-${hash}.zip`;
299
+ console.log(` Uploading ${s3Key}...`);
300
+ const { exitCode } = await awsCli(
301
+ ["s3", "cp", zipPath, `s3://${deployBucketName}/${s3Key}`],
302
+ awsEnv
303
+ );
304
+ if (exitCode !== 0) {
305
+ console.error(`Failed to upload ${s3Key}`);
306
+ process.exit(1);
307
+ }
308
+ s3KeyReplacements.push({
309
+ placeholder: `build/${mount}/${entryKey}/code.zip`,
310
+ s3Key,
311
+ });
312
+ }
313
+ }
314
+
315
+ // Cloudflare DNS: request ACM certificate before template generation
316
+ let certificateArn: string | undefined;
317
+ const dnsProvider = otavia.domain?.dns?.provider;
318
+ const isCloudflare = dnsProvider === "cloudflare";
319
+ if (isCloudflare && otavia.domain?.host && otavia.domain?.dns?.zoneId) {
320
+ console.log("\n=== Cloudflare DNS: Ensuring ACM certificate ===");
321
+ const cfToken =
322
+ awsEnv.CLOUDFLARE_API_TOKEN?.trim() ||
323
+ awsEnv.CF_API_TOKEN?.trim() ||
324
+ process.env.CLOUDFLARE_API_TOKEN?.trim() ||
325
+ process.env.CF_API_TOKEN?.trim();
326
+ if (!cfToken) {
327
+ console.error("Cloudflare API token required. Set CLOUDFLARE_API_TOKEN in .env or environment.");
328
+ process.exit(1);
329
+ }
330
+ try {
331
+ certificateArn = await ensureAcmCertificateWithCloudflare({
332
+ domainHost: otavia.domain.host,
333
+ zoneId: otavia.domain.dns.zoneId,
334
+ cloudflareToken: cfToken,
335
+ awsEnv,
336
+ awsCli,
337
+ region: awsEnv.AWS_REGION ?? "us-east-1",
338
+ });
339
+ } catch (err) {
340
+ console.error(err instanceof Error ? err.message : String(err));
341
+ process.exit(1);
342
+ }
343
+ }
344
+
345
+ console.log("\n=== Generating CloudFormation template ===");
346
+ let template: string;
347
+ try {
348
+ template = generateTemplate(rootDir, { certificateArn });
349
+ } catch (err) {
350
+ console.error(err instanceof Error ? err.message : String(err));
351
+ process.exit(1);
352
+ }
353
+
354
+ template = template.replace(/S3Bucket: PLACEHOLDER/g, `S3Bucket: ${deployBucketName}`);
355
+ for (const { placeholder, s3Key } of s3KeyReplacements) {
356
+ const escaped = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
357
+ template = template.replace(new RegExp(`S3Key: ${escaped}`), `S3Key: ${s3Key}`);
358
+ }
359
+
360
+ const cfnDir = resolve(rootDir, ".otavia");
361
+ mkdirSync(cfnDir, { recursive: true });
362
+ const packagedPath = resolve(cfnDir, "cfn-packaged.yaml");
363
+ writeFileSync(packagedPath, template);
364
+ console.log(` → .otavia/cfn-packaged.yaml`);
365
+
366
+ if (!options?.yes) {
367
+ const canPromptForConfirm = Boolean(process.stdin.isTTY && process.stdout.isTTY);
368
+ if (!canPromptForConfirm) {
369
+ console.log("Non-interactive terminal detected, continue without prompt (same as --yes).");
370
+ } else {
371
+ process.stdout.write(`About to deploy stack ${stackName}. Continue? (y/N) `);
372
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
373
+ const answer = await new Promise<string>((res) => rl.question("", res));
374
+ rl.close();
375
+ if (!/^y(es)?$/i.test(answer.trim())) {
376
+ console.log("Deploy cancelled.");
377
+ process.exit(0);
378
+ }
379
+ }
380
+ }
381
+
382
+ console.log("\n=== Deploying CloudFormation stack ===");
383
+ console.log(" Streaming CloudFormation deploy output...");
384
+ const regionArgs = awsEnv.AWS_REGION ? ["--region", awsEnv.AWS_REGION] : [];
385
+ const deployProc = Bun.spawn(
386
+ [
387
+ "aws",
388
+ "cloudformation",
389
+ "deploy",
390
+ "--template-file",
391
+ packagedPath,
392
+ "--stack-name",
393
+ stackName,
394
+ "--s3-bucket",
395
+ deployBucketName,
396
+ "--s3-prefix",
397
+ "cloudformation",
398
+ "--capabilities",
399
+ "CAPABILITY_IAM",
400
+ "CAPABILITY_AUTO_EXPAND",
401
+ "--no-fail-on-empty-changeset",
402
+ ...regionArgs,
403
+ ],
404
+ {
405
+ cwd: rootDir,
406
+ env: { ...process.env, ...awsEnv },
407
+ stdout: "inherit",
408
+ stderr: "inherit",
409
+ }
410
+ );
411
+ const deployExitCode = await deployProc.exited;
412
+ if (deployExitCode !== 0) {
413
+ console.error("CloudFormation deploy failed");
414
+ process.exit(1);
415
+ }
416
+
417
+ const { exitCode: descCode, stdout: descOut } = await awsCli(
418
+ [
419
+ "cloudformation",
420
+ "describe-stacks",
421
+ "--stack-name",
422
+ stackName,
423
+ "--query",
424
+ "Stacks[0].Outputs",
425
+ "--output",
426
+ "json",
427
+ ...regionArgs,
428
+ ],
429
+ awsEnv
430
+ );
431
+ if (descCode !== 0) {
432
+ console.error("Failed to get stack outputs");
433
+ process.exit(1);
434
+ }
435
+
436
+ const outputsArr = (JSON.parse(descOut || "[]") as Array<{ OutputKey: string; OutputValue: string }>);
437
+ const outputs: Record<string, string> = {};
438
+ for (const { OutputKey, OutputValue } of outputsArr) {
439
+ outputs[OutputKey] = OutputValue;
440
+ console.log(` ${OutputKey}: ${OutputValue}`);
441
+ }
442
+
443
+ const frontendBucket = outputs.FrontendBucketName;
444
+ const distributionId = outputs.FrontendDistributionId;
445
+
446
+ if (frontendBucket && hasFrontend) {
447
+ console.log("\n=== Uploading frontend ===");
448
+ for (const { mount } of cells) {
449
+ const srcDir = resolve(rootDir, OTAVIA_DIST, mount);
450
+ if (!existsSync(srcDir)) continue;
451
+ console.log(` Syncing ${mount} → s3://${frontendBucket}/${mount}/`);
452
+ const { exitCode: syncCode } = await awsCli(
453
+ ["s3", "sync", srcDir, `s3://${frontendBucket}/${mount}/`, "--delete"],
454
+ awsEnv
455
+ );
456
+ if (syncCode !== 0) {
457
+ console.error(`Failed to sync frontend for ${mount}`);
458
+ process.exit(1);
459
+ }
460
+ }
461
+ }
462
+
463
+ if (distributionId) {
464
+ console.log("\n=== Invalidating CloudFront ===");
465
+ const { exitCode: invCode } = await awsCli(
466
+ ["cloudfront", "create-invalidation", "--distribution-id", distributionId, "--paths", "/*"],
467
+ awsEnv
468
+ );
469
+ if (invCode !== 0) {
470
+ console.error("CloudFront invalidation failed");
471
+ process.exit(1);
472
+ }
473
+ console.log(" Invalidation created");
474
+ }
475
+
476
+ // Cloudflare DNS: create CNAME pointing domain to CloudFront
477
+ if (isCloudflare && otavia.domain?.host && otavia.domain?.dns?.zoneId && outputs.FrontendUrl) {
478
+ console.log("\n=== Cloudflare DNS: Creating domain record ===");
479
+ const cfToken =
480
+ awsEnv.CLOUDFLARE_API_TOKEN?.trim() ||
481
+ awsEnv.CF_API_TOKEN?.trim() ||
482
+ process.env.CLOUDFLARE_API_TOKEN?.trim() ||
483
+ process.env.CF_API_TOKEN?.trim();
484
+ if (cfToken) {
485
+ try {
486
+ // Extract CloudFront domain from the URL output
487
+ const cfDomain = outputs.FrontendUrl.replace("https://", "").replace(/\/$/, "");
488
+ await createCloudFrontDnsRecord({
489
+ domainHost: otavia.domain.host,
490
+ cloudFrontDomain: cfDomain,
491
+ zoneId: otavia.domain.dns.zoneId,
492
+ cloudflareToken: cfToken,
493
+ });
494
+ } catch (err) {
495
+ console.warn(` Warning: DNS record creation failed: ${err instanceof Error ? err.message : err}`);
496
+ console.warn(` You may need to manually create a CNAME: ${otavia.domain.host} → CloudFront domain`);
497
+ }
498
+ }
499
+ }
500
+
501
+ console.log("\n=== Deploy complete! ===");
502
+ if (outputs.FrontendUrl) {
503
+ console.log(` CloudFront URL: ${outputs.FrontendUrl}`);
504
+ }
505
+ if (otavia.domain?.host) {
506
+ console.log(` Domain: https://${otavia.domain.host}`);
507
+ }
508
+ }
@@ -0,0 +1,8 @@
1
+ name: gateway
2
+ backend:
3
+ runtime: bun
4
+ entries:
5
+ api:
6
+ handler: backend/handler.ts
7
+ routes:
8
+ - /oauth/server/callback
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolve } from "node:path";
3
+ import { loadCellConfig } from "../../../config/load-cell-yaml.js";
4
+
5
+ describe("gateway backend route declarations", () => {
6
+ test("includes oauth server callback route for popup flow", () => {
7
+ const gatewayCellDir = resolve(import.meta.dir, "fixtures/gateway-cell");
8
+ const config = loadCellConfig(gatewayCellDir);
9
+ const routes = Object.values(config.backend?.entries ?? {})
10
+ .flatMap((entry) => entry.routes ?? []);
11
+ expect(routes).toContain("/oauth/server/callback");
12
+ });
13
+ });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildForwardUrlForCellMount } from "../forward-url.js";
3
+
4
+ describe("buildForwardUrlForCellMount", () => {
5
+ test("preserves query string when forwarding nested mount paths", () => {
6
+ const forwarded = buildForwardUrlForCellMount(
7
+ "http://localhost:8900/sso/oauth/callback?code=abc-123&state=xyz",
8
+ "/sso"
9
+ );
10
+
11
+ expect(forwarded.pathname).toBe("/oauth/callback");
12
+ expect(forwarded.search).toBe("?code=abc-123&state=xyz");
13
+ });
14
+
15
+ test("forwards mount root to slash path", () => {
16
+ const forwarded = buildForwardUrlForCellMount("http://localhost:8900/sso/", "/sso");
17
+ expect(forwarded.pathname).toBe("/");
18
+ expect(forwarded.search).toBe("");
19
+ });
20
+ });