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.
- package/bun.lock +589 -0
- package/package.json +35 -0
- package/src/cli.ts +153 -0
- package/src/commands/__tests__/aws-auth.test.ts +32 -0
- package/src/commands/__tests__/cell.test.ts +44 -0
- package/src/commands/__tests__/dev.test.ts +49 -0
- package/src/commands/__tests__/init.test.ts +47 -0
- package/src/commands/__tests__/setup.test.ts +263 -0
- package/src/commands/aws-auth.ts +32 -0
- package/src/commands/aws.ts +59 -0
- package/src/commands/cell.ts +33 -0
- package/src/commands/clean.ts +32 -0
- package/src/commands/deploy.ts +508 -0
- package/src/commands/dev/__tests__/fixtures/gateway-cell/cell.yaml +8 -0
- package/src/commands/dev/__tests__/gateway-backend-routes.test.ts +13 -0
- package/src/commands/dev/__tests__/gateway-forward-url.test.ts +20 -0
- package/src/commands/dev/__tests__/gateway-sso-base-url.test.ts +93 -0
- package/src/commands/dev/__tests__/tunnel.test.ts +93 -0
- package/src/commands/dev/__tests__/vite-dev-proxy-rules.test.ts +220 -0
- package/src/commands/dev/__tests__/well-known.test.ts +88 -0
- package/src/commands/dev/forward-url.ts +7 -0
- package/src/commands/dev/gateway.ts +421 -0
- package/src/commands/dev/main-frontend-runtime/main-entry.ts +35 -0
- package/src/commands/dev/main-frontend-runtime/vite-config.ts +210 -0
- package/src/commands/dev/mount-selection.ts +9 -0
- package/src/commands/dev/tunnel.ts +176 -0
- package/src/commands/dev/vite-dev.ts +382 -0
- package/src/commands/dev/well-known.ts +76 -0
- package/src/commands/dev.ts +107 -0
- package/src/commands/init.ts +69 -0
- package/src/commands/lint.ts +49 -0
- package/src/commands/setup.ts +887 -0
- package/src/commands/test.ts +331 -0
- package/src/commands/typecheck.ts +36 -0
- package/src/config/__tests__/load-cell-yaml.test.ts +248 -0
- package/src/config/__tests__/load-otavia-yaml.test.ts +492 -0
- package/src/config/__tests__/ports.test.ts +48 -0
- package/src/config/__tests__/resolve-cell-dir.test.ts +60 -0
- package/src/config/__tests__/resolve-params.test.ts +137 -0
- package/src/config/__tests__/resource-names.test.ts +62 -0
- package/src/config/cell-yaml-schema.ts +115 -0
- package/src/config/load-cell-yaml.ts +87 -0
- package/src/config/load-otavia-yaml.ts +256 -0
- package/src/config/otavia-yaml-schema.ts +49 -0
- package/src/config/ports.ts +57 -0
- package/src/config/resolve-cell-dir.ts +55 -0
- package/src/config/resolve-params.ts +160 -0
- package/src/config/resource-names.ts +60 -0
- package/src/deploy/__tests__/template.test.ts +137 -0
- package/src/deploy/api-gateway.ts +96 -0
- package/src/deploy/cloudflare-dns.ts +261 -0
- package/src/deploy/cloudfront.ts +228 -0
- package/src/deploy/dynamodb.ts +68 -0
- package/src/deploy/lambda.ts +121 -0
- package/src/deploy/s3.ts +57 -0
- package/src/deploy/template.ts +264 -0
- package/src/deploy/types.ts +16 -0
- package/src/local/docker.ts +175 -0
- package/src/local/dynamodb-local.ts +124 -0
- package/src/local/minio-local.ts +44 -0
- package/src/utils/env.test.ts +74 -0
- package/src/utils/env.ts +79 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
4
|
+
import { loadOtaviaYaml } from "../config/load-otavia-yaml.js";
|
|
5
|
+
import { loadCellConfig } from "../config/load-cell-yaml.js";
|
|
6
|
+
import { resolveCellDir } from "../config/resolve-cell-dir.js";
|
|
7
|
+
import { assertDeclaredParamsProvided, mergeParams, resolveParams } from "../config/resolve-params.js";
|
|
8
|
+
import { tablePhysicalName, bucketPhysicalName } from "../config/resource-names.js";
|
|
9
|
+
import { loadEnvForCell } from "../utils/env.js";
|
|
10
|
+
import {
|
|
11
|
+
isDockerRunning,
|
|
12
|
+
startDynamoDB,
|
|
13
|
+
startMinIO,
|
|
14
|
+
waitForPort,
|
|
15
|
+
stopContainer,
|
|
16
|
+
} from "../local/docker.js";
|
|
17
|
+
import {
|
|
18
|
+
isDynamoDBReady,
|
|
19
|
+
ensureLocalTables,
|
|
20
|
+
type LocalTableEntry,
|
|
21
|
+
} from "../local/dynamodb-local.js";
|
|
22
|
+
import { ensureLocalBuckets } from "../local/minio-local.js";
|
|
23
|
+
import { resolvePortsFromEnv } from "../config/ports.js";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_UNIT_PATTERN = "**/__tests__/*.test.ts";
|
|
26
|
+
const CELL_YAML = "cell.yaml";
|
|
27
|
+
const E2E_DYNAMODB_CONTAINER = "otavia-dynamodb-e2e";
|
|
28
|
+
const E2E_MINIO_CONTAINER = "otavia-minio-e2e";
|
|
29
|
+
|
|
30
|
+
interface CellYamlTesting {
|
|
31
|
+
unit?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface CellYaml {
|
|
35
|
+
testing?: CellYamlTesting;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadCellYaml(rootDir: string, packageName: string): CellYaml | null {
|
|
39
|
+
const cellDir = resolveCellDir(rootDir, packageName);
|
|
40
|
+
const cellPath = path.join(cellDir, CELL_YAML);
|
|
41
|
+
if (!fs.existsSync(cellPath)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const raw = fs.readFileSync(cellPath, "utf-8");
|
|
45
|
+
const doc = parseDocument(raw);
|
|
46
|
+
const data = doc.toJSON() as Record<string, unknown> | null | undefined;
|
|
47
|
+
if (data == null || typeof data !== "object") {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
const testing = data.testing;
|
|
51
|
+
if (testing == null || typeof testing !== "object" || Array.isArray(testing)) {
|
|
52
|
+
return { testing: undefined };
|
|
53
|
+
}
|
|
54
|
+
const unit = (testing as Record<string, unknown>).unit;
|
|
55
|
+
return {
|
|
56
|
+
testing: {
|
|
57
|
+
unit: typeof unit === "string" ? unit : undefined,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve glob patterns to detect if any test files exist under the given pattern.
|
|
64
|
+
* - If pattern ends with "/", treat as directory and look for .test.ts and .spec.ts under it.
|
|
65
|
+
* - Otherwise use pattern as-is (e.g. __tests__/*.test.ts).
|
|
66
|
+
*/
|
|
67
|
+
function getGlobPatterns(pattern: string): string[] {
|
|
68
|
+
const p = pattern.trim();
|
|
69
|
+
if (p.endsWith("/")) {
|
|
70
|
+
return [p + "**/*.test.ts", p + "**/*.spec.ts"];
|
|
71
|
+
}
|
|
72
|
+
return [p];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function hasTestFiles(cellDir: string, pattern: string): Promise<boolean> {
|
|
76
|
+
const patterns = getGlobPatterns(pattern);
|
|
77
|
+
const { Glob } = await import("bun");
|
|
78
|
+
for (const p of patterns) {
|
|
79
|
+
const glob = new Glob(p);
|
|
80
|
+
for await (const _ of glob.scan({ cwd: cellDir, onlyFiles: true })) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run unit tests for all cells: load otavia.yaml, for each cell load cell.yaml,
|
|
89
|
+
* get testing.unit pattern (default: __tests__/*.test.ts), run bun test in each cellDir.
|
|
90
|
+
* If no test files found for a cell, skip and log. Aggregate exit codes; if any cell fails, exit(1).
|
|
91
|
+
*/
|
|
92
|
+
export async function testUnitCommand(rootDir: string): Promise<void> {
|
|
93
|
+
const root = path.resolve(rootDir);
|
|
94
|
+
const otavia = loadOtaviaYaml(root);
|
|
95
|
+
const failedCells: string[] = [];
|
|
96
|
+
|
|
97
|
+
for (const entry of otavia.cellsList) {
|
|
98
|
+
const cellDir = resolveCellDir(root, entry.package);
|
|
99
|
+
if (!fs.existsSync(path.join(cellDir, CELL_YAML))) {
|
|
100
|
+
console.warn(`Skipping ${entry.mount}: cell not found`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const cellConfig = loadCellYaml(root, entry.package);
|
|
105
|
+
const pattern =
|
|
106
|
+
cellConfig?.testing?.unit ?? DEFAULT_UNIT_PATTERN;
|
|
107
|
+
|
|
108
|
+
const hasTests = await hasTestFiles(cellDir, pattern);
|
|
109
|
+
if (!hasTests) {
|
|
110
|
+
console.log(`Skipping ${entry.mount}: no unit tests`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const proc = Bun.spawn(["bun", "test", pattern], {
|
|
115
|
+
cwd: cellDir,
|
|
116
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
117
|
+
});
|
|
118
|
+
const exitCode = await proc.exited;
|
|
119
|
+
if (exitCode !== 0) {
|
|
120
|
+
failedCells.push(entry.mount);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (failedCells.length > 0) {
|
|
125
|
+
console.error(`Unit tests failed for: ${failedCells.join(", ")}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* E2E test command: start non-persistent Docker (DynamoDB Local + MinIO with --rm),
|
|
132
|
+
* start gateway in a subprocess with DYNAMODB_ENDPOINT and S3_ENDPOINT injected;
|
|
133
|
+
* for each cell that has testing.e2e run bun test <e2ePattern> in cellDir with
|
|
134
|
+
* env CELL_BASE_URL etc.; then stop gateway and containers.
|
|
135
|
+
*/
|
|
136
|
+
export async function testE2eCommand(rootDir: string): Promise<void> {
|
|
137
|
+
const root = path.resolve(rootDir);
|
|
138
|
+
const otavia = loadOtaviaYaml(root);
|
|
139
|
+
const stageEnv = loadEnvForCell(root, root, { stage: "test" });
|
|
140
|
+
const ports = resolvePortsFromEnv("test", { ...stageEnv, ...process.env });
|
|
141
|
+
const firstMount = otavia.cellsList[0]?.mount ?? "sso";
|
|
142
|
+
|
|
143
|
+
type CellE2E = {
|
|
144
|
+
mount: string;
|
|
145
|
+
cellDir: string;
|
|
146
|
+
config: ReturnType<typeof loadCellConfig>;
|
|
147
|
+
e2ePattern: string;
|
|
148
|
+
params?: Record<string, unknown>;
|
|
149
|
+
};
|
|
150
|
+
const e2eCells: CellE2E[] = [];
|
|
151
|
+
let hasTables = false;
|
|
152
|
+
let hasBuckets = false;
|
|
153
|
+
|
|
154
|
+
for (const entry of otavia.cellsList) {
|
|
155
|
+
const cellDir = resolveCellDir(root, entry.package);
|
|
156
|
+
if (!fs.existsSync(path.join(cellDir, "cell.yaml"))) continue;
|
|
157
|
+
let config: ReturnType<typeof loadCellConfig>;
|
|
158
|
+
try {
|
|
159
|
+
config = loadCellConfig(cellDir);
|
|
160
|
+
} catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (config.tables && Object.keys(config.tables).length > 0) hasTables = true;
|
|
164
|
+
if (config.buckets && Object.keys(config.buckets).length > 0) hasBuckets = true;
|
|
165
|
+
const e2ePattern = config.testing?.e2e;
|
|
166
|
+
if (e2ePattern && typeof e2ePattern === "string") {
|
|
167
|
+
e2eCells.push({ mount: entry.mount, cellDir, config, e2ePattern, params: entry.params });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (e2eCells.length === 0) {
|
|
172
|
+
console.log("No e2e tests configured");
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let gatewayProc: ReturnType<typeof Bun.spawn> | null = null;
|
|
177
|
+
const needDocker = hasTables || hasBuckets;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
if (needDocker && !(await isDockerRunning())) {
|
|
181
|
+
throw new Error("Docker is not running. Start Docker to run e2e tests.");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (hasTables) {
|
|
185
|
+
await startDynamoDB({
|
|
186
|
+
port: ports.dynamodb,
|
|
187
|
+
persistent: false,
|
|
188
|
+
containerName: E2E_DYNAMODB_CONTAINER,
|
|
189
|
+
});
|
|
190
|
+
const ready = await waitForPort(ports.dynamodb);
|
|
191
|
+
if (!ready) throw new Error("DynamoDB Local did not become ready in time");
|
|
192
|
+
const dynamoEndpoint = `http://localhost:${ports.dynamodb}`;
|
|
193
|
+
if (!(await isDynamoDBReady(dynamoEndpoint))) {
|
|
194
|
+
throw new Error("DynamoDB endpoint not accepting requests");
|
|
195
|
+
}
|
|
196
|
+
const tablesList: LocalTableEntry[] = [];
|
|
197
|
+
for (const entry of otavia.cellsList) {
|
|
198
|
+
const cellDir = resolveCellDir(root, entry.package);
|
|
199
|
+
if (!fs.existsSync(path.join(cellDir, "cell.yaml"))) continue;
|
|
200
|
+
let config: ReturnType<typeof loadCellConfig>;
|
|
201
|
+
try {
|
|
202
|
+
config = loadCellConfig(cellDir);
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (!config.tables) continue;
|
|
207
|
+
for (const [key, tableConfig] of Object.entries(config.tables)) {
|
|
208
|
+
tablesList.push({
|
|
209
|
+
tableName: tablePhysicalName(otavia.stackName, entry.mount, key),
|
|
210
|
+
config: tableConfig,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
await ensureLocalTables(dynamoEndpoint, tablesList);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (hasBuckets) {
|
|
218
|
+
await startMinIO({
|
|
219
|
+
port: ports.minio,
|
|
220
|
+
containerName: E2E_MINIO_CONTAINER,
|
|
221
|
+
rm: true,
|
|
222
|
+
});
|
|
223
|
+
const ready = await waitForPort(ports.minio);
|
|
224
|
+
if (!ready) throw new Error("MinIO did not become ready in time");
|
|
225
|
+
const s3Endpoint = `http://localhost:${ports.minio}`;
|
|
226
|
+
const bucketNames: string[] = [];
|
|
227
|
+
for (const entry of otavia.cellsList) {
|
|
228
|
+
const cellDir = resolveCellDir(root, entry.package);
|
|
229
|
+
if (!fs.existsSync(path.join(cellDir, "cell.yaml"))) continue;
|
|
230
|
+
let config: ReturnType<typeof loadCellConfig>;
|
|
231
|
+
try {
|
|
232
|
+
config = loadCellConfig(cellDir);
|
|
233
|
+
} catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!config.buckets) continue;
|
|
237
|
+
for (const key of Object.keys(config.buckets)) {
|
|
238
|
+
bucketNames.push(bucketPhysicalName(otavia.stackName, entry.mount, key));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
await ensureLocalBuckets(s3Endpoint, bucketNames);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const gatewayEnv: Record<string, string> = {
|
|
245
|
+
...process.env,
|
|
246
|
+
OTAVIA_DEV_GATEWAY_ONLY: "1",
|
|
247
|
+
PORT: String(ports.backend),
|
|
248
|
+
};
|
|
249
|
+
if (hasTables) gatewayEnv.DYNAMODB_ENDPOINT = `http://localhost:${ports.dynamodb}`;
|
|
250
|
+
if (hasBuckets) gatewayEnv.S3_ENDPOINT = `http://localhost:${ports.minio}`;
|
|
251
|
+
|
|
252
|
+
const cliPath = fs.existsSync(path.join(root, "apps", "otavia", "src", "cli.ts"))
|
|
253
|
+
? path.join(root, "apps", "otavia", "src", "cli.ts")
|
|
254
|
+
: path.join(root, "..", "otavia", "src", "cli.ts");
|
|
255
|
+
gatewayProc = Bun.spawn(["bun", "run", cliPath, "dev"], {
|
|
256
|
+
cwd: root,
|
|
257
|
+
env: gatewayEnv,
|
|
258
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const gatewayReady = await waitForPort(ports.backend);
|
|
262
|
+
if (!gatewayReady) {
|
|
263
|
+
throw new Error("Gateway did not become ready in time");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function resolvedParamsToEnv(resolved: Record<string, string | unknown>): Record<string, string> {
|
|
267
|
+
const env: Record<string, string> = {};
|
|
268
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
269
|
+
if (value === null || value === undefined) {
|
|
270
|
+
env[key] = "";
|
|
271
|
+
} else if (typeof value === "object") {
|
|
272
|
+
env[key] = JSON.stringify(value);
|
|
273
|
+
} else {
|
|
274
|
+
env[key] = String(value);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return env;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const failedCells: string[] = [];
|
|
281
|
+
for (const { mount, cellDir, config, e2ePattern, params } of e2eCells) {
|
|
282
|
+
const merged = mergeParams(otavia.params, params);
|
|
283
|
+
assertDeclaredParamsProvided(config.params, merged, mount);
|
|
284
|
+
const envMap = loadEnvForCell(root, cellDir, { stage: "test" });
|
|
285
|
+
if (!envMap.SSO_BASE_URL?.trim()) {
|
|
286
|
+
envMap.SSO_BASE_URL = `http://localhost:${ports.backend}/${firstMount}`;
|
|
287
|
+
}
|
|
288
|
+
const resolved = resolveParams(merged as Record<string, unknown>, envMap, {
|
|
289
|
+
onMissingParam: "placeholder",
|
|
290
|
+
});
|
|
291
|
+
const resolvedEnv = resolvedParamsToEnv(resolved as Record<string, string | unknown>);
|
|
292
|
+
const cellEnv: Record<string, string> = {
|
|
293
|
+
...resolvedEnv,
|
|
294
|
+
CELL_BASE_URL: `http://localhost:${ports.backend}/${mount}`,
|
|
295
|
+
PORT: String(ports.backend),
|
|
296
|
+
CELL_STAGE: "test",
|
|
297
|
+
};
|
|
298
|
+
if (hasTables) cellEnv.DYNAMODB_ENDPOINT = `http://localhost:${ports.dynamodb}`;
|
|
299
|
+
if (hasBuckets) cellEnv.S3_ENDPOINT = `http://localhost:${ports.minio}`;
|
|
300
|
+
|
|
301
|
+
const proc = Bun.spawn(["bun", "test", e2ePattern], {
|
|
302
|
+
cwd: cellDir,
|
|
303
|
+
env: cellEnv,
|
|
304
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
305
|
+
});
|
|
306
|
+
const exitCode = await proc.exited;
|
|
307
|
+
if (exitCode !== 0) failedCells.push(mount);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (failedCells.length > 0) {
|
|
311
|
+
console.error(`E2E tests failed for: ${failedCells.join(", ")}`);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
} finally {
|
|
315
|
+
if (gatewayProc) {
|
|
316
|
+
gatewayProc.kill();
|
|
317
|
+
}
|
|
318
|
+
if (needDocker) {
|
|
319
|
+
try {
|
|
320
|
+
await stopContainer(E2E_DYNAMODB_CONTAINER);
|
|
321
|
+
} catch {
|
|
322
|
+
// container may already be removed (--rm)
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
await stopContainer(E2E_MINIO_CONTAINER);
|
|
326
|
+
} catch {
|
|
327
|
+
// container may already be removed (--rm)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Run tsc --noEmit in each resolved cellDir. Aggregate exit codes; if any cell
|
|
8
|
+
* fails, exit(1). Cells without cell.yaml are skipped with a warning.
|
|
9
|
+
* Uses bun x tsc so each cell's node_modules/.bin/tsc is used when present.
|
|
10
|
+
*/
|
|
11
|
+
export async function typecheckCommand(rootDir: string): Promise<void> {
|
|
12
|
+
const root = path.resolve(rootDir);
|
|
13
|
+
const otavia = loadOtaviaYaml(root);
|
|
14
|
+
let failed = false;
|
|
15
|
+
|
|
16
|
+
for (const entry of otavia.cellsList) {
|
|
17
|
+
const cellDir = resolveCellDir(root, entry.package);
|
|
18
|
+
if (!fs.existsSync(path.join(cellDir, "cell.yaml"))) {
|
|
19
|
+
console.warn(`Skipping ${entry.mount}: cell not found`);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const proc = Bun.spawn(["bun", "x", "tsc", "--noEmit"], {
|
|
24
|
+
cwd: cellDir,
|
|
25
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
26
|
+
});
|
|
27
|
+
const exitCode = await proc.exited;
|
|
28
|
+
if (exitCode !== 0) {
|
|
29
|
+
failed = true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (failed) {
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { loadCellConfig } from "../load-cell-yaml.js";
|
|
6
|
+
|
|
7
|
+
function writeCellYaml(dir: string, content: string) {
|
|
8
|
+
const filePath = path.join(dir, "cell.yaml");
|
|
9
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
10
|
+
return filePath;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("loadCellConfig", () => {
|
|
14
|
+
test("returns correct structure for minimal valid cell.yaml", () => {
|
|
15
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
16
|
+
try {
|
|
17
|
+
writeCellYaml(
|
|
18
|
+
tmp,
|
|
19
|
+
`
|
|
20
|
+
name: my-cell
|
|
21
|
+
`
|
|
22
|
+
);
|
|
23
|
+
const result = loadCellConfig(tmp);
|
|
24
|
+
expect(result.name).toBe("my-cell");
|
|
25
|
+
expect(result.backend).toBeUndefined();
|
|
26
|
+
expect(result.frontend).toBeUndefined();
|
|
27
|
+
expect(result.params).toBeUndefined();
|
|
28
|
+
} finally {
|
|
29
|
+
fs.rmSync(tmp, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("returns backend and declared params when present", () => {
|
|
34
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
35
|
+
try {
|
|
36
|
+
writeCellYaml(
|
|
37
|
+
tmp,
|
|
38
|
+
`
|
|
39
|
+
name: app-cell
|
|
40
|
+
backend:
|
|
41
|
+
runtime: bun
|
|
42
|
+
entries:
|
|
43
|
+
api:
|
|
44
|
+
handler: backend/handler.ts
|
|
45
|
+
timeout: 30
|
|
46
|
+
memory: 256
|
|
47
|
+
routes:
|
|
48
|
+
- /api/*
|
|
49
|
+
params:
|
|
50
|
+
- DOMAIN_ROOT
|
|
51
|
+
- SSO_BASE_URL
|
|
52
|
+
`
|
|
53
|
+
);
|
|
54
|
+
const result = loadCellConfig(tmp);
|
|
55
|
+
expect(result.name).toBe("app-cell");
|
|
56
|
+
expect(result.backend?.runtime).toBe("bun");
|
|
57
|
+
expect(result.backend?.entries?.api?.handler).toBe("backend/handler.ts");
|
|
58
|
+
expect(result.backend?.entries?.api?.routes).toEqual(["/api/*"]);
|
|
59
|
+
expect(result.params).toEqual(["DOMAIN_ROOT", "SSO_BASE_URL"]);
|
|
60
|
+
} finally {
|
|
61
|
+
fs.rmSync(tmp, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("throws when name is missing", () => {
|
|
66
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
67
|
+
try {
|
|
68
|
+
writeCellYaml(
|
|
69
|
+
tmp,
|
|
70
|
+
`
|
|
71
|
+
backend:
|
|
72
|
+
runtime: bun
|
|
73
|
+
entries: {}
|
|
74
|
+
`
|
|
75
|
+
);
|
|
76
|
+
expect(() => loadCellConfig(tmp)).toThrow("cell.yaml: missing required field 'name'");
|
|
77
|
+
} finally {
|
|
78
|
+
fs.rmSync(tmp, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("throws when name is empty string", () => {
|
|
83
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
84
|
+
try {
|
|
85
|
+
writeCellYaml(
|
|
86
|
+
tmp,
|
|
87
|
+
`
|
|
88
|
+
name: ""
|
|
89
|
+
`
|
|
90
|
+
);
|
|
91
|
+
expect(() => loadCellConfig(tmp)).toThrow("cell.yaml: 'name' must be a non-empty string");
|
|
92
|
+
} finally {
|
|
93
|
+
fs.rmSync(tmp, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("throws when params is not string array", () => {
|
|
98
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
99
|
+
try {
|
|
100
|
+
writeCellYaml(
|
|
101
|
+
tmp,
|
|
102
|
+
`
|
|
103
|
+
name: secret-cell
|
|
104
|
+
params:
|
|
105
|
+
API_KEY: value
|
|
106
|
+
`
|
|
107
|
+
);
|
|
108
|
+
expect(() => loadCellConfig(tmp)).toThrow("cell.yaml: 'params' must be an array of strings");
|
|
109
|
+
} finally {
|
|
110
|
+
fs.rmSync(tmp, { recursive: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("throws when !Env or !Secret appears in cell.yaml", () => {
|
|
115
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
116
|
+
try {
|
|
117
|
+
writeCellYaml(
|
|
118
|
+
tmp,
|
|
119
|
+
`
|
|
120
|
+
name: secret-cell
|
|
121
|
+
params:
|
|
122
|
+
- API_KEY
|
|
123
|
+
runtimeParam: !Secret BFL_API_KEY
|
|
124
|
+
`
|
|
125
|
+
);
|
|
126
|
+
expect(() => loadCellConfig(tmp)).toThrow(
|
|
127
|
+
"cell.yaml: !Env and !Secret are not supported; move refs to otavia.yaml params"
|
|
128
|
+
);
|
|
129
|
+
} finally {
|
|
130
|
+
fs.rmSync(tmp, { recursive: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("accepts minimal oauth config", () => {
|
|
135
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
136
|
+
try {
|
|
137
|
+
writeCellYaml(
|
|
138
|
+
tmp,
|
|
139
|
+
`
|
|
140
|
+
name: oauth-cell
|
|
141
|
+
oauth:
|
|
142
|
+
enabled: true
|
|
143
|
+
role: resource_server
|
|
144
|
+
scopes:
|
|
145
|
+
- use_mcp
|
|
146
|
+
`
|
|
147
|
+
);
|
|
148
|
+
const result = loadCellConfig(tmp);
|
|
149
|
+
expect(result.oauth).toEqual({
|
|
150
|
+
enabled: true,
|
|
151
|
+
role: "resource_server",
|
|
152
|
+
scopes: ["use_mcp"],
|
|
153
|
+
});
|
|
154
|
+
} finally {
|
|
155
|
+
fs.rmSync(tmp, { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("throws when oauth enabled but scopes is empty", () => {
|
|
160
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
161
|
+
try {
|
|
162
|
+
writeCellYaml(
|
|
163
|
+
tmp,
|
|
164
|
+
`
|
|
165
|
+
name: oauth-cell
|
|
166
|
+
oauth:
|
|
167
|
+
enabled: true
|
|
168
|
+
role: both
|
|
169
|
+
scopes: []
|
|
170
|
+
`
|
|
171
|
+
);
|
|
172
|
+
expect(() => loadCellConfig(tmp)).toThrow(
|
|
173
|
+
"cell.yaml: 'oauth.scopes' must be a non-empty array of strings when oauth.enabled is true"
|
|
174
|
+
);
|
|
175
|
+
} finally {
|
|
176
|
+
fs.rmSync(tmp, { recursive: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("throws when oauth uses unsupported v1 fields", () => {
|
|
181
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
182
|
+
try {
|
|
183
|
+
writeCellYaml(
|
|
184
|
+
tmp,
|
|
185
|
+
`
|
|
186
|
+
name: oauth-cell
|
|
187
|
+
oauth:
|
|
188
|
+
enabled: true
|
|
189
|
+
role: authorization_server
|
|
190
|
+
scopes:
|
|
191
|
+
- use_mcp
|
|
192
|
+
issuerPath: /agent
|
|
193
|
+
`
|
|
194
|
+
);
|
|
195
|
+
expect(() => loadCellConfig(tmp)).toThrow(
|
|
196
|
+
"cell.yaml: 'oauth.issuerPath' is not supported in v1; issuer path is derived from mount"
|
|
197
|
+
);
|
|
198
|
+
} finally {
|
|
199
|
+
fs.rmSync(tmp, { recursive: true });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("throws when oauth role is invalid", () => {
|
|
204
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
205
|
+
try {
|
|
206
|
+
writeCellYaml(
|
|
207
|
+
tmp,
|
|
208
|
+
`
|
|
209
|
+
name: oauth-cell
|
|
210
|
+
oauth:
|
|
211
|
+
enabled: true
|
|
212
|
+
role: invalid_role
|
|
213
|
+
scopes:
|
|
214
|
+
- use_mcp
|
|
215
|
+
`
|
|
216
|
+
);
|
|
217
|
+
expect(() => loadCellConfig(tmp)).toThrow(
|
|
218
|
+
"cell.yaml: 'oauth.role' must be one of: resource_server, authorization_server, both"
|
|
219
|
+
);
|
|
220
|
+
} finally {
|
|
221
|
+
fs.rmSync(tmp, { recursive: true });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("throws when oauth.discovery is present in v1", () => {
|
|
226
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-cell-"));
|
|
227
|
+
try {
|
|
228
|
+
writeCellYaml(
|
|
229
|
+
tmp,
|
|
230
|
+
`
|
|
231
|
+
name: oauth-cell
|
|
232
|
+
oauth:
|
|
233
|
+
enabled: true
|
|
234
|
+
role: authorization_server
|
|
235
|
+
scopes:
|
|
236
|
+
- use_mcp
|
|
237
|
+
discovery:
|
|
238
|
+
enabled: false
|
|
239
|
+
`
|
|
240
|
+
);
|
|
241
|
+
expect(() => loadCellConfig(tmp)).toThrow(
|
|
242
|
+
"cell.yaml: 'oauth.discovery' is not supported in v1; discovery is automatic for oauth-enabled cells"
|
|
243
|
+
);
|
|
244
|
+
} finally {
|
|
245
|
+
fs.rmSync(tmp, { recursive: true });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|