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
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "otavia",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Otavia stack (deploy, dev, local tooling)",
5
+ "type": "module",
6
+ "exports": {
7
+ "./dev/main-frontend-runtime/main-entry": "./src/commands/dev/main-frontend-runtime/main-entry.ts",
8
+ "./dev/main-frontend-runtime/vite-config": "./src/commands/dev/main-frontend-runtime/vite-config.ts"
9
+ },
10
+ "bin": {
11
+ "otavia": "src/cli.ts"
12
+ },
13
+ "scripts": {
14
+ "dev": "bun run src/cli.ts",
15
+ "typecheck": "tsc --noEmit",
16
+ "test": "bun test src/"
17
+ },
18
+ "dependencies": {
19
+ "@aws-sdk/client-dynamodb": "^3.x",
20
+ "@aws-sdk/client-s3": "^3.x",
21
+ "commander": "^12.1.0",
22
+ "concurrently": "^9.2.1",
23
+ "esbuild": "^0.24.0",
24
+ "hono": "^4.6.0",
25
+ "yaml": "^2.5.0",
26
+ "zod": "^4.3.6"
27
+ },
28
+ "devDependencies": {
29
+ "@types/bun": "^1.3.11",
30
+ "@types/node": "^25.5.0",
31
+ "@vitejs/plugin-react": "^4.x",
32
+ "typescript": "^5.8.3",
33
+ "vite": "^7.x"
34
+ }
35
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "commander";
3
+ import { loadOtaviaYaml } from "./config/load-otavia-yaml.js";
4
+ import { setupCommand } from "./commands/setup.js";
5
+ import { cleanCommand } from "./commands/clean.js";
6
+ import { awsLoginCommand, awsLogoutCommand } from "./commands/aws.js";
7
+ import { devCommand } from "./commands/dev.js";
8
+ import { testUnitCommand, testE2eCommand } from "./commands/test.js";
9
+ import { typecheckCommand } from "./commands/typecheck.js";
10
+ import { lintCommand } from "./commands/lint.js";
11
+ import { deployCommand } from "./commands/deploy.js";
12
+ import { listCellsCommand } from "./commands/cell.js";
13
+ import { initCommand } from "./commands/init.js";
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name("otavia")
19
+ .description("CLI for Otavia stack")
20
+ .version("0.1.0");
21
+
22
+ const placeholderAction = async () => {
23
+ console.log("Not implemented");
24
+ };
25
+
26
+ program.hook("preAction", (_thisCommand, actionCommand) => {
27
+ if (actionCommand.name() === "init") return;
28
+ try {
29
+ loadOtaviaYaml(process.cwd());
30
+ } catch (err) {
31
+ console.error(err instanceof Error ? err.message : String(err));
32
+ process.exit(1);
33
+ }
34
+ });
35
+
36
+ program
37
+ .command("init")
38
+ .description("Create otavia.yaml and a starter cell under cells/app")
39
+ .option("--force", "Overwrite existing otavia.yaml and cells/app/cell.yaml")
40
+ .option("--stack-name <name>", "CloudFormation stack name (default: current directory name)")
41
+ .option("--domain <host>", "Primary domain host (default: example.com)")
42
+ .action(
43
+ (_args: unknown, cmd: { opts: () => { force?: boolean; stackName?: string; domain?: string } }) => {
44
+ const opts = cmd.opts();
45
+ initCommand(process.cwd(), {
46
+ force: opts.force,
47
+ stackName: opts.stackName,
48
+ domain: opts.domain,
49
+ });
50
+ }
51
+ );
52
+
53
+ program.command("setup")
54
+ .description("Setup Otavia stack")
55
+ .option("--tunnel", "Setup tunnel for remote dev")
56
+ .action(
57
+ async (
58
+ _args: unknown,
59
+ cmd: {
60
+ opts: () => { tunnel?: boolean };
61
+ getOptionValueSource: (name: string) => string | undefined;
62
+ }
63
+ ) => {
64
+ const opts = cmd.opts();
65
+ const source = cmd.getOptionValueSource("tunnel");
66
+ await setupCommand(process.cwd(), { tunnel: opts.tunnel, tunnelSpecified: source === "cli" });
67
+ }
68
+ );
69
+ program.command("dev")
70
+ .description("Start development")
71
+ .option("--tunnel", "Enable cloudflared tunnel and use tunnel host URLs")
72
+ .option("--tunnel-host <host>", "Tunnel hostname or full URL used as public base URL")
73
+ .option("--tunnel-config <path>", "Path to cloudflared config.yml")
74
+ .option("--tunnel-protocol <protocol>", "Tunnel transport protocol: quic, http2, or auto")
75
+ .action(
76
+ async (
77
+ _args: unknown,
78
+ cmd: {
79
+ opts: () => {
80
+ tunnel?: boolean;
81
+ tunnelHost?: string;
82
+ tunnelConfig?: string;
83
+ tunnelProtocol?: string;
84
+ };
85
+ }
86
+ ) => {
87
+ const opts = cmd.opts();
88
+ await devCommand(process.cwd(), {
89
+ tunnel: opts.tunnel,
90
+ tunnelHost: opts.tunnelHost,
91
+ tunnelConfig: opts.tunnelConfig,
92
+ tunnelProtocol: opts.tunnelProtocol,
93
+ });
94
+ }
95
+ );
96
+ program.command("test")
97
+ .description("Run tests (unit then e2e)")
98
+ .action(async () => {
99
+ const rootDir = process.cwd();
100
+ await testUnitCommand(rootDir);
101
+ await testE2eCommand(rootDir);
102
+ });
103
+ program.command("test:unit")
104
+ .description("Run unit tests")
105
+ .action(async () => {
106
+ await testUnitCommand(process.cwd());
107
+ });
108
+ program.command("test:e2e")
109
+ .description("Run e2e tests")
110
+ .action(async () => {
111
+ await testE2eCommand(process.cwd());
112
+ });
113
+ program.command("deploy")
114
+ .description("Deploy stack (build, upload, CloudFormation)")
115
+ .option("--yes", "Skip confirmation")
116
+ .action(async (_args: unknown, cmd: { opts: () => { yes?: boolean } }) => {
117
+ await deployCommand(process.cwd(), { yes: cmd.opts().yes });
118
+ });
119
+ program
120
+ .command("typecheck")
121
+ .description("Type check all cells")
122
+ .action(async () => {
123
+ await typecheckCommand(process.cwd());
124
+ });
125
+ program
126
+ .command("lint")
127
+ .description("Lint all cells")
128
+ .option("--fix", "Apply safe fixes")
129
+ .option("--unsafe", "Apply unsafe fixes")
130
+ .action(async (_args: unknown, cmd: { opts: () => { fix?: boolean; unsafe?: boolean } }) => {
131
+ const opts = cmd.opts();
132
+ await lintCommand(process.cwd(), { fix: opts.fix, unsafe: opts.unsafe });
133
+ });
134
+ program.command("clean").description("Clean artifacts").action(() => {
135
+ cleanCommand(process.cwd());
136
+ });
137
+
138
+ const aws = program.command("aws").description("AWS-related commands");
139
+ aws.command("login").description("AWS login").action(async () => { await awsLoginCommand(process.cwd()); });
140
+ aws.command("logout").description("AWS logout").action(async () => { await awsLogoutCommand(process.cwd()); });
141
+
142
+ const cell = program.command("cell").description("List and manage cells");
143
+ cell
144
+ .command("list")
145
+ .description("List cells from otavia.yaml and their resolved directories")
146
+ .action(() => {
147
+ listCellsCommand(process.cwd());
148
+ });
149
+
150
+ program.parseAsync(process.argv).catch((err: unknown) => {
151
+ console.error(err instanceof Error ? err.message : String(err));
152
+ process.exit(1);
153
+ });
@@ -0,0 +1,32 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, test } from "bun:test";
5
+ import { checkAwsCredentials } from "../aws-auth.js";
6
+
7
+ describe("checkAwsCredentials", () => {
8
+ test("uses AWS_PROFILE from root .env when process env missing", async () => {
9
+ const rootDir = mkdtempSync(join(tmpdir(), "otavia-aws-auth-"));
10
+ writeFileSync(join(rootDir, ".env"), "AWS_PROFILE=my-sso-profile\n");
11
+
12
+ const calls: Array<{ args: string[]; env: Record<string, string | undefined> }> = [];
13
+ const result = await checkAwsCredentials(rootDir, async (args, env) => {
14
+ calls.push({ args, env });
15
+ return 0;
16
+ });
17
+
18
+ expect(result.ok).toBe(true);
19
+ expect(result.profile).toBe("my-sso-profile");
20
+ expect(calls[0]?.args).toEqual(["sts", "get-caller-identity", "--output", "json"]);
21
+ expect(calls[0]?.env.AWS_PROFILE).toBe("my-sso-profile");
22
+ });
23
+
24
+ test("reports invalid credentials when sts check fails", async () => {
25
+ const rootDir = mkdtempSync(join(tmpdir(), "otavia-aws-auth-"));
26
+ writeFileSync(join(rootDir, ".env"), "AWS_PROFILE=expired-profile\n");
27
+
28
+ const result = await checkAwsCredentials(rootDir, async () => 255);
29
+ expect(result.ok).toBe(false);
30
+ expect(result.profile).toBe("expired-profile");
31
+ });
32
+ });
@@ -0,0 +1,44 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { describe, expect, test } from "bun:test";
5
+ import { listCellsCommand } from "../cell.js";
6
+
7
+ describe("listCellsCommand", () => {
8
+ test("prints mounts and paths for cells in otavia.yaml", () => {
9
+ const root = mkdtempSync(join(tmpdir(), "otavia-cell-list-"));
10
+ try {
11
+ writeFileSync(
12
+ join(root, "otavia.yaml"),
13
+ `
14
+ stackName: test-stack
15
+ domain:
16
+ host: example.com
17
+ cells:
18
+ sso: "@otavia/sso"
19
+ drive: "@otavia/drive"
20
+ `,
21
+ "utf-8"
22
+ );
23
+ const ssoDir = join(root, "cells", "sso");
24
+ mkdirSync(ssoDir, { recursive: true });
25
+ writeFileSync(join(ssoDir, "cell.yaml"), "name: sso\n", "utf-8");
26
+
27
+ const lines: string[] = [];
28
+ const origLog = console.log;
29
+ console.log = (...args: unknown[]) => {
30
+ lines.push(args.map(String).join(" "));
31
+ };
32
+ try {
33
+ listCellsCommand(root);
34
+ } finally {
35
+ console.log = origLog;
36
+ }
37
+
38
+ expect(lines.some((l) => l.includes("sso") && l.includes("@otavia/sso"))).toBe(true);
39
+ expect(lines.some((l) => l.includes("drive") && l.includes("(no cell.yaml)"))).toBe(true);
40
+ } finally {
41
+ rmSync(root, { recursive: true, force: true });
42
+ }
43
+ });
44
+ });
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveDevPublicBaseUrl, resolveDevTunnelEnabled } from "../dev";
3
+
4
+ describe("resolveDevPublicBaseUrl", () => {
5
+ test("uses tunnel public base URL when tunnel is enabled", () => {
6
+ expect(
7
+ resolveDevPublicBaseUrl({
8
+ tunnelEnabled: true,
9
+ tunnelPublicBaseUrl: "https://mybox.dev.example.com",
10
+ gatewayOnly: false,
11
+ vitePort: 7100,
12
+ })
13
+ ).toBe("https://mybox.dev.example.com");
14
+ });
15
+
16
+ test("uses localhost vite base URL in normal local dev", () => {
17
+ expect(
18
+ resolveDevPublicBaseUrl({
19
+ tunnelEnabled: false,
20
+ gatewayOnly: false,
21
+ vitePort: 7100,
22
+ })
23
+ ).toBe("http://localhost:7100");
24
+ });
25
+
26
+ test("returns undefined in gateway-only mode", () => {
27
+ expect(
28
+ resolveDevPublicBaseUrl({
29
+ tunnelEnabled: false,
30
+ gatewayOnly: true,
31
+ vitePort: 7100,
32
+ })
33
+ ).toBeUndefined();
34
+ });
35
+ });
36
+
37
+ describe("resolveDevTunnelEnabled", () => {
38
+ test("defaults to false when option is missing", () => {
39
+ expect(resolveDevTunnelEnabled()).toBe(false);
40
+ });
41
+
42
+ test("uses explicit tunnel=true", () => {
43
+ expect(resolveDevTunnelEnabled({ tunnel: true })).toBe(true);
44
+ });
45
+
46
+ test("uses explicit tunnel=false", () => {
47
+ expect(resolveDevTunnelEnabled({ tunnel: false })).toBe(false);
48
+ });
49
+ });
@@ -0,0 +1,47 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { describe, expect, test } from "bun:test";
5
+ import { loadOtaviaYaml } from "../../config/load-otavia-yaml.js";
6
+ import { initCommand } from "../init.js";
7
+
8
+ describe("initCommand", () => {
9
+ test("creates otavia.yaml, cells/app/cell.yaml, and valid config", () => {
10
+ const root = mkdtempSync(join(tmpdir(), "otavia-init-"));
11
+ try {
12
+ initCommand(root, { stackName: "demo-stack", domain: "app.example.dev" });
13
+ expect(existsSync(join(root, "otavia.yaml"))).toBe(true);
14
+ expect(existsSync(join(root, "cells", "app", "cell.yaml"))).toBe(true);
15
+ const otavia = loadOtaviaYaml(root);
16
+ expect(otavia.stackName).toBe("demo-stack");
17
+ expect(otavia.domain.host).toBe("app.example.dev");
18
+ expect(otavia.cells.app).toBe("@otavia/app");
19
+ } finally {
20
+ rmSync(root, { recursive: true, force: true });
21
+ }
22
+ });
23
+
24
+ test("refuses to overwrite without --force", () => {
25
+ const root = mkdtempSync(join(tmpdir(), "otavia-init-"));
26
+ try {
27
+ writeFileSync(join(root, "otavia.yaml"), "stackName: x\ndomain:\n host: h\ncells:\n a: '@otavia/a'\n", "utf-8");
28
+ expect(() => initCommand(root, {})).toThrow(/already exists/);
29
+ } finally {
30
+ rmSync(root, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ test("appends common entries to existing .gitignore", () => {
35
+ const root = mkdtempSync(join(tmpdir(), "otavia-init-"));
36
+ try {
37
+ writeFileSync(join(root, ".gitignore"), "custom\n", "utf-8");
38
+ initCommand(root, { stackName: "s", domain: "d.example.com" });
39
+ const gi = readFileSync(join(root, ".gitignore"), "utf-8");
40
+ expect(gi).toContain("custom");
41
+ expect(gi).toContain("node_modules/");
42
+ expect(gi).toContain(".otavia/");
43
+ } finally {
44
+ rmSync(root, { recursive: true, force: true });
45
+ }
46
+ });
47
+ });
@@ -0,0 +1,263 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildCognitoUserPoolClientUpdateArgs,
4
+ buildOAuthCallbackUrl,
5
+ buildTunnelConfigYaml,
6
+ bootstrapNamedTunnel,
7
+ ensureCloudflaredInstalled,
8
+ ensureCloudflaredLogin,
9
+ fetchCloudflareZonesWithToken,
10
+ isAwsSsoExpiredError,
11
+ resolveTunnelSetupEnabled,
12
+ type CommandRunner,
13
+ } from "../setup";
14
+
15
+ describe("resolveTunnelSetupEnabled", () => {
16
+ test("uses explicit --tunnel=true without prompting", async () => {
17
+ const enabled = await resolveTunnelSetupEnabled(
18
+ { tunnel: true, tunnelSpecified: true },
19
+ {
20
+ isTTY: true,
21
+ ask: async () => {
22
+ throw new Error("should not ask");
23
+ },
24
+ }
25
+ );
26
+ expect(enabled).toBe(true);
27
+ });
28
+
29
+ test("uses explicit --tunnel=false without prompting", async () => {
30
+ const enabled = await resolveTunnelSetupEnabled(
31
+ { tunnel: false, tunnelSpecified: true },
32
+ {
33
+ isTTY: true,
34
+ ask: async () => {
35
+ throw new Error("should not ask");
36
+ },
37
+ }
38
+ );
39
+ expect(enabled).toBe(false);
40
+ });
41
+
42
+ test("defaults to false when non-interactive and option unspecified", async () => {
43
+ const enabled = await resolveTunnelSetupEnabled(
44
+ { tunnelSpecified: false },
45
+ {
46
+ isTTY: false,
47
+ }
48
+ );
49
+ expect(enabled).toBe(false);
50
+ });
51
+
52
+ test("prompts user when interactive and option unspecified", async () => {
53
+ const enabled = await resolveTunnelSetupEnabled(
54
+ { tunnelSpecified: false },
55
+ {
56
+ isTTY: true,
57
+ ask: async () => "y",
58
+ }
59
+ );
60
+ expect(enabled).toBe(true);
61
+ });
62
+ });
63
+
64
+ function makeRunner(sequence: Array<{ cmd: string; exitCode: number }>): CommandRunner {
65
+ let idx = 0;
66
+ return async (args) => {
67
+ const step = sequence[idx];
68
+ if (!step) {
69
+ throw new Error(`unexpected command: ${args.join(" ")}`);
70
+ }
71
+ expect(args.join(" ")).toBe(step.cmd);
72
+ idx += 1;
73
+ return { exitCode: step.exitCode, stdout: "", stderr: "" };
74
+ };
75
+ }
76
+
77
+ describe("ensureCloudflaredInstalled", () => {
78
+ test("installs with brew on macOS when cloudflared is missing", async () => {
79
+ const run = makeRunner([
80
+ { cmd: "cloudflared --version", exitCode: 1 },
81
+ { cmd: "brew --version", exitCode: 0 },
82
+ { cmd: "brew install cloudflared", exitCode: 0 },
83
+ { cmd: "cloudflared --version", exitCode: 0 },
84
+ ]);
85
+ await ensureCloudflaredInstalled({ run, platform: "darwin" });
86
+ });
87
+ });
88
+
89
+ describe("ensureCloudflaredLogin", () => {
90
+ test("runs cloudflared tunnel login when not logged in", async () => {
91
+ const run = makeRunner([
92
+ { cmd: "cloudflared tunnel list", exitCode: 1 },
93
+ { cmd: "cloudflared tunnel login", exitCode: 0 },
94
+ { cmd: "cloudflared tunnel list", exitCode: 0 },
95
+ ]);
96
+ await ensureCloudflaredLogin({ run, hasExistingCert: false });
97
+ });
98
+
99
+ test("does not force login when cert exists; retries list", async () => {
100
+ const run = makeRunner([
101
+ { cmd: "cloudflared tunnel list", exitCode: 1 },
102
+ { cmd: "cloudflared tunnel list", exitCode: 0 },
103
+ ]);
104
+ await ensureCloudflaredLogin({ run, hasExistingCert: true });
105
+ });
106
+ });
107
+
108
+ describe("bootstrapNamedTunnel", () => {
109
+ test("creates tunnel and routes DNS", async () => {
110
+ const run = makeRunner([
111
+ {
112
+ cmd: "cloudflared tunnel create --credentials-file /tmp/otavia/credentials.json otavia-dev-mybox",
113
+ exitCode: 0,
114
+ },
115
+ {
116
+ cmd: "cloudflared tunnel route dns --overwrite-dns otavia-dev-mybox mybox.dev.example.com",
117
+ exitCode: 0,
118
+ },
119
+ ]);
120
+ const result = await bootstrapNamedTunnel({
121
+ configDir: "/tmp/otavia",
122
+ devRoot: "dev.example.com",
123
+ machineName: "mybox",
124
+ run,
125
+ });
126
+ expect(result.tunnelName).toBe("otavia-dev-mybox");
127
+ expect(result.hostname).toBe("mybox.dev.example.com");
128
+ expect(result.credentialsPath).toBe("/tmp/otavia/credentials.json");
129
+ });
130
+ });
131
+
132
+ describe("buildTunnelConfigYaml", () => {
133
+ test("renders config for fixed host", () => {
134
+ const yaml = buildTunnelConfigYaml({
135
+ tunnelName: "otavia-dev-mybox",
136
+ credentialsPath: "/tmp/otavia/credentials.json",
137
+ hostname: "mybox.dev.example.com",
138
+ localPort: 7100,
139
+ });
140
+ expect(yaml).toContain("tunnel: otavia-dev-mybox");
141
+ expect(yaml).toContain('hostname: "mybox.dev.example.com"');
142
+ expect(yaml).toContain("service: http://127.0.0.1:7100");
143
+ });
144
+ });
145
+
146
+ describe("buildOAuthCallbackUrl", () => {
147
+ test("builds callback URL from host + cell + path", () => {
148
+ expect(buildOAuthCallbackUrl("mybox.dev.example.com", "sso", "/oauth/callback")).toBe(
149
+ "https://mybox.dev.example.com/sso/oauth/callback"
150
+ );
151
+ });
152
+ });
153
+
154
+ describe("fetchCloudflareZonesWithToken", () => {
155
+ test("returns zones on successful API response", async () => {
156
+ const zones = await fetchCloudflareZonesWithToken("token", async () => {
157
+ return new Response(
158
+ JSON.stringify({
159
+ success: true,
160
+ result: [
161
+ { id: "z1", name: "example.com" },
162
+ { id: "z2", name: "dev.example.com" },
163
+ ],
164
+ }),
165
+ { status: 200, headers: { "Content-Type": "application/json" } }
166
+ );
167
+ });
168
+ expect(zones).toEqual([
169
+ { id: "z1", name: "example.com" },
170
+ { id: "z2", name: "dev.example.com" },
171
+ ]);
172
+ });
173
+
174
+ test("returns empty list on API failure", async () => {
175
+ const zones = await fetchCloudflareZonesWithToken("token", async () => {
176
+ return new Response("denied", { status: 403 });
177
+ });
178
+ expect(zones).toEqual([]);
179
+ });
180
+ });
181
+
182
+ describe("isAwsSsoExpiredError", () => {
183
+ test("detects common AWS SSO expiration errors", () => {
184
+ expect(isAwsSsoExpiredError("Error when retrieving token from sso: Token has expired and refresh failed")).toBe(true);
185
+ expect(isAwsSsoExpiredError("ExpiredToken: The security token included in the request is expired")).toBe(true);
186
+ });
187
+
188
+ test("does not match unrelated errors", () => {
189
+ expect(isAwsSsoExpiredError("AccessDeniedException: User is not authorized")).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe("buildCognitoUserPoolClientUpdateArgs", () => {
194
+ test("keeps existing OAuth config when present", () => {
195
+ const args = buildCognitoUserPoolClientUpdateArgs(
196
+ {
197
+ AllowedOAuthFlows: ["code"],
198
+ AllowedOAuthScopes: ["openid", "email", "profile"],
199
+ SupportedIdentityProviders: ["COGNITO"],
200
+ },
201
+ ["https://mymbp.shazhou.work/sso/oauth/callback"],
202
+ ["https://mymbp.shazhou.work"]
203
+ );
204
+ expect(args).toContain("--allowed-o-auth-flows-user-pool-client");
205
+ expect(args).toEqual(
206
+ expect.arrayContaining([
207
+ "--allowed-o-auth-flows",
208
+ "code",
209
+ "--allowed-o-auth-scopes",
210
+ "openid",
211
+ "email",
212
+ "profile",
213
+ "--supported-identity-providers",
214
+ "COGNITO",
215
+ ])
216
+ );
217
+ });
218
+
219
+ test("falls back to safe OAuth defaults when describe has empty lists", () => {
220
+ const args = buildCognitoUserPoolClientUpdateArgs(
221
+ {
222
+ AllowedOAuthFlows: [],
223
+ AllowedOAuthScopes: [],
224
+ SupportedIdentityProviders: [],
225
+ },
226
+ ["https://mymbp.shazhou.work/sso/oauth/callback"],
227
+ ["https://mymbp.shazhou.work"]
228
+ );
229
+ expect(args).toEqual(
230
+ expect.arrayContaining([
231
+ "--allowed-o-auth-flows",
232
+ "code",
233
+ "--allowed-o-auth-scopes",
234
+ "openid",
235
+ "email",
236
+ "profile",
237
+ "--supported-identity-providers",
238
+ "COGNITO",
239
+ ])
240
+ );
241
+ });
242
+
243
+ test("uses merged identity providers when explicitly provided", () => {
244
+ const args = buildCognitoUserPoolClientUpdateArgs(
245
+ {
246
+ AllowedOAuthFlows: ["code"],
247
+ AllowedOAuthScopes: ["openid", "email", "profile"],
248
+ SupportedIdentityProviders: ["COGNITO"],
249
+ },
250
+ ["https://mymbp.shazhou.work/sso/oauth/callback"],
251
+ ["https://mymbp.shazhou.work"],
252
+ ["COGNITO", "Google", "Microsoft"]
253
+ );
254
+ expect(args).toEqual(
255
+ expect.arrayContaining([
256
+ "--supported-identity-providers",
257
+ "COGNITO",
258
+ "Google",
259
+ "Microsoft",
260
+ ])
261
+ );
262
+ });
263
+ });
@@ -0,0 +1,32 @@
1
+ import { getAwsProfile } from "./aws.js";
2
+
3
+ export type AwsCliRunner = (
4
+ args: string[],
5
+ env: Record<string, string | undefined>
6
+ ) => Promise<number>;
7
+
8
+ const defaultAwsCliRunner: AwsCliRunner = async (args, env) => {
9
+ const proc = Bun.spawn(["aws", ...args], {
10
+ cwd: process.cwd(),
11
+ env: { ...process.env, ...env },
12
+ stdout: "ignore",
13
+ stderr: "ignore",
14
+ });
15
+ return await proc.exited;
16
+ };
17
+
18
+ export async function checkAwsCredentials(
19
+ rootDir: string,
20
+ runAwsCli: AwsCliRunner = defaultAwsCliRunner
21
+ ): Promise<{ ok: boolean; profile: string }> {
22
+ const profile = getAwsProfile(rootDir);
23
+ const env: Record<string, string | undefined> = {
24
+ AWS_PROFILE: process.env.AWS_PROFILE ?? profile,
25
+ AWS_REGION: process.env.AWS_REGION,
26
+ };
27
+ const exitCode = await runAwsCli(
28
+ ["sts", "get-caller-identity", "--output", "json"],
29
+ env
30
+ );
31
+ return { ok: exitCode === 0, profile };
32
+ }