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,57 @@
1
+ import type { CfnFragment } from "./types.js";
2
+ import { toPascalCase } from "./types.js";
3
+
4
+ /**
5
+ * Generate S3 bucket fragment (data bucket).
6
+ * AWS::S3::Bucket with PublicAccessBlock.
7
+ */
8
+ export function generateBucket(bucketKey: string, bucketName: string): CfnFragment {
9
+ const logicalId = `${toPascalCase(bucketKey)}Bucket`;
10
+ return {
11
+ Resources: {
12
+ [logicalId]: {
13
+ Type: "AWS::S3::Bucket",
14
+ Properties: {
15
+ BucketName: bucketName,
16
+ PublicAccessBlockConfiguration: {
17
+ BlockPublicAcls: true,
18
+ BlockPublicPolicy: true,
19
+ IgnorePublicAcls: true,
20
+ RestrictPublicBuckets: true,
21
+ },
22
+ },
23
+ },
24
+ },
25
+ Outputs: {
26
+ [`${logicalId}Name`]: { Value: { Ref: logicalId } },
27
+ [`${logicalId}Arn`]: { Value: { "Fn::GetAtt": [logicalId, "Arn"] } },
28
+ },
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Generate frontend asset bucket (single per stack).
34
+ * Same structure: AWS::S3::Bucket + PublicAccessBlock.
35
+ */
36
+ export function generateFrontendBucket(bucketName: string): CfnFragment {
37
+ return {
38
+ Resources: {
39
+ FrontendBucket: {
40
+ Type: "AWS::S3::Bucket",
41
+ Properties: {
42
+ BucketName: bucketName,
43
+ PublicAccessBlockConfiguration: {
44
+ BlockPublicAcls: true,
45
+ BlockPublicPolicy: true,
46
+ IgnorePublicAcls: true,
47
+ RestrictPublicBuckets: true,
48
+ },
49
+ },
50
+ },
51
+ },
52
+ Outputs: {
53
+ FrontendBucketName: { Value: { Ref: "FrontendBucket" } },
54
+ FrontendBucketArn: { Value: { "Fn::GetAtt": ["FrontendBucket", "Arn"] } },
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,264 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { stringify } from "yaml";
4
+ import type { OtaviaYaml } from "../config/otavia-yaml-schema.js";
5
+ import type { CellConfig } from "../config/cell-yaml-schema.js";
6
+ import { loadOtaviaYaml } from "../config/load-otavia-yaml.js";
7
+ import { loadCellConfig } from "../config/load-cell-yaml.js";
8
+ import { resolveCellDir } from "../config/resolve-cell-dir.js";
9
+ import { assertDeclaredParamsProvided, mergeParams, resolveParams } from "../config/resolve-params.js";
10
+ import { loadEnvForCell } from "../utils/env.js";
11
+ import { tablePhysicalName, bucketPhysicalName } from "../config/resource-names.js";
12
+ import { generateDynamoDBTable } from "./dynamodb.js";
13
+ import { generateBucket, generateFrontendBucket } from "./s3.js";
14
+ import { generateLambdaFragment } from "./lambda.js";
15
+ import { generateHttpApi } from "./api-gateway.js";
16
+ import { generateCloudFrontDistribution } from "./cloudfront.js";
17
+ import type { CfnFragment } from "./types.js";
18
+ import { toPascalCase } from "./types.js";
19
+
20
+ /** Build ref map: short logical id -> prefixed logical id for a cell's fragments */
21
+ function buildRefMap(fragments: CfnFragment[], prefix: string): Map<string, string> {
22
+ const map = new Map<string, string>();
23
+ for (const f of fragments) {
24
+ for (const key of Object.keys(f.Resources)) {
25
+ map.set(key, prefix + key);
26
+ }
27
+ }
28
+ return map;
29
+ }
30
+
31
+ /** Deep-replace Ref and Fn::GetAtt in a value using refMap */
32
+ function replaceRefsInValue(val: unknown, refMap: Map<string, string>): unknown {
33
+ if (val === null || val === undefined) return val;
34
+ if (Array.isArray(val)) {
35
+ return val.map((item) => replaceRefsInValue(item, refMap));
36
+ }
37
+ if (typeof val === "object") {
38
+ const obj = val as Record<string, unknown>;
39
+ if ("Ref" in obj && typeof obj.Ref === "string" && refMap.has(obj.Ref)) {
40
+ return { Ref: refMap.get(obj.Ref) };
41
+ }
42
+ if ("Fn::GetAtt" in obj && Array.isArray(obj["Fn::GetAtt"])) {
43
+ const att = obj["Fn::GetAtt"] as string[];
44
+ if (att.length >= 1 && typeof att[0] === "string" && refMap.has(att[0])) {
45
+ return { "Fn::GetAtt": [refMap.get(att[0]), ...att.slice(1)] };
46
+ }
47
+ }
48
+ const out: Record<string, unknown> = {};
49
+ for (const [k, v] of Object.entries(obj)) {
50
+ out[k] = replaceRefsInValue(v, refMap);
51
+ }
52
+ return out;
53
+ }
54
+ return val;
55
+ }
56
+
57
+ /** Prefix fragment keys and rewrite internal Ref/GetAtt to use prefixed names */
58
+ function prefixFragment(
59
+ fragment: CfnFragment,
60
+ prefix: string,
61
+ refMap: Map<string, string>
62
+ ): CfnFragment {
63
+ const prefixKey = (key: string) => prefix + key;
64
+ const resources: Record<string, unknown> = {};
65
+ for (const [key, value] of Object.entries(fragment.Resources)) {
66
+ resources[prefixKey(key)] = replaceRefsInValue(value, refMap);
67
+ }
68
+ const result: CfnFragment = { Resources: resources };
69
+ if (fragment.Outputs) {
70
+ result.Outputs = {};
71
+ for (const [key, value] of Object.entries(fragment.Outputs)) {
72
+ result.Outputs[prefixKey(key)] = replaceRefsInValue(value, refMap);
73
+ }
74
+ }
75
+ if (fragment.Conditions) {
76
+ result.Conditions = {};
77
+ for (const [key, value] of Object.entries(fragment.Conditions)) {
78
+ result.Conditions[prefixKey(key)] = replaceRefsInValue(value, refMap);
79
+ }
80
+ }
81
+ return result;
82
+ }
83
+
84
+ function resolvedParamsToEnv(resolved: Record<string, string | unknown>): Record<string, string> {
85
+ const env: Record<string, string> = {};
86
+ for (const [key, value] of Object.entries(resolved)) {
87
+ if (value === null || value === undefined) {
88
+ env[key] = "";
89
+ } else if (typeof value === "object") {
90
+ env[key] = JSON.stringify(value);
91
+ } else {
92
+ env[key] = String(value);
93
+ }
94
+ }
95
+ return env;
96
+ }
97
+
98
+ function toResourceEnvKey(prefix: string, key: string): string {
99
+ const normalized = key.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
100
+ return `${prefix}${normalized}`;
101
+ }
102
+ function toApiPathPattern(mount: string, route: string): string {
103
+ const trimmedRoute = route.trim();
104
+ const mountPrefix = `/${mount}`.replace(/\/+/g, "/");
105
+ const joined = `${mountPrefix}${trimmedRoute.startsWith("/") ? trimmedRoute : `/${trimmedRoute}`}`;
106
+ return joined.replace(/\/+/g, "/");
107
+ }
108
+
109
+ /**
110
+ * Generate a single CloudFormation template (YAML) from OtaviaYaml + all cell configs + resolved params (cloud stage).
111
+ * Resources: each cell's tables -> DynamoDB, buckets -> S3, backend entries -> Lambda + API Gateway HTTP API,
112
+ * frontend -> single S3 bucket + CloudFront path behaviors for single domain.
113
+ */
114
+ export function generateTemplate(rootDir: string, opts?: { certificateArn?: string }): string {
115
+ const otavia = loadOtaviaYaml(rootDir);
116
+ const stackName = otavia.stackName;
117
+ const domainHost = otavia.domain?.host ?? "";
118
+ const bucketSuffix =
119
+ domainHost.replace(/\./g, "-").toLowerCase().replace(/[^a-z0-9-]/g, "-") || "platform";
120
+ const frontendBucketName = `frontend-${stackName}-${bucketSuffix}`.toLowerCase();
121
+
122
+ const resources: Record<string, unknown> = {};
123
+ const outputs: Record<string, unknown> = {};
124
+ const conditions: Record<string, unknown> = {};
125
+ const pathBehaviors: Array<{ pathPattern: string; originId: string; isApi?: boolean }> = [];
126
+ const firstMount = otavia.cellsList[0]?.mount ?? "";
127
+ const defaultCellMount = otavia.defaultCell ?? firstMount;
128
+ const origin = domainHost ? `https://${domainHost}` : "";
129
+
130
+ for (const cellEntry of otavia.cellsList) {
131
+ const cellDir = resolveCellDir(rootDir, cellEntry.package);
132
+ if (!existsSync(resolve(cellDir, "cell.yaml"))) {
133
+ continue;
134
+ }
135
+ const config = loadCellConfig(cellDir);
136
+ const envMap = loadEnvForCell(rootDir, cellDir, { stage: "deploy" });
137
+ const merged = mergeParams(otavia.params, cellEntry.params) as Record<string, unknown>;
138
+ assertDeclaredParamsProvided(config.params, merged, cellEntry.mount);
139
+ const resolved = resolveParams(merged, envMap, { onMissingParam: "throw" });
140
+ const envVars = resolvedParamsToEnv(resolved);
141
+ const pathPrefix = `/${cellEntry.mount}`;
142
+ envVars.CELL_BASE_URL = origin ? `${origin}${pathPrefix}` : "";
143
+ if (firstMount) {
144
+ envVars.SSO_BASE_URL = origin ? `${origin}/${firstMount}` : "";
145
+ }
146
+ envVars.CELL_STAGE = "cloud";
147
+
148
+ const prefix = toPascalCase(cellEntry.mount);
149
+
150
+ if (config.tables) {
151
+ for (const [tableKey, tableConfig] of Object.entries(config.tables)) {
152
+ const tableName = tablePhysicalName(stackName, cellEntry.mount, tableKey);
153
+ const frag = generateDynamoDBTable(tableName, tableKey, tableConfig);
154
+ const refMap = buildRefMap([frag], prefix);
155
+ const prefixed = prefixFragment(frag, prefix, refMap);
156
+ Object.assign(resources, prefixed.Resources);
157
+ if (prefixed.Outputs) Object.assign(outputs, prefixed.Outputs);
158
+ }
159
+ }
160
+
161
+ if (config.buckets) {
162
+ for (const [bucketKey] of Object.entries(config.buckets)) {
163
+ const bucketName = bucketPhysicalName(stackName, cellEntry.mount, bucketKey);
164
+ const frag = generateBucket(bucketKey, bucketName);
165
+ const refMap = buildRefMap([frag], prefix);
166
+ const prefixed = prefixFragment(frag, prefix, refMap);
167
+ Object.assign(resources, prefixed.Resources);
168
+ if (prefixed.Outputs) Object.assign(outputs, prefixed.Outputs);
169
+ }
170
+ }
171
+
172
+ if (config.backend) {
173
+ const tableLogicalIds = config.tables
174
+ ? Object.keys(config.tables).map((k) => `${prefix}${toPascalCase(k)}Table`)
175
+ : [];
176
+ const bucketLogicalIds = config.buckets
177
+ ? Object.keys(config.buckets).map((k) => `${prefix}${toPascalCase(k)}Bucket`)
178
+ : [];
179
+ if (config.tables) {
180
+ for (const tableKey of Object.keys(config.tables)) {
181
+ envVars[toResourceEnvKey("DYNAMODB_TABLE_", tableKey)] = tablePhysicalName(
182
+ stackName,
183
+ cellEntry.mount,
184
+ tableKey
185
+ );
186
+ }
187
+ }
188
+ if (config.buckets) {
189
+ for (const bucketKey of Object.keys(config.buckets)) {
190
+ envVars[toResourceEnvKey("S3_BUCKET_", bucketKey)] = bucketPhysicalName(
191
+ stackName,
192
+ cellEntry.mount,
193
+ bucketKey
194
+ );
195
+ }
196
+ }
197
+ const apiRoutes: Array<{ functionLogicalId: string }> = [];
198
+ const apiPathPatterns = new Set<string>();
199
+
200
+ for (const [entryKey, entry] of Object.entries(config.backend.entries)) {
201
+ const frag = generateLambdaFragment(entryKey, prefix, {
202
+ handlerPath: `build/${cellEntry.mount}/${entryKey}/code.zip`,
203
+ runtime: config.backend.runtime,
204
+ timeout: entry.timeout,
205
+ memory: entry.memory,
206
+ envVars,
207
+ tableLogicalIds: tableLogicalIds.length > 0 ? tableLogicalIds : undefined,
208
+ bucketLogicalIds: bucketLogicalIds.length > 0 ? bucketLogicalIds : undefined,
209
+ });
210
+ Object.assign(resources, frag.Resources);
211
+ const funcLogicalId = `${prefix}${toPascalCase(entryKey)}Function`;
212
+ apiRoutes.push({ functionLogicalId: funcLogicalId });
213
+ for (const route of entry.routes ?? []) {
214
+ apiPathPatterns.add(toApiPathPattern(cellEntry.mount, route));
215
+ }
216
+ }
217
+
218
+ const apiFrag = generateHttpApi(prefix, `${stackName}-${cellEntry.mount}-api`, apiRoutes);
219
+ Object.assign(resources, apiFrag.Resources);
220
+ if (apiFrag.Outputs) Object.assign(outputs, apiFrag.Outputs);
221
+
222
+ if (apiPathPatterns.size === 0) {
223
+ apiPathPatterns.add(pathPrefix.endsWith("/") ? `${pathPrefix}*` : `${pathPrefix}/*`);
224
+ }
225
+ for (const pathPattern of Array.from(apiPathPatterns).sort((a, b) => b.length - a.length)) {
226
+ pathBehaviors.push({
227
+ pathPattern,
228
+ originId: `${prefix}HttpApi`,
229
+ isApi: true,
230
+ });
231
+ }
232
+ }
233
+ }
234
+
235
+ const frontendFrag = generateFrontendBucket(frontendBucketName);
236
+ Object.assign(resources, frontendFrag.Resources);
237
+ if (frontendFrag.Outputs) Object.assign(outputs, frontendFrag.Outputs);
238
+
239
+ const cloudFrontFrag = generateCloudFrontDistribution({
240
+ stackName,
241
+ domainHost,
242
+ defaultOriginId: "S3Frontend",
243
+ defaultCellMount,
244
+ frontendBucketRef: "FrontendBucket",
245
+ pathBehaviors: pathBehaviors.sort((a, b) => b.pathPattern.length - a.pathPattern.length),
246
+ hostedZoneId: otavia.domain?.dns?.provider === "cloudflare" ? undefined : otavia.domain?.dns?.zoneId,
247
+ certificateArn: opts?.certificateArn,
248
+ });
249
+ Object.assign(resources, cloudFrontFrag.Resources);
250
+ if (cloudFrontFrag.Outputs) Object.assign(outputs, cloudFrontFrag.Outputs);
251
+ if (cloudFrontFrag.Conditions) Object.assign(conditions, cloudFrontFrag.Conditions);
252
+
253
+ const template: Record<string, unknown> = {
254
+ AWSTemplateFormatVersion: "2010-09-09",
255
+ Description: `Otavia stack ${stackName}: single CloudFormation`,
256
+ Resources: resources,
257
+ Outputs: outputs,
258
+ };
259
+ if (Object.keys(conditions).length > 0) {
260
+ template.Conditions = conditions;
261
+ }
262
+
263
+ return stringify(template);
264
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * CloudFormation fragment types for Otavia deploy.
3
+ */
4
+
5
+ export type CfnFragment = {
6
+ Resources: Record<string, unknown>;
7
+ Outputs?: Record<string, unknown>;
8
+ Conditions?: Record<string, unknown>;
9
+ };
10
+
11
+ export function toPascalCase(s: string): string {
12
+ return s
13
+ .split(/[-_]/)
14
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
15
+ .join("");
16
+ }
@@ -0,0 +1,175 @@
1
+ export interface ExecResult {
2
+ exitCode: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+
7
+ export async function exec(cmd: string[], opts?: { cwd?: string }): Promise<ExecResult> {
8
+ const proc = Bun.spawn(cmd, {
9
+ cwd: opts?.cwd,
10
+ stdout: "pipe",
11
+ stderr: "pipe",
12
+ });
13
+
14
+ const [stdout, stderr] = await Promise.all([
15
+ new Response(proc.stdout).text(),
16
+ new Response(proc.stderr).text(),
17
+ ]);
18
+ const exitCode = await proc.exited;
19
+
20
+ return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
21
+ }
22
+
23
+ export async function isDockerRunning(): Promise<boolean> {
24
+ const { exitCode } = await exec(["docker", "info"]);
25
+ return exitCode === 0;
26
+ }
27
+
28
+ export async function isContainerRunning(name: string): Promise<boolean> {
29
+ const { exitCode, stdout } = await exec(["docker", "inspect", "-f", "{{.State.Running}}", name]);
30
+ return exitCode === 0 && stdout === "true";
31
+ }
32
+
33
+ export async function containerExists(name: string): Promise<boolean> {
34
+ const { exitCode } = await exec(["docker", "inspect", name]);
35
+ return exitCode === 0;
36
+ }
37
+
38
+ export async function stopContainer(name: string): Promise<void> {
39
+ await exec(["docker", "rm", "-f", name]);
40
+ }
41
+
42
+ export async function getContainerHostPort(
43
+ name: string,
44
+ containerPort: number
45
+ ): Promise<number | undefined> {
46
+ const { exitCode, stdout } = await exec(["docker", "port", name, `${containerPort}/tcp`]);
47
+ if (exitCode !== 0 || !stdout) return undefined;
48
+ const firstLine = stdout.split(/\r?\n/)[0]?.trim();
49
+ if (!firstLine) return undefined;
50
+ const match = firstLine.match(/:(\d+)$/);
51
+ if (!match) return undefined;
52
+ const port = Number.parseInt(match[1], 10);
53
+ if (!Number.isFinite(port)) return undefined;
54
+ return port;
55
+ }
56
+
57
+ export interface DynamoDBOpts {
58
+ port: number;
59
+ persistent: boolean;
60
+ containerName: string;
61
+ }
62
+
63
+ export function buildDynamoDBArgs(opts: DynamoDBOpts): string[] {
64
+ const args = [
65
+ "docker",
66
+ "run",
67
+ "-d",
68
+ ...(opts.persistent ? [] : ["--rm"]),
69
+ "--name",
70
+ opts.containerName,
71
+ "-p",
72
+ `${opts.port}:8000`,
73
+ "amazon/dynamodb-local",
74
+ "-jar",
75
+ "DynamoDBLocal.jar",
76
+ "-sharedDb",
77
+ ];
78
+ if (!opts.persistent) {
79
+ args.push("-inMemory");
80
+ }
81
+ return args;
82
+ }
83
+
84
+ export async function startDynamoDB(opts: DynamoDBOpts): Promise<void> {
85
+ if (await isContainerRunning(opts.containerName)) {
86
+ const mappedPort = await getContainerHostPort(opts.containerName, 8000);
87
+ if (mappedPort === opts.port) return;
88
+ await stopContainer(opts.containerName);
89
+ }
90
+ if (await containerExists(opts.containerName)) {
91
+ await exec(["docker", "start", opts.containerName]);
92
+ const mappedPort = await getContainerHostPort(opts.containerName, 8000);
93
+ if (mappedPort === opts.port) return;
94
+ await stopContainer(opts.containerName);
95
+ }
96
+ const args = buildDynamoDBArgs(opts);
97
+ const { exitCode, stderr } = await exec(args);
98
+ if (exitCode !== 0) {
99
+ throw new Error(`Failed to start DynamoDB container: ${stderr}`);
100
+ }
101
+ }
102
+
103
+ export interface MinIOOpts {
104
+ port: number;
105
+ containerName: string;
106
+ dataDir?: string;
107
+ /** When true, add --rm so container is removed on exit (e.g. for e2e). */
108
+ rm?: boolean;
109
+ }
110
+
111
+ export function buildMinIOArgs(opts: MinIOOpts): string[] {
112
+ const args = [
113
+ "docker",
114
+ "run",
115
+ "-d",
116
+ ...(opts.rm ? ["--rm"] : []),
117
+ "--name",
118
+ opts.containerName,
119
+ "-p",
120
+ `${opts.port}:9000`,
121
+ "-e",
122
+ "MINIO_ROOT_USER=minioadmin",
123
+ "-e",
124
+ "MINIO_ROOT_PASSWORD=minioadmin",
125
+ ];
126
+ if (opts.dataDir) {
127
+ args.push("-v", `${opts.dataDir}:/data`);
128
+ }
129
+ args.push("minio/minio", "server", "/data");
130
+ return args;
131
+ }
132
+
133
+ export async function startMinIO(opts: MinIOOpts): Promise<void> {
134
+ if (await isContainerRunning(opts.containerName)) {
135
+ const mappedPort = await getContainerHostPort(opts.containerName, 9000);
136
+ if (mappedPort === opts.port) return;
137
+ await stopContainer(opts.containerName);
138
+ }
139
+ if (await containerExists(opts.containerName)) {
140
+ await exec(["docker", "start", opts.containerName]);
141
+ const mappedPort = await getContainerHostPort(opts.containerName, 9000);
142
+ if (mappedPort === opts.port) return;
143
+ await stopContainer(opts.containerName);
144
+ }
145
+ const args = buildMinIOArgs(opts);
146
+ const { exitCode, stderr } = await exec(args);
147
+ if (exitCode !== 0) {
148
+ throw new Error(`Failed to start MinIO container: ${stderr}`);
149
+ }
150
+ }
151
+
152
+ export async function waitForPort(port: number, timeoutMs = 30_000): Promise<boolean> {
153
+ const start = Date.now();
154
+ while (Date.now() - start < timeoutMs) {
155
+ try {
156
+ const socket = await Bun.connect({
157
+ hostname: "127.0.0.1",
158
+ port,
159
+ socket: {
160
+ data() {},
161
+ open(socket) {
162
+ socket.end();
163
+ },
164
+ error() {},
165
+ close() {},
166
+ },
167
+ });
168
+ socket.end();
169
+ return true;
170
+ } catch {
171
+ await Bun.sleep(200);
172
+ }
173
+ }
174
+ return false;
175
+ }
@@ -0,0 +1,124 @@
1
+ import {
2
+ type AttributeDefinition,
3
+ CreateTableCommand,
4
+ type CreateTableCommandInput,
5
+ DynamoDBClient,
6
+ type GlobalSecondaryIndex,
7
+ type KeySchemaElement,
8
+ ListTablesCommand,
9
+ } from "@aws-sdk/client-dynamodb";
10
+ import type { TableConfig } from "../config/cell-yaml-schema.js";
11
+
12
+ function attrType(t: string): "S" | "N" | "B" {
13
+ const upper = t.toUpperCase();
14
+ if (upper === "S" || upper === "N" || upper === "B") return upper;
15
+ return "S";
16
+ }
17
+
18
+ export function buildCreateTableInput(
19
+ tableName: string,
20
+ config: TableConfig
21
+ ): CreateTableCommandInput {
22
+ const keyEntries = Object.entries(config.keys);
23
+ const keySchema: KeySchemaElement[] = keyEntries.map(([name], i) => ({
24
+ AttributeName: name,
25
+ KeyType: i === 0 ? "HASH" : "RANGE",
26
+ }));
27
+
28
+ const attrSet = new Map<string, string>();
29
+ for (const [name, type] of keyEntries) {
30
+ attrSet.set(name, attrType(type));
31
+ }
32
+
33
+ const gsiList: GlobalSecondaryIndex[] = [];
34
+ if (config.gsi) {
35
+ for (const [gsiName, gsiConfig] of Object.entries(config.gsi)) {
36
+ const gsiKeyEntries = Object.entries(gsiConfig.keys);
37
+ const gsiKeySchema: KeySchemaElement[] = gsiKeyEntries.map(([name], i) => ({
38
+ AttributeName: name,
39
+ KeyType: i === 0 ? "HASH" : "RANGE",
40
+ }));
41
+
42
+ for (const [name, type] of gsiKeyEntries) {
43
+ attrSet.set(name, attrType(type));
44
+ }
45
+
46
+ gsiList.push({
47
+ IndexName: gsiName,
48
+ KeySchema: gsiKeySchema,
49
+ Projection: {
50
+ ProjectionType: (gsiConfig.projection?.toUpperCase() as "ALL" | "KEYS_ONLY") || "ALL",
51
+ },
52
+ });
53
+ }
54
+ }
55
+
56
+ const attributeDefinitions: AttributeDefinition[] = [...attrSet.entries()].map(
57
+ ([name, type]) => ({
58
+ AttributeName: name,
59
+ AttributeType: type as "S" | "N" | "B",
60
+ })
61
+ );
62
+
63
+ const input: CreateTableCommandInput = {
64
+ TableName: tableName,
65
+ KeySchema: keySchema,
66
+ AttributeDefinitions: attributeDefinitions,
67
+ BillingMode: "PAY_PER_REQUEST",
68
+ };
69
+
70
+ if (gsiList.length > 0) {
71
+ input.GlobalSecondaryIndexes = gsiList;
72
+ }
73
+
74
+ return input;
75
+ }
76
+
77
+ function makeClient(endpoint: string): DynamoDBClient {
78
+ return new DynamoDBClient({
79
+ endpoint,
80
+ region: "local",
81
+ credentials: {
82
+ accessKeyId: "local",
83
+ secretAccessKey: "local",
84
+ },
85
+ });
86
+ }
87
+
88
+ export async function isDynamoDBReady(endpoint: string): Promise<boolean> {
89
+ const client = makeClient(endpoint);
90
+ try {
91
+ await client.send(new ListTablesCommand({}));
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ } finally {
96
+ client.destroy();
97
+ }
98
+ }
99
+
100
+ export interface LocalTableEntry {
101
+ tableName: string;
102
+ config: TableConfig;
103
+ }
104
+
105
+ export async function ensureLocalTables(
106
+ endpoint: string,
107
+ tables: LocalTableEntry[]
108
+ ): Promise<void> {
109
+ const client = makeClient(endpoint);
110
+ try {
111
+ for (const table of tables) {
112
+ const input = buildCreateTableInput(table.tableName, table.config);
113
+ try {
114
+ await client.send(new CreateTableCommand(input));
115
+ } catch (err: unknown) {
116
+ const e = err as { name?: string };
117
+ if (e.name === "ResourceInUseException") continue;
118
+ throw err;
119
+ }
120
+ }
121
+ } finally {
122
+ client.destroy();
123
+ }
124
+ }
@@ -0,0 +1,44 @@
1
+ import { CreateBucketCommand, ListBucketsCommand, S3Client } from "@aws-sdk/client-s3";
2
+
3
+ function makeClient(endpoint: string): S3Client {
4
+ return new S3Client({
5
+ endpoint,
6
+ region: "local",
7
+ forcePathStyle: true,
8
+ credentials: {
9
+ accessKeyId: "minioadmin",
10
+ secretAccessKey: "minioadmin",
11
+ },
12
+ });
13
+ }
14
+
15
+ export async function ensureLocalBuckets(endpoint: string, bucketNames: string[]): Promise<void> {
16
+ const client = makeClient(endpoint);
17
+ try {
18
+ for (const bucket of bucketNames) {
19
+ try {
20
+ await client.send(new CreateBucketCommand({ Bucket: bucket }));
21
+ } catch (err: unknown) {
22
+ const e = err as { name?: string };
23
+ if (e.name === "BucketAlreadyOwnedByYou" || e.name === "BucketAlreadyExists") {
24
+ continue;
25
+ }
26
+ throw err;
27
+ }
28
+ }
29
+ } finally {
30
+ client.destroy();
31
+ }
32
+ }
33
+
34
+ export async function isMinIOReady(endpoint: string): Promise<boolean> {
35
+ const client = makeClient(endpoint);
36
+ try {
37
+ await client.send(new ListBucketsCommand({}));
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ } finally {
42
+ client.destroy();
43
+ }
44
+ }