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,492 @@
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 { loadOtaviaYaml } from "../load-otavia-yaml.js";
6
+ import { isEnvRef, isParamRef, isSecretRef } from "../cell-yaml-schema.js";
7
+
8
+ function writeYaml(dir: string, content: string) {
9
+ const filePath = path.join(dir, "otavia.yaml");
10
+ fs.writeFileSync(filePath, content, "utf-8");
11
+ return filePath;
12
+ }
13
+
14
+ describe("loadOtaviaYaml", () => {
15
+ test("returns parsed object when valid otavia.yaml exists", () => {
16
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
17
+ try {
18
+ writeYaml(
19
+ tmp,
20
+ `
21
+ stackName: my-stack
22
+ cells:
23
+ - cell-a
24
+ - cell-b
25
+ domain:
26
+ host: example.com
27
+ dns:
28
+ provider: route53
29
+ zone: example.com
30
+ zoneId: Z123
31
+ params:
32
+ foo: bar
33
+ `
34
+ );
35
+ const result = loadOtaviaYaml(tmp);
36
+ expect(result.stackName).toBe("my-stack");
37
+ expect(result.cells).toEqual({ "cell-a": "@otavia/cell-a", "cell-b": "@otavia/cell-b" });
38
+ expect(result.cellsList).toEqual([
39
+ { mount: "cell-a", package: "@otavia/cell-a" },
40
+ { mount: "cell-b", package: "@otavia/cell-b" },
41
+ ]);
42
+ expect(result.domain.host).toBe("example.com");
43
+ expect(result.domain.dns?.provider).toBe("route53");
44
+ expect(result.domain.dns?.zone).toBe("example.com");
45
+ expect(result.domain.dns?.zoneId).toBe("Z123");
46
+ expect(result.params).toEqual({ foo: "bar" });
47
+ } finally {
48
+ fs.rmSync(tmp, { recursive: true });
49
+ }
50
+ });
51
+
52
+ test("parses defaultCell when configured", () => {
53
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
54
+ try {
55
+ writeYaml(
56
+ tmp,
57
+ `
58
+ stackName: my-stack
59
+ defaultCell: drive
60
+ cells:
61
+ sso: "@otavia/sso"
62
+ drive: "@otavia/drive"
63
+ domain:
64
+ host: example.com
65
+ `
66
+ );
67
+ const result = loadOtaviaYaml(tmp);
68
+ expect(result.defaultCell).toBe("drive");
69
+ } finally {
70
+ fs.rmSync(tmp, { recursive: true });
71
+ }
72
+ });
73
+
74
+ test("parses cells as object (mount -> package)", () => {
75
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
76
+ try {
77
+ writeYaml(
78
+ tmp,
79
+ `
80
+ stackName: my-stack
81
+ cells:
82
+ sso: "@otavia/sso"
83
+ drive: "@otavia/drive"
84
+ domain:
85
+ host: example.com
86
+ `
87
+ );
88
+ const result = loadOtaviaYaml(tmp);
89
+ expect(result.cells).toEqual({ sso: "@otavia/sso", drive: "@otavia/drive" });
90
+ expect(result.cellsList).toEqual([
91
+ { mount: "sso", package: "@otavia/sso" },
92
+ { mount: "drive", package: "@otavia/drive" },
93
+ ]);
94
+ } finally {
95
+ fs.rmSync(tmp, { recursive: true });
96
+ }
97
+ });
98
+
99
+ test("parses cells object values as { package, params }", () => {
100
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
101
+ try {
102
+ writeYaml(
103
+ tmp,
104
+ `
105
+ stackName: my-stack
106
+ cells:
107
+ sso:
108
+ package: "@otavia/sso"
109
+ params:
110
+ issuer: "https://issuer.example.com"
111
+ drive:
112
+ package: "@otavia/drive"
113
+ domain:
114
+ host: example.com
115
+ `
116
+ );
117
+ const result = loadOtaviaYaml(tmp);
118
+ expect(result.cells).toEqual({ sso: "@otavia/sso", drive: "@otavia/drive" });
119
+ expect(result.cellsList).toEqual([
120
+ {
121
+ mount: "sso",
122
+ package: "@otavia/sso",
123
+ params: { issuer: "https://issuer.example.com" },
124
+ },
125
+ {
126
+ mount: "drive",
127
+ package: "@otavia/drive",
128
+ params: undefined,
129
+ },
130
+ ]);
131
+ } finally {
132
+ fs.rmSync(tmp, { recursive: true });
133
+ }
134
+ });
135
+
136
+ test("parses canonical cells list with package/mount/params", () => {
137
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
138
+ try {
139
+ writeYaml(
140
+ tmp,
141
+ `
142
+ stackName: my-stack
143
+ cells:
144
+ - package: "@otavia/sso"
145
+ mount: "auth"
146
+ params:
147
+ issuer: "https://issuer.example.com"
148
+ - package: "@otavia/drive"
149
+ domain:
150
+ host: example.com
151
+ `
152
+ );
153
+ const result = loadOtaviaYaml(tmp);
154
+ expect(result.cells).toEqual({ auth: "@otavia/sso", drive: "@otavia/drive" });
155
+ expect(result.cellsList).toEqual([
156
+ {
157
+ mount: "auth",
158
+ package: "@otavia/sso",
159
+ params: { issuer: "https://issuer.example.com" },
160
+ },
161
+ {
162
+ mount: "drive",
163
+ package: "@otavia/drive",
164
+ params: undefined,
165
+ },
166
+ ]);
167
+ } finally {
168
+ fs.rmSync(tmp, { recursive: true });
169
+ }
170
+ });
171
+
172
+ test("parses !Env and !Secret in otavia params", () => {
173
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
174
+ try {
175
+ writeYaml(
176
+ tmp,
177
+ `
178
+ stackName: my-stack
179
+ cells:
180
+ sso: "@otavia/sso"
181
+ domain:
182
+ host: example.com
183
+ params:
184
+ SSO_BASE_URL: !Env SSO_BASE_URL
185
+ BFL_API_KEY: !Secret BFL_API_KEY
186
+ `
187
+ );
188
+ const result = loadOtaviaYaml(tmp);
189
+ const ssoBaseUrl = result.params?.SSO_BASE_URL;
190
+ const bflApiKey = result.params?.BFL_API_KEY;
191
+ expect(isEnvRef(ssoBaseUrl)).toBe(true);
192
+ expect(isSecretRef(bflApiKey)).toBe(true);
193
+ if (isEnvRef(ssoBaseUrl)) {
194
+ expect(ssoBaseUrl.env).toBe("SSO_BASE_URL");
195
+ }
196
+ if (isSecretRef(bflApiKey)) {
197
+ expect(bflApiKey.secret).toBe("BFL_API_KEY");
198
+ }
199
+ } finally {
200
+ fs.rmSync(tmp, { recursive: true });
201
+ }
202
+ });
203
+
204
+ test("parses !Param in cell-level params", () => {
205
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
206
+ try {
207
+ writeYaml(
208
+ tmp,
209
+ `
210
+ stackName: my-stack
211
+ cells:
212
+ - package: "@otavia/artist"
213
+ mount: "artist"
214
+ params:
215
+ BFL_API_KEY: !Param BFL_API_KEY
216
+ domain:
217
+ host: example.com
218
+ params:
219
+ BFL_API_KEY: !Secret BFL_API_KEY
220
+ `
221
+ );
222
+ const result = loadOtaviaYaml(tmp);
223
+ const bflApiKey = result.cellsList[0]?.params?.BFL_API_KEY;
224
+ expect(isParamRef(bflApiKey)).toBe(true);
225
+ if (isParamRef(bflApiKey)) {
226
+ expect(bflApiKey.param).toBe("BFL_API_KEY");
227
+ }
228
+ } finally {
229
+ fs.rmSync(tmp, { recursive: true });
230
+ }
231
+ });
232
+
233
+ test("throws when top-level params uses !Param", () => {
234
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
235
+ try {
236
+ writeYaml(
237
+ tmp,
238
+ `
239
+ stackName: my-stack
240
+ cells:
241
+ sso: "@otavia/sso"
242
+ domain:
243
+ host: example.com
244
+ params:
245
+ SSO_BASE_URL: !Param OTHER_KEY
246
+ `
247
+ );
248
+ expect(() => loadOtaviaYaml(tmp)).toThrow(
249
+ "otavia.yaml: params.SSO_BASE_URL cannot use !Param; top-level params only allow plain values, !Env, !Secret"
250
+ );
251
+ } finally {
252
+ fs.rmSync(tmp, { recursive: true });
253
+ }
254
+ });
255
+
256
+ test("throws when cell-level params uses !Env/!Secret", () => {
257
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
258
+ try {
259
+ writeYaml(
260
+ tmp,
261
+ `
262
+ stackName: my-stack
263
+ cells:
264
+ - package: "@otavia/sso"
265
+ mount: "sso"
266
+ params:
267
+ AUTH_COOKIE_DOMAIN: !Env AUTH_COOKIE_DOMAIN
268
+ domain:
269
+ host: example.com
270
+ `
271
+ );
272
+ expect(() => loadOtaviaYaml(tmp)).toThrow(
273
+ 'otavia.yaml: cells["sso"].params.AUTH_COOKIE_DOMAIN cannot use !Env/!Secret; use !Param to reference top-level params'
274
+ );
275
+ } finally {
276
+ fs.rmSync(tmp, { recursive: true });
277
+ }
278
+ });
279
+
280
+ test("throws when stackName is missing or empty", () => {
281
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
282
+ try {
283
+ writeYaml(
284
+ tmp,
285
+ `
286
+ stackName:
287
+ cells: [a]
288
+ domain:
289
+ host: x.com
290
+ `
291
+ );
292
+ expect(() => loadOtaviaYaml(tmp)).toThrow("otavia.yaml: missing stackName");
293
+ } finally {
294
+ fs.rmSync(tmp, { recursive: true });
295
+ }
296
+ });
297
+
298
+ test("throws when stackName is empty string", () => {
299
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
300
+ try {
301
+ writeYaml(
302
+ tmp,
303
+ `
304
+ stackName: ""
305
+ cells: [a]
306
+ domain:
307
+ host: x.com
308
+ `
309
+ );
310
+ expect(() => loadOtaviaYaml(tmp)).toThrow("otavia.yaml: missing stackName");
311
+ } finally {
312
+ fs.rmSync(tmp, { recursive: true });
313
+ }
314
+ });
315
+
316
+ test("throws when cells is missing or empty array", () => {
317
+ const tmpMissing = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
318
+ const tmpEmpty = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
319
+ try {
320
+ writeYaml(
321
+ tmpMissing,
322
+ `
323
+ stackName: s
324
+ domain:
325
+ host: x.com
326
+ `
327
+ );
328
+ expect(() => loadOtaviaYaml(tmpMissing)).toThrow("otavia.yaml: missing cells");
329
+
330
+ writeYaml(
331
+ tmpEmpty,
332
+ `
333
+ stackName: s
334
+ cells: []
335
+ domain:
336
+ host: x.com
337
+ `
338
+ );
339
+ expect(() => loadOtaviaYaml(tmpEmpty)).toThrow("otavia.yaml: cells must be a non-empty array or object");
340
+ } finally {
341
+ fs.rmSync(tmpMissing, { recursive: true });
342
+ fs.rmSync(tmpEmpty, { recursive: true });
343
+ }
344
+ });
345
+
346
+ test("throws when domain or domain.host is missing", () => {
347
+ const tmpNoDomain = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
348
+ const tmpNoHost = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
349
+ try {
350
+ writeYaml(
351
+ tmpNoDomain,
352
+ `
353
+ stackName: s
354
+ cells: [a]
355
+ `
356
+ );
357
+ expect(() => loadOtaviaYaml(tmpNoDomain)).toThrow("otavia.yaml: missing domain");
358
+
359
+ writeYaml(
360
+ tmpNoHost,
361
+ `
362
+ stackName: s
363
+ cells: [a]
364
+ domain: {}
365
+ `
366
+ );
367
+ expect(() => loadOtaviaYaml(tmpNoHost)).toThrow("otavia.yaml: missing domain.host");
368
+ } finally {
369
+ fs.rmSync(tmpNoDomain, { recursive: true });
370
+ fs.rmSync(tmpNoHost, { recursive: true });
371
+ }
372
+ });
373
+
374
+ test("throws when defaultCell is not a string", () => {
375
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
376
+ try {
377
+ writeYaml(
378
+ tmp,
379
+ `
380
+ stackName: s
381
+ defaultCell: 123
382
+ cells: [sso]
383
+ domain:
384
+ host: x.com
385
+ `
386
+ );
387
+ expect(() => loadOtaviaYaml(tmp)).toThrow("otavia.yaml: defaultCell must be a string");
388
+ } finally {
389
+ fs.rmSync(tmp, { recursive: true });
390
+ }
391
+ });
392
+
393
+ test("throws when defaultCell is not in cells", () => {
394
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
395
+ try {
396
+ writeYaml(
397
+ tmp,
398
+ `
399
+ stackName: s
400
+ defaultCell: drive
401
+ cells: [sso]
402
+ domain:
403
+ host: x.com
404
+ `
405
+ );
406
+ expect(() => loadOtaviaYaml(tmp)).toThrow(
407
+ 'otavia.yaml: defaultCell "drive" must match one of configured cell mounts'
408
+ );
409
+ } finally {
410
+ fs.rmSync(tmp, { recursive: true });
411
+ }
412
+ });
413
+
414
+ test("parses oauth callback config", () => {
415
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
416
+ try {
417
+ writeYaml(
418
+ tmp,
419
+ `
420
+ stackName: my-stack
421
+ cells:
422
+ sso: "@otavia/sso"
423
+ domain:
424
+ host: example.com
425
+ oauth:
426
+ callback:
427
+ cell: sso
428
+ path: /oauth/callback
429
+ `
430
+ );
431
+ const result = loadOtaviaYaml(tmp);
432
+ expect(result.oauth).toEqual({
433
+ callback: {
434
+ cell: "sso",
435
+ path: "/oauth/callback",
436
+ },
437
+ });
438
+ } finally {
439
+ fs.rmSync(tmp, { recursive: true });
440
+ }
441
+ });
442
+
443
+ test("throws when oauth callback path does not start with slash", () => {
444
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
445
+ try {
446
+ writeYaml(
447
+ tmp,
448
+ `
449
+ stackName: my-stack
450
+ cells:
451
+ sso: "@otavia/sso"
452
+ domain:
453
+ host: example.com
454
+ oauth:
455
+ callback:
456
+ cell: sso
457
+ path: oauth/callback
458
+ `
459
+ );
460
+ expect(() => loadOtaviaYaml(tmp)).toThrow(
461
+ "otavia.yaml: oauth.callback.path must start with '/'"
462
+ );
463
+ } finally {
464
+ fs.rmSync(tmp, { recursive: true });
465
+ }
466
+ });
467
+
468
+ test("throws when oauth callback cell is not in cells", () => {
469
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-test-"));
470
+ try {
471
+ writeYaml(
472
+ tmp,
473
+ `
474
+ stackName: my-stack
475
+ cells:
476
+ sso: "@otavia/sso"
477
+ domain:
478
+ host: example.com
479
+ oauth:
480
+ callback:
481
+ cell: drive
482
+ path: /oauth/callback
483
+ `
484
+ );
485
+ expect(() => loadOtaviaYaml(tmp)).toThrow(
486
+ 'otavia.yaml: oauth.callback.cell "drive" must match one of configured cells'
487
+ );
488
+ } finally {
489
+ fs.rmSync(tmp, { recursive: true });
490
+ }
491
+ });
492
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolvePortsFromEnv, resolvePortsFromPortBase } from "../ports.js";
3
+
4
+ describe("resolvePortsFromPortBase", () => {
5
+ test("derives dev ports from PORT_BASE + dev offsets", () => {
6
+ expect(resolvePortsFromPortBase("dev", 7000)).toEqual({
7
+ portBase: 7000,
8
+ frontend: 7100,
9
+ backend: 8900,
10
+ dynamodb: 9001,
11
+ minio: 9000,
12
+ });
13
+ });
14
+
15
+ test("derives test ports from PORT_BASE + test offsets", () => {
16
+ expect(resolvePortsFromPortBase("test", 8000)).toEqual({
17
+ portBase: 8000,
18
+ frontend: 8100,
19
+ backend: 8910,
20
+ dynamodb: 8012,
21
+ minio: 9014,
22
+ });
23
+ });
24
+ });
25
+
26
+ describe("resolvePortsFromEnv", () => {
27
+ test("reads PORT_BASE from env map", () => {
28
+ expect(resolvePortsFromEnv("dev", { PORT_BASE: "7000" })).toEqual({
29
+ portBase: 7000,
30
+ frontend: 7100,
31
+ backend: 8900,
32
+ dynamodb: 9001,
33
+ minio: 9000,
34
+ });
35
+ });
36
+
37
+ test("throws when PORT_BASE is missing", () => {
38
+ expect(() => resolvePortsFromEnv("dev", {})).toThrow(
39
+ 'Missing PORT_BASE for stage "dev". Define it in .env.dev/.env.test or process env.'
40
+ );
41
+ });
42
+
43
+ test("throws when PORT_BASE is invalid", () => {
44
+ expect(() => resolvePortsFromEnv("test", { PORT_BASE: "abc" })).toThrow(
45
+ 'Invalid PORT_BASE for stage "test": "abc"'
46
+ );
47
+ });
48
+ });
@@ -0,0 +1,60 @@
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 { resolveCellDir } from "../resolve-cell-dir.js";
6
+
7
+ function writeCellYaml(dir: string) {
8
+ writeFileSync(join(dir, "cell.yaml"), "name: test\n", "utf-8");
9
+ }
10
+
11
+ describe("resolveCellDir", () => {
12
+ test("mount-only prefers cells/<mount> when cell.yaml exists", () => {
13
+ const root = mkdtempSync(join(tmpdir(), "otavia-rcd-"));
14
+ try {
15
+ const cells = join(root, "cells", "sso");
16
+ mkdirSync(cells, { recursive: true });
17
+ writeCellYaml(cells);
18
+ expect(resolveCellDir(root, "sso")).toBe(cells);
19
+ } finally {
20
+ rmSync(root, { recursive: true, force: true });
21
+ }
22
+ });
23
+
24
+ test("scoped package prefers cells/<slug> over node_modules when both have cell.yaml", () => {
25
+ const root = mkdtempSync(join(tmpdir(), "otavia-rcd-"));
26
+ try {
27
+ const cells = join(root, "cells", "sso");
28
+ mkdirSync(cells, { recursive: true });
29
+ writeCellYaml(cells);
30
+ const nm = join(root, "node_modules", "@otavia", "sso");
31
+ mkdirSync(nm, { recursive: true });
32
+ writeCellYaml(nm);
33
+ expect(resolveCellDir(root, "@otavia/sso")).toBe(cells);
34
+ } finally {
35
+ rmSync(root, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ test("scoped package uses node_modules when cells has no cell.yaml", () => {
40
+ const root = mkdtempSync(join(tmpdir(), "otavia-rcd-"));
41
+ try {
42
+ mkdirSync(join(root, "cells", "sso"), { recursive: true });
43
+ const nm = join(root, "node_modules", "@otavia", "sso");
44
+ mkdirSync(nm, { recursive: true });
45
+ writeCellYaml(nm);
46
+ expect(resolveCellDir(root, "@otavia/sso")).toBe(nm);
47
+ } finally {
48
+ rmSync(root, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ test("scoped package defaults to cells/<slug> path when nothing resolves", () => {
53
+ const root = mkdtempSync(join(tmpdir(), "otavia-rcd-"));
54
+ try {
55
+ expect(resolveCellDir(root, "@otavia/sso")).toBe(join(root, "cells", "sso"));
56
+ } finally {
57
+ rmSync(root, { recursive: true, force: true });
58
+ }
59
+ });
60
+ });