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,137 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ assertDeclaredParamsProvided,
4
+ MissingDeclaredParamsError,
5
+ MissingParamsError,
6
+ mergeParams,
7
+ resolveParams,
8
+ } from "../resolve-params.js";
9
+
10
+ describe("mergeParams", () => {
11
+ test("stack has A, B; cell has B, C → result has A(from stack), B(from cell), C(from cell)", () => {
12
+ const stack = { A: "stackA", B: "stackB" };
13
+ const cell = { B: "cellB", C: "cellC" };
14
+ const result = mergeParams(stack, cell);
15
+ expect(result.A).toBe("stackA");
16
+ expect(result.B).toBe("cellB");
17
+ expect(result.C).toBe("cellC");
18
+ });
19
+
20
+ test("empty stack and cell returns empty object", () => {
21
+ expect(mergeParams(undefined, undefined)).toEqual({});
22
+ expect(mergeParams({}, {})).toEqual({});
23
+ });
24
+
25
+ test("cell-only params", () => {
26
+ expect(mergeParams(undefined, { x: 1 })).toEqual({ x: 1 });
27
+ });
28
+
29
+ test("stack-only params", () => {
30
+ expect(mergeParams({ x: 1 }, undefined)).toEqual({ x: 1 });
31
+ });
32
+
33
+ test("cell !Param references top-level params", () => {
34
+ const stack = { REGION: "us-east-1", API_KEY: { secret: "BFL_API_KEY" } };
35
+ const cell = {
36
+ REGION: { param: "REGION" },
37
+ BFL_API_KEY: { param: "API_KEY" },
38
+ };
39
+ expect(mergeParams(stack, cell)).toEqual({
40
+ REGION: "us-east-1",
41
+ API_KEY: { secret: "BFL_API_KEY" },
42
+ BFL_API_KEY: { secret: "BFL_API_KEY" },
43
+ });
44
+ });
45
+ });
46
+
47
+ describe("resolveParams", () => {
48
+ test("all EnvRef/SecretRef present in envMap → replaced with strings", () => {
49
+ const merged = {
50
+ API_URL: { env: "API_URL" },
51
+ TOKEN: { secret: "API_TOKEN" },
52
+ plain: "hello",
53
+ };
54
+ const envMap = { API_URL: "https://api.example.com", API_TOKEN: "s3cret" };
55
+ const result = resolveParams(merged, envMap);
56
+ expect(result.API_URL).toBe("https://api.example.com");
57
+ expect(result.TOKEN).toBe("s3cret");
58
+ expect(result.plain).toBe("hello");
59
+ });
60
+
61
+ test("nested object: EnvRef inside object is resolved", () => {
62
+ const merged = {
63
+ dns: {
64
+ provider: "cloudflare",
65
+ zoneId: { env: "ZONE_ID" },
66
+ },
67
+ };
68
+ const envMap = { ZONE_ID: "zone-123" };
69
+ const result = resolveParams(merged, envMap);
70
+ expect(result).toEqual({
71
+ dns: {
72
+ provider: "cloudflare",
73
+ zoneId: "zone-123",
74
+ },
75
+ });
76
+ });
77
+
78
+ test("missing env key with onMissingParam throw → throws MissingParamsError with missing key listed", () => {
79
+ const merged = {
80
+ API_URL: { env: "API_URL" },
81
+ KEY: { env: "MISSING_VAR" },
82
+ };
83
+ const envMap = { API_URL: "https://api.example.com" };
84
+ expect(() => resolveParams(merged, envMap)).toThrow(MissingParamsError);
85
+ try {
86
+ resolveParams(merged, envMap);
87
+ } catch (e) {
88
+ expect(e).toBeInstanceOf(MissingParamsError);
89
+ expect((e as MissingParamsError).missingKeys).toContain("MISSING_VAR");
90
+ }
91
+ });
92
+
93
+ test("missing secret with onMissingParam throw → throws MissingParamsError", () => {
94
+ const merged = { TOKEN: { secret: "MISSING_SECRET" } };
95
+ const envMap = {};
96
+ expect(() => resolveParams(merged, envMap)).toThrow(MissingParamsError);
97
+ try {
98
+ resolveParams(merged, envMap);
99
+ } catch (e) {
100
+ expect((e as MissingParamsError).missingKeys).toContain("MISSING_SECRET");
101
+ }
102
+ });
103
+
104
+ test("missing with onMissingParam placeholder → use placeholder for missing", () => {
105
+ const merged = {
106
+ API_URL: { env: "API_URL" },
107
+ MISSING: { env: "MISSING_VAR" },
108
+ TOKEN: { secret: "API_TOKEN" },
109
+ };
110
+ const envMap = { API_URL: "https://api.example.com" };
111
+ const result = resolveParams(merged, envMap, {
112
+ onMissingParam: "placeholder",
113
+ });
114
+ expect(result.API_URL).toBe("https://api.example.com");
115
+ expect(result.MISSING).toBe("[missing]");
116
+ expect(result.TOKEN).toBe("[missing]");
117
+ });
118
+
119
+ test("default onMissingParam is throw", () => {
120
+ const merged = { X: { env: "NOT_IN_ENV" } };
121
+ expect(() => resolveParams(merged, {})).toThrow(MissingParamsError);
122
+ });
123
+ });
124
+
125
+ describe("assertDeclaredParamsProvided", () => {
126
+ test("passes when all declared params are present", () => {
127
+ expect(() =>
128
+ assertDeclaredParamsProvided(["A", "B"], { A: 1, B: { env: "B" } }, "cell-a")
129
+ ).not.toThrow();
130
+ });
131
+
132
+ test("throws when declared params are missing", () => {
133
+ expect(() =>
134
+ assertDeclaredParamsProvided(["A", "B"], { A: 1 }, "cell-a")
135
+ ).toThrow(MissingDeclaredParamsError);
136
+ });
137
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ bucketPhysicalName,
4
+ tablePhysicalName,
5
+ } from "../resource-names.js";
6
+
7
+ describe("tablePhysicalName", () => {
8
+ test("returns myapp-server-next-realms for (myapp, server-next, realms)", () => {
9
+ expect(tablePhysicalName("myapp", "server-next", "realms")).toBe(
10
+ "myapp-server-next-realms",
11
+ );
12
+ });
13
+
14
+ test("normalizes uppercase to lowercase", () => {
15
+ expect(tablePhysicalName("MyApp", "Server-Next", "Realms")).toBe(
16
+ "myapp-server-next-realms",
17
+ );
18
+ });
19
+
20
+ test("normalizes underscore to hyphen", () => {
21
+ expect(tablePhysicalName("my_app", "server_next", "realms_table")).toBe(
22
+ "my-app-server-next-realms-table",
23
+ );
24
+ });
25
+ });
26
+
27
+ describe("bucketPhysicalName", () => {
28
+ test("returns normalized name when length ≤63", () => {
29
+ const name = bucketPhysicalName("myapp", "server-next", "assets");
30
+ expect(name).toBe("myapp-server-next-assets");
31
+ expect(name.length).toBeLessThanOrEqual(63);
32
+ });
33
+
34
+ test("normalizes uppercase and underscore like table", () => {
35
+ expect(bucketPhysicalName("MyApp", "server_next", "Assets")).toBe(
36
+ "myapp-server-next-assets",
37
+ );
38
+ });
39
+
40
+ test("when total length > 63, result ≤63 and includes hash suffix", () => {
41
+ const stackName = "myapp";
42
+ const cellId = "server-next";
43
+ const bucketKey = "a".repeat(60);
44
+ const full = `${stackName}-${cellId}-${bucketKey}`;
45
+ expect(full.length).toBeGreaterThan(63);
46
+
47
+ const name = bucketPhysicalName(stackName, cellId, bucketKey);
48
+ expect(name.length).toBeLessThanOrEqual(63);
49
+ expect(name.length).toBe(63);
50
+ expect(name).toMatch(/^[a-z0-9-]+$/);
51
+ expect(name).toMatch(/-[a-f0-9]{8}$/);
52
+ });
53
+
54
+ test("same inputs produce same bucket name (deterministic)", () => {
55
+ const stackName = "longstack";
56
+ const cellId = "longcell";
57
+ const bucketKey = "x".repeat(50);
58
+ const a = bucketPhysicalName(stackName, cellId, bucketKey);
59
+ const b = bucketPhysicalName(stackName, cellId, bucketKey);
60
+ expect(a).toBe(b);
61
+ });
62
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Schema types for otavia cell.yaml (otavia variant).
3
+ * Excludes: pathPrefix, bucketNameSuffix, dev, domain, domains, cloudflare, network.
4
+ */
5
+
6
+ export type SecretRef = { secret: string };
7
+ export type EnvRef = { env: string };
8
+ export type ParamRef = { param: string };
9
+
10
+ /**
11
+ * Param value shape used by config pipeline.
12
+ * NOTE: cell.yaml itself does not support !Env/!Secret; these refs come from otavia.yaml params.
13
+ */
14
+ export type RawParamValue =
15
+ | string
16
+ | SecretRef
17
+ | EnvRef
18
+ | ParamRef
19
+ | Record<string, unknown>;
20
+
21
+ export function isSecretRef(v: unknown): v is SecretRef {
22
+ return (
23
+ typeof v === "object" &&
24
+ v !== null &&
25
+ "secret" in v &&
26
+ !("env" in v)
27
+ );
28
+ }
29
+
30
+ export function isEnvRef(v: unknown): v is EnvRef {
31
+ return (
32
+ typeof v === "object" &&
33
+ v !== null &&
34
+ "env" in v &&
35
+ !("secret" in v)
36
+ );
37
+ }
38
+
39
+ export function isParamRef(v: unknown): v is ParamRef {
40
+ return (
41
+ typeof v === "object" &&
42
+ v !== null &&
43
+ "param" in v &&
44
+ !("env" in v) &&
45
+ !("secret" in v)
46
+ );
47
+ }
48
+
49
+ export interface BackendEntry {
50
+ handler: string;
51
+ app?: string;
52
+ timeout: number;
53
+ memory: number;
54
+ routes: string[];
55
+ }
56
+
57
+ export interface BackendConfig {
58
+ dir?: string;
59
+ runtime: string;
60
+ entries: Record<string, BackendEntry>;
61
+ }
62
+
63
+ export interface FrontendEntry {
64
+ entry: string;
65
+ routes: string[];
66
+ }
67
+
68
+ export interface FrontendConfig {
69
+ dir: string;
70
+ entries: Record<string, FrontendEntry>;
71
+ }
72
+
73
+ export interface TableGsi {
74
+ keys: Record<string, string>;
75
+ projection: string;
76
+ }
77
+
78
+ export interface TableConfig {
79
+ keys: Record<string, string>;
80
+ gsi?: Record<string, TableGsi>;
81
+ }
82
+
83
+ export interface TestingConfig {
84
+ unit?: string;
85
+ e2e?: string;
86
+ }
87
+
88
+ export type OAuthRole = "resource_server" | "authorization_server" | "both";
89
+
90
+ export interface OAuthConfig {
91
+ enabled: boolean;
92
+ role: OAuthRole;
93
+ scopes: string[];
94
+ }
95
+
96
+ export interface CognitoConfig {
97
+ region: string;
98
+ userPoolId: string;
99
+ clientId: string;
100
+ hostedUiUrl?: string;
101
+ clientSecret?: string;
102
+ }
103
+
104
+ export interface CellConfig {
105
+ name: string;
106
+ backend?: BackendConfig;
107
+ frontend?: FrontendConfig;
108
+ testing?: TestingConfig;
109
+ tables?: Record<string, TableConfig>;
110
+ buckets?: Record<string, Record<string, unknown>>;
111
+ oauth?: OAuthConfig;
112
+ cognito?: CognitoConfig;
113
+ /** Declared required param keys; values are provided by otavia.yaml. */
114
+ params?: string[];
115
+ }
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { parseDocument } from "yaml";
4
+ import type { CellConfig } from "./cell-yaml-schema.js";
5
+
6
+ const OAUTH_ROLES = new Set(["resource_server", "authorization_server", "both"]);
7
+
8
+ /**
9
+ * Load and parse cell.yaml from cellDir.
10
+ * cell.yaml should only declare required param keys; !Env/!Secret are not supported here.
11
+ */
12
+ export function loadCellConfig(cellDir: string): CellConfig {
13
+ const filePath = path.join(cellDir, "cell.yaml");
14
+ let content: string;
15
+ try {
16
+ content = readFileSync(filePath, "utf-8");
17
+ } catch (err) {
18
+ const message = err instanceof Error ? err.message : String(err);
19
+ throw new Error(`Failed to read cell.yaml: ${message}`);
20
+ }
21
+
22
+ if (/(^|[\s:[{,])!(Env|Secret)\b/m.test(content)) {
23
+ throw new Error("cell.yaml: !Env and !Secret are not supported; move refs to otavia.yaml params");
24
+ }
25
+
26
+ const doc = parseDocument(content);
27
+ const raw = doc.toJS() as Record<string, unknown> | null | undefined;
28
+
29
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
30
+ throw new Error("cell.yaml: invalid YAML or empty document");
31
+ }
32
+
33
+ const name = raw.name;
34
+ if (name == null) {
35
+ throw new Error("cell.yaml: missing required field 'name'");
36
+ }
37
+ if (typeof name !== "string" || name.trim() === "") {
38
+ throw new Error("cell.yaml: 'name' must be a non-empty string");
39
+ }
40
+
41
+ if (raw.params != null) {
42
+ if (!Array.isArray(raw.params)) {
43
+ throw new Error("cell.yaml: 'params' must be an array of strings");
44
+ }
45
+ for (let i = 0; i < raw.params.length; i += 1) {
46
+ if (typeof raw.params[i] !== "string" || raw.params[i].trim() === "") {
47
+ throw new Error(`cell.yaml: params[${i}] must be a non-empty string`);
48
+ }
49
+ }
50
+ }
51
+
52
+ if (raw.oauth != null) {
53
+ if (typeof raw.oauth !== "object" || Array.isArray(raw.oauth)) {
54
+ throw new Error("cell.yaml: 'oauth' must be an object");
55
+ }
56
+ const oauth = raw.oauth as Record<string, unknown>;
57
+ if ("issuerPath" in oauth) {
58
+ throw new Error("cell.yaml: 'oauth.issuerPath' is not supported in v1; issuer path is derived from mount");
59
+ }
60
+ if ("discovery" in oauth) {
61
+ throw new Error("cell.yaml: 'oauth.discovery' is not supported in v1; discovery is automatic for oauth-enabled cells");
62
+ }
63
+
64
+ if (typeof oauth.enabled !== "boolean") {
65
+ throw new Error("cell.yaml: 'oauth.enabled' must be a boolean");
66
+ }
67
+ if (typeof oauth.role !== "string" || !OAUTH_ROLES.has(oauth.role)) {
68
+ throw new Error(
69
+ "cell.yaml: 'oauth.role' must be one of: resource_server, authorization_server, both"
70
+ );
71
+ }
72
+ if (!Array.isArray(oauth.scopes)) {
73
+ throw new Error("cell.yaml: 'oauth.scopes' must be an array of strings");
74
+ }
75
+ for (let i = 0; i < oauth.scopes.length; i += 1) {
76
+ const scope = oauth.scopes[i];
77
+ if (typeof scope !== "string" || scope.trim() === "") {
78
+ throw new Error(`cell.yaml: oauth.scopes[${i}] must be a non-empty string`);
79
+ }
80
+ }
81
+ if (oauth.enabled && oauth.scopes.length === 0) {
82
+ throw new Error("cell.yaml: 'oauth.scopes' must be a non-empty array of strings when oauth.enabled is true");
83
+ }
84
+ }
85
+
86
+ return raw as unknown as CellConfig;
87
+ }
@@ -0,0 +1,256 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { parseDocument, type SchemaOptions } from "yaml";
4
+ import type { OtaviaYaml } from "./otavia-yaml-schema.js";
5
+ import { isEnvRef, isParamRef, isSecretRef } from "./cell-yaml-schema.js";
6
+
7
+ const CONFIG_FILENAME = "otavia.yaml";
8
+ const DEFAULT_SCOPE = "@otavia";
9
+
10
+ type CellRef = { mount: string; package: string; params?: Record<string, unknown> };
11
+
12
+ const customTags: SchemaOptions["customTags"] = [
13
+ {
14
+ tag: "!Secret",
15
+ resolve(value: string) {
16
+ return { secret: value ?? "" };
17
+ },
18
+ },
19
+ {
20
+ tag: "!Env",
21
+ resolve(value: string) {
22
+ return { env: value ?? "" };
23
+ },
24
+ },
25
+ {
26
+ tag: "!Param",
27
+ resolve(value: string) {
28
+ return { param: value ?? "" };
29
+ },
30
+ },
31
+ ];
32
+
33
+ function walkParamTree(
34
+ value: unknown,
35
+ pathLabel: string,
36
+ visitor: (v: unknown, p: string) => void
37
+ ): void {
38
+ visitor(value, pathLabel);
39
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return;
40
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
41
+ walkParamTree(v, `${pathLabel}.${k}`, visitor);
42
+ }
43
+ }
44
+
45
+ function packageToMount(packageName: string): string {
46
+ const trimmed = packageName.trim();
47
+ if (trimmed.length === 0) return trimmed;
48
+ const parts = trimmed.split("/");
49
+ return trimmed.startsWith("@") ? (parts[1] ?? "") : (parts[0] ?? "");
50
+ }
51
+
52
+ function normalizeParams(value: unknown, pathLabel: string): Record<string, unknown> | undefined {
53
+ if (value == null) return undefined;
54
+ if (typeof value !== "object" || Array.isArray(value)) {
55
+ throw new Error(`${pathLabel} must be an object`);
56
+ }
57
+ return value as Record<string, unknown>;
58
+ }
59
+
60
+ function parseCells(data: unknown): { cells: Record<string, string>; cellsList: CellRef[] } {
61
+ if (data == null) {
62
+ throw new Error("otavia.yaml: missing cells");
63
+ }
64
+ if (Array.isArray(data)) {
65
+ if (data.length === 0) {
66
+ throw new Error("otavia.yaml: cells must be a non-empty array or object");
67
+ }
68
+ const cellsList: CellRef[] = [];
69
+ for (let i = 0; i < data.length; i += 1) {
70
+ const item = data[i];
71
+ const itemPath = `otavia.yaml: cells[${i}]`;
72
+ if (typeof item === "string") {
73
+ const mount = item.trim();
74
+ if (!mount) throw new Error(`${itemPath} must be a non-empty string`);
75
+ cellsList.push({ mount, package: `${DEFAULT_SCOPE}/${mount}` });
76
+ continue;
77
+ }
78
+ if (item == null || typeof item !== "object" || Array.isArray(item)) {
79
+ throw new Error(`${itemPath} must be a string or an object { package, mount?, params? }`);
80
+ }
81
+ const record = item as Record<string, unknown>;
82
+ const packageName = typeof record.package === "string" ? record.package.trim() : "";
83
+ if (!packageName) throw new Error(`${itemPath}.package must be a non-empty string`);
84
+ const mount =
85
+ typeof record.mount === "string" && record.mount.trim()
86
+ ? record.mount.trim()
87
+ : packageToMount(packageName);
88
+ if (!mount) throw new Error(`${itemPath}.mount is required when package cannot infer mount`);
89
+ const params = normalizeParams(record.params, `${itemPath}.params`);
90
+ cellsList.push({ mount, package: packageName, params });
91
+ }
92
+ const cells = Object.fromEntries(cellsList.map((c) => [c.mount, c.package]));
93
+ return { cells, cellsList };
94
+ }
95
+ if (typeof data === "object" && !Array.isArray(data)) {
96
+ const entries = Object.entries(data as Record<string, unknown>);
97
+ if (entries.length === 0) {
98
+ throw new Error("otavia.yaml: cells object must have at least one entry");
99
+ }
100
+ const cellsList: CellRef[] = [];
101
+ for (const [mountRaw, cellDef] of entries) {
102
+ const mount = mountRaw.trim();
103
+ if (typeof mount !== "string" || mount === "") {
104
+ throw new Error("otavia.yaml: cells keys (mount) must be non-empty strings");
105
+ }
106
+ if (typeof cellDef === "string") {
107
+ const packageName = cellDef.trim();
108
+ if (!packageName) {
109
+ throw new Error(`otavia.yaml: cells["${mount}"] must be a non-empty package name string`);
110
+ }
111
+ cellsList.push({ mount, package: packageName });
112
+ continue;
113
+ }
114
+ if (cellDef == null || typeof cellDef !== "object" || Array.isArray(cellDef)) {
115
+ throw new Error(
116
+ `otavia.yaml: cells["${mount}"] must be a package string or object { package, params? }`
117
+ );
118
+ }
119
+ const record = cellDef as Record<string, unknown>;
120
+ const packageName = typeof record.package === "string" ? record.package.trim() : "";
121
+ if (!packageName) {
122
+ throw new Error(`otavia.yaml: cells["${mount}"].package must be a non-empty string`);
123
+ }
124
+ const params = normalizeParams(record.params, `otavia.yaml: cells["${mount}"].params`);
125
+ cellsList.push({ mount, package: packageName, params });
126
+ }
127
+ const cells = Object.fromEntries(cellsList.map((c) => [c.mount, c.package]));
128
+ return { cells, cellsList };
129
+ }
130
+ throw new Error("otavia.yaml: cells must be an array or an object");
131
+ }
132
+
133
+ export function loadOtaviaYaml(rootDir: string): OtaviaYaml {
134
+ const configPath = path.resolve(rootDir, CONFIG_FILENAME);
135
+ if (!fs.existsSync(configPath)) {
136
+ throw new Error("otavia.yaml not found");
137
+ }
138
+ const raw = fs.readFileSync(configPath, "utf-8");
139
+ const doc = parseDocument(raw, { customTags });
140
+ const data = doc.toJSON() as Record<string, unknown> | null | undefined;
141
+ if (data == null || typeof data !== "object") {
142
+ throw new Error("otavia.yaml: invalid YAML or empty document");
143
+ }
144
+
145
+ if (data.stackName == null || data.stackName === "") {
146
+ throw new Error("otavia.yaml: missing stackName");
147
+ }
148
+ if (typeof data.stackName !== "string") {
149
+ throw new Error("otavia.yaml: stackName must be a string");
150
+ }
151
+
152
+ const { cells, cellsList } = parseCells(data.cells);
153
+ let defaultCell: string | undefined;
154
+ if (data.defaultCell != null) {
155
+ if (typeof data.defaultCell !== "string") {
156
+ throw new Error("otavia.yaml: defaultCell must be a string");
157
+ }
158
+ const normalized = data.defaultCell.trim();
159
+ if (!normalized) {
160
+ throw new Error("otavia.yaml: defaultCell must be a string");
161
+ }
162
+ const mountSet = new Set(cellsList.map((cell) => cell.mount));
163
+ if (!mountSet.has(normalized)) {
164
+ throw new Error(
165
+ `otavia.yaml: defaultCell "${normalized}" must match one of configured cell mounts`
166
+ );
167
+ }
168
+ defaultCell = normalized;
169
+ }
170
+
171
+ if (data.domain == null || typeof data.domain !== "object") {
172
+ throw new Error("otavia.yaml: missing domain");
173
+ }
174
+ const domain = data.domain as Record<string, unknown>;
175
+ if (domain.host == null || domain.host === "") {
176
+ throw new Error("otavia.yaml: missing domain.host");
177
+ }
178
+ if (typeof domain.host !== "string") {
179
+ throw new Error("otavia.yaml: domain.host must be a string");
180
+ }
181
+
182
+ const result: OtaviaYaml = {
183
+ stackName: data.stackName as string,
184
+ defaultCell,
185
+ cells,
186
+ cellsList,
187
+ domain: {
188
+ host: domain.host as string,
189
+ dns:
190
+ domain.dns != null && typeof domain.dns === "object"
191
+ ? {
192
+ provider:
193
+ (domain.dns as Record<string, unknown>).provider as
194
+ | string
195
+ | undefined,
196
+ zone: (domain.dns as Record<string, unknown>).zone as
197
+ | string
198
+ | undefined,
199
+ zoneId: (domain.dns as Record<string, unknown>).zoneId as
200
+ | string
201
+ | undefined,
202
+ }
203
+ : undefined,
204
+ },
205
+ };
206
+ if (data.params != null && typeof data.params === "object" && !Array.isArray(data.params)) {
207
+ result.params = data.params as Record<string, unknown>;
208
+ walkParamTree(result.params, "otavia.yaml: params", (v, p) => {
209
+ if (isParamRef(v)) {
210
+ throw new Error(`${p} cannot use !Param; top-level params only allow plain values, !Env, !Secret`);
211
+ }
212
+ });
213
+ }
214
+ for (const cell of result.cellsList) {
215
+ if (!cell.params) continue;
216
+ walkParamTree(cell.params, `otavia.yaml: cells["${cell.mount}"].params`, (v, p) => {
217
+ if (isEnvRef(v) || isSecretRef(v)) {
218
+ throw new Error(`${p} cannot use !Env/!Secret; use !Param to reference top-level params`);
219
+ }
220
+ });
221
+ }
222
+
223
+ if (data.oauth != null) {
224
+ if (typeof data.oauth !== "object" || Array.isArray(data.oauth)) {
225
+ throw new Error("otavia.yaml: oauth must be an object");
226
+ }
227
+ const oauth = data.oauth as Record<string, unknown>;
228
+ if (oauth.callback != null) {
229
+ if (typeof oauth.callback !== "object" || Array.isArray(oauth.callback)) {
230
+ throw new Error("otavia.yaml: oauth.callback must be an object");
231
+ }
232
+ const callback = oauth.callback as Record<string, unknown>;
233
+ if (typeof callback.cell !== "string" || callback.cell.trim() === "") {
234
+ throw new Error("otavia.yaml: oauth.callback.cell must be a non-empty string");
235
+ }
236
+ if (typeof callback.path !== "string" || callback.path.trim() === "") {
237
+ throw new Error("otavia.yaml: oauth.callback.path must be a non-empty string");
238
+ }
239
+ const cell = callback.cell.trim();
240
+ const callbackPath = callback.path.trim();
241
+ if (!callbackPath.startsWith("/")) {
242
+ throw new Error("otavia.yaml: oauth.callback.path must start with '/'");
243
+ }
244
+ const mountSet = new Set(result.cellsList.map((entry) => entry.mount));
245
+ if (!mountSet.has(cell)) {
246
+ throw new Error(
247
+ `otavia.yaml: oauth.callback.cell "${cell}" must match one of configured cells`
248
+ );
249
+ }
250
+ result.oauth = { callback: { cell, path: callbackPath } };
251
+ } else {
252
+ result.oauth = {};
253
+ }
254
+ }
255
+ return result;
256
+ }