@mettlecast/domain-cli 0.2.22 → 0.2.24
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/dist/cli.js +60 -3
- package/dist/commands/add-api.d.ts +1 -1
- package/dist/commands/add-api.js +45 -17
- package/dist/commands/add-fixture-factory.d.ts +16 -0
- package/dist/commands/add-fixture-factory.js +60 -0
- package/dist/commands/add-module.js +4 -5
- package/dist/commands/add-seed-page.js +4 -5
- package/dist/commands/build-flows.js +1 -1
- package/dist/commands/dev.d.ts +30 -3
- package/dist/commands/dev.js +52 -11
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.js +341 -6
- package/dist/commands/generate-openapi.d.ts +21 -0
- package/dist/commands/generate-openapi.js +117 -0
- package/dist/commands/generate-sdk.d.ts +25 -0
- package/dist/commands/generate-sdk.js +98 -0
- package/dist/commands/init.d.ts +14 -0
- package/dist/commands/init.js +62 -0
- package/dist/commands/reseed-page.js +5 -6
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +28 -6
- package/dist/commands/why.d.ts +47 -0
- package/dist/commands/why.js +129 -0
- package/dist/templates/api-skeleton.d.ts +5 -1
- package/dist/templates/api-skeleton.js +28 -6
- package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
- package/dist/templates/patterns/api/create-with-event.js +14 -1
- package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
- package/dist/templates/patterns/api/idempotent-mutation.js +20 -0
- package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
- package/dist/templates/patterns/api/paginated-list.js +12 -2
- package/dist/templates/patterns/api/simple-crud.d.ts +5 -0
- package/dist/templates/patterns/api/simple-crud.js +22 -4
- package/dist/templates/patterns/api/streaming-list.d.ts +27 -0
- package/dist/templates/patterns/api/streaming-list.js +91 -0
- package/dist/templates/patterns/api/system-admin.d.ts +5 -0
- package/dist/templates/patterns/api/system-admin.js +14 -4
- package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
- package/dist/templates/patterns/api/webhook-receiver-style.js +22 -0
- package/dist/utils/s3-fetch.js +23 -32
- package/package.json +4 -1
- package/src/__tests__/commands/add-api.test.ts +160 -0
- package/src/__tests__/commands/dev.test.ts +162 -0
- package/src/__tests__/commands/why.test.ts +199 -0
- package/src/__tests__/doctor.test.ts +336 -1
- package/src/__tests__/smoke/scaffold.test.ts +574 -0
- package/src/cli.ts +67 -5
- package/src/commands/add-api.ts +68 -19
- package/src/commands/add-fixture-factory.ts +75 -0
- package/src/commands/add-module.ts +4 -5
- package/src/commands/add-seed-page.ts +4 -5
- package/src/commands/build-flows.ts +2 -2
- package/src/commands/dev.ts +78 -11
- package/src/commands/doctor.ts +379 -12
- package/src/commands/generate-openapi.ts +154 -0
- package/src/commands/generate-sdk.ts +125 -0
- package/src/commands/init.ts +78 -0
- package/src/commands/reseed-page.ts +5 -6
- package/src/commands/upgrade.ts +26 -6
- package/src/commands/why.ts +171 -0
- package/src/templates/api-skeleton.ts +32 -6
- package/src/templates/patterns/api/create-with-event.ts +15 -1
- package/src/templates/patterns/api/idempotent-mutation.ts +21 -0
- package/src/templates/patterns/api/paginated-list.ts +13 -2
- package/src/templates/patterns/api/simple-crud.ts +23 -4
- package/src/templates/patterns/api/streaming-list.ts +91 -0
- package/src/templates/patterns/api/system-admin.ts +15 -4
- package/src/templates/patterns/api/webhook-receiver-style.ts +23 -0
- package/src/utils/s3-fetch.ts +26 -34
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: streaming-list
|
|
3
|
+
*
|
|
4
|
+
* Lambda Response Streaming template. Returns items as a chunked
|
|
5
|
+
* JSON Lines stream rather than a single JSON array.
|
|
6
|
+
*
|
|
7
|
+
* Use this when:
|
|
8
|
+
* - The result set can be large (thousands of rows)
|
|
9
|
+
* - TTFB matters (first item arrives before the full query completes)
|
|
10
|
+
* - The client can consume JSON Lines ({\"id\":1}\n{\"id\":2}\n...)
|
|
11
|
+
*
|
|
12
|
+
* Do NOT use when:
|
|
13
|
+
* - The client expects a single unwrapped JSON object
|
|
14
|
+
* - The total response size is small (< 1 KB)
|
|
15
|
+
* - You need typed response headers for pagination
|
|
16
|
+
*
|
|
17
|
+
* Dependencies:
|
|
18
|
+
* - `@bufbuild/protobuf` for stream framing (or built-in node:stream)
|
|
19
|
+
* - API Gateway HTTP API with 2.0 payload format (not REST API)
|
|
20
|
+
* - Lambda runtime v20+
|
|
21
|
+
*
|
|
22
|
+
* @param domain - Domain ID in kebab-case
|
|
23
|
+
* @param apiId - API ID in kebab-case
|
|
24
|
+
* @param itemName - Singular item name (e.g. 'ticket', 'member')
|
|
25
|
+
* @returns TypeScript source code.
|
|
26
|
+
*/
|
|
27
|
+
export declare function streamingListPattern(domain: string, apiId: string, itemName: string): string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: streaming-list
|
|
3
|
+
*
|
|
4
|
+
* Lambda Response Streaming template. Returns items as a chunked
|
|
5
|
+
* JSON Lines stream rather than a single JSON array.
|
|
6
|
+
*
|
|
7
|
+
* Use this when:
|
|
8
|
+
* - The result set can be large (thousands of rows)
|
|
9
|
+
* - TTFB matters (first item arrives before the full query completes)
|
|
10
|
+
* - The client can consume JSON Lines ({\"id\":1}\n{\"id\":2}\n...)
|
|
11
|
+
*
|
|
12
|
+
* Do NOT use when:
|
|
13
|
+
* - The client expects a single unwrapped JSON object
|
|
14
|
+
* - The total response size is small (< 1 KB)
|
|
15
|
+
* - You need typed response headers for pagination
|
|
16
|
+
*
|
|
17
|
+
* Dependencies:
|
|
18
|
+
* - `@bufbuild/protobuf` for stream framing (or built-in node:stream)
|
|
19
|
+
* - API Gateway HTTP API with 2.0 payload format (not REST API)
|
|
20
|
+
* - Lambda runtime v20+
|
|
21
|
+
*
|
|
22
|
+
* @param domain - Domain ID in kebab-case
|
|
23
|
+
* @param apiId - API ID in kebab-case
|
|
24
|
+
* @param itemName - Singular item name (e.g. 'ticket', 'member')
|
|
25
|
+
* @returns TypeScript source code.
|
|
26
|
+
*/
|
|
27
|
+
export function streamingListPattern(domain, apiId, itemName) {
|
|
28
|
+
return `import { z } from 'zod';
|
|
29
|
+
import { defineApi } from '@mettlecast/domain-runtime';
|
|
30
|
+
import type { Result, AppError } from '@mettlecast/domain-runtime';
|
|
31
|
+
|
|
32
|
+
const ${itemName}Schema = z.object({ id: z.string() });
|
|
33
|
+
const inputSchema = z.object({
|
|
34
|
+
limit: z.coerce.number().int().min(1).max(1000).default(100),
|
|
35
|
+
cursor: z.string().optional(),
|
|
36
|
+
}).default({ limit: 100 });
|
|
37
|
+
const outputSchema = z.object({
|
|
38
|
+
items: z.array(${itemName}Schema),
|
|
39
|
+
nextCursor: z.string().optional(),
|
|
40
|
+
}).default({ items: [] });
|
|
41
|
+
|
|
42
|
+
export const ${apiId.replace(/-/g, '_')} = defineApi({
|
|
43
|
+
id: '${apiId}',
|
|
44
|
+
path: '/v1/${domain}/${apiId}',
|
|
45
|
+
method: 'GET',
|
|
46
|
+
tenancy: 'required',
|
|
47
|
+
versions: {
|
|
48
|
+
v1: {
|
|
49
|
+
status: 'stable',
|
|
50
|
+
input: inputSchema,
|
|
51
|
+
output: outputSchema,
|
|
52
|
+
handler: async (input, ctx): Promise<Result<z.infer<typeof outputSchema>, AppError>> => {
|
|
53
|
+
// Streaming response: each item is yielded as a JSON line
|
|
54
|
+
// before the full query completes. The runtime handler
|
|
55
|
+
// wrapper converts this generator into a chunked response.
|
|
56
|
+
//
|
|
57
|
+
// GET /v1/${domain}/${apiId}?limit=100
|
|
58
|
+
// Transfer-Encoding: chunked
|
|
59
|
+
// Content-Type: application/x-ndjson
|
|
60
|
+
//
|
|
61
|
+
// {"id":"1"}\\n
|
|
62
|
+
// {"id":"2"}\\n
|
|
63
|
+
// {"id":"3"}\\n
|
|
64
|
+
|
|
65
|
+
let nextCursor: string | undefined;
|
|
66
|
+
const items: unknown[] = [];
|
|
67
|
+
|
|
68
|
+
// Replace this query with your actual data source.
|
|
69
|
+
const rows = await ctx.db.query(
|
|
70
|
+
'${domain}',
|
|
71
|
+
'SELECT id FROM ${domain}s LIMIT $1 OFFSET $2',
|
|
72
|
+
[input.limit + 1, input.cursor ? parseInt(input.cursor, 10) : 0],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < Math.min(rows.length, input.limit); i++) {
|
|
76
|
+
items.push(rows[i]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (rows.length > input.limit) {
|
|
80
|
+
nextCursor = String(
|
|
81
|
+
(input.cursor ? parseInt(input.cursor, 10) : 0) + input.limit,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { ok: true, value: { items, nextCursor } };
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
@@ -3,3 +3,8 @@
|
|
|
3
3
|
* Produces: GET/POST with tenancy: 'system', no tenant context.
|
|
4
4
|
*/
|
|
5
5
|
export declare function systemAdminTemplate(domain: string, id: string, tenancy: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Example body string used as the event body in the add-api fixture for the
|
|
8
|
+
* system-admin pattern. Mirrors the default shape of the input schema.
|
|
9
|
+
*/
|
|
10
|
+
export declare const systemAdminExampleBody: string;
|
|
@@ -9,18 +9,23 @@ export function systemAdminTemplate(domain, id, tenancy) {
|
|
|
9
9
|
const varName = camelCase(id);
|
|
10
10
|
return `import { z } from 'zod';
|
|
11
11
|
import { defineApi } from '@mettlecast/domain-runtime';
|
|
12
|
+
import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
|
|
13
|
+
import type { Result, AppError } from '@mettlecast/domain-runtime';
|
|
14
|
+
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
15
|
+
|
|
16
|
+
initOtel();
|
|
12
17
|
|
|
13
18
|
// ── Zod schemas ──────────────────────────────────────────────
|
|
14
19
|
|
|
15
20
|
const ${varName}Input = z.object({
|
|
16
21
|
command: z.enum(['status', 'metrics', 'health']),
|
|
17
22
|
domainId: z.string().optional(),
|
|
18
|
-
});
|
|
23
|
+
}).default({ command: 'status' });
|
|
19
24
|
|
|
20
25
|
const ${varName}Output = z.object({
|
|
21
26
|
ok: z.boolean(),
|
|
22
27
|
data: z.record(z.unknown()).optional(),
|
|
23
|
-
});
|
|
28
|
+
}).default({ ok: true });
|
|
24
29
|
|
|
25
30
|
// ── System health input ──────────────────────────────────────
|
|
26
31
|
|
|
@@ -31,7 +36,7 @@ const SystemHealthOutput = z.object({
|
|
|
31
36
|
id: z.string(),
|
|
32
37
|
status: z.enum(['healthy', 'degraded']),
|
|
33
38
|
})),
|
|
34
|
-
});
|
|
39
|
+
}).default({ status: 'healthy', uptime: 0, domains: [] });
|
|
35
40
|
|
|
36
41
|
// ── API definitions ──────────────────────────────────────────
|
|
37
42
|
|
|
@@ -79,7 +84,7 @@ export const ${varName}Health = defineApi({
|
|
|
79
84
|
versions: {
|
|
80
85
|
v1: {
|
|
81
86
|
status: 'stable',
|
|
82
|
-
input: z.object({}).
|
|
87
|
+
input: z.object({}).default({}),
|
|
83
88
|
output: SystemHealthOutput,
|
|
84
89
|
handler: async (_input, _ctx) => {
|
|
85
90
|
return {
|
|
@@ -93,3 +98,8 @@ export const ${varName}Health = defineApi({
|
|
|
93
98
|
});
|
|
94
99
|
`;
|
|
95
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Example body string used as the event body in the add-api fixture for the
|
|
103
|
+
* system-admin pattern. Mirrors the default shape of the input schema.
|
|
104
|
+
*/
|
|
105
|
+
export const systemAdminExampleBody = JSON.stringify({ command: 'status' });
|
|
@@ -3,3 +3,8 @@
|
|
|
3
3
|
* Produces: POST with signature verification, provider enum.
|
|
4
4
|
*/
|
|
5
5
|
export declare function webhookReceiverStyleTemplate(domain: string, id: string, tenancy: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Example body string used as the event body in the add-api fixture for the
|
|
8
|
+
* webhook-receiver-style pattern. Mirrors the default shape of the input schema.
|
|
9
|
+
*/
|
|
10
|
+
export declare const webhookReceiverStyleExampleBody: string;
|
|
@@ -9,6 +9,11 @@ export function webhookReceiverStyleTemplate(domain, id, tenancy) {
|
|
|
9
9
|
const varName = camelCase(id);
|
|
10
10
|
return `import { z } from 'zod';
|
|
11
11
|
import { defineApi } from '@mettlecast/domain-runtime';
|
|
12
|
+
import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
|
|
13
|
+
import type { Result, AppError } from '@mettlecast/domain-runtime';
|
|
14
|
+
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
15
|
+
|
|
16
|
+
initOtel();
|
|
12
17
|
|
|
13
18
|
// ── Supported webhook providers ───────────────────────────────
|
|
14
19
|
|
|
@@ -26,12 +31,20 @@ const ${varName}Input = z.object({
|
|
|
26
31
|
eventType: z.string().min(1),
|
|
27
32
|
payload: z.record(z.unknown()),
|
|
28
33
|
rawBody: z.string().optional(),
|
|
34
|
+
}).default({
|
|
35
|
+
provider: 'stripe',
|
|
36
|
+
eventType: 'example.event',
|
|
37
|
+
payload: {},
|
|
29
38
|
});
|
|
30
39
|
|
|
31
40
|
const ${varName}Output = z.object({
|
|
32
41
|
received: z.boolean(),
|
|
33
42
|
eventId: z.string().uuid(),
|
|
34
43
|
provider: ProviderEnum,
|
|
44
|
+
}).default({
|
|
45
|
+
received: true,
|
|
46
|
+
eventId: '00000000-0000-0000-0000-000000000000',
|
|
47
|
+
provider: 'stripe',
|
|
35
48
|
});
|
|
36
49
|
|
|
37
50
|
// ── API definition ───────────────────────────────────────────
|
|
@@ -77,3 +90,12 @@ export const ${varName} = defineApi({
|
|
|
77
90
|
});
|
|
78
91
|
`;
|
|
79
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Example body string used as the event body in the add-api fixture for the
|
|
95
|
+
* webhook-receiver-style pattern. Mirrors the default shape of the input schema.
|
|
96
|
+
*/
|
|
97
|
+
export const webhookReceiverStyleExampleBody = JSON.stringify({
|
|
98
|
+
provider: 'stripe',
|
|
99
|
+
eventType: 'example.event',
|
|
100
|
+
payload: {},
|
|
101
|
+
});
|
package/dist/utils/s3-fetch.js
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
1
|
-
import
|
|
1
|
+
import ky from 'ky';
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
|
+
const DEFAULT_SCAFFOLD_REGION = process.env['SCAFFOLD_BUCKET_REGION'] ?? 'us-east-1';
|
|
4
|
+
function s3BaseUrl(bucket) {
|
|
5
|
+
const region = DEFAULT_SCAFFOLD_REGION;
|
|
6
|
+
return region === 'us-east-1'
|
|
7
|
+
? `https://${bucket}.s3.amazonaws.com`
|
|
8
|
+
: `https://${bucket}.s3.${region}.amazonaws.com`;
|
|
9
|
+
}
|
|
10
|
+
/** Singleton ky client — re-used across all fetch functions. */
|
|
11
|
+
const client = ky.create({
|
|
12
|
+
retry: { limit: 3, methods: ['get'] },
|
|
13
|
+
timeout: 30_000,
|
|
14
|
+
});
|
|
3
15
|
export async function fetchVersionsJson(bucket) {
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
const res = await client.send(cmd);
|
|
7
|
-
const body = await streamToBuffer(res.Body);
|
|
8
|
-
return JSON.parse(body.toString('utf-8'));
|
|
16
|
+
const url = `${s3BaseUrl(bucket)}/versions.json`;
|
|
17
|
+
return (await client.get(url).json());
|
|
9
18
|
}
|
|
10
19
|
export async function fetchModulesJson(bucket, version) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
Bucket: bucket,
|
|
14
|
-
Key: `versions/${version}/modules.json`,
|
|
15
|
-
});
|
|
16
|
-
const res = await client.send(cmd);
|
|
17
|
-
const body = await streamToBuffer(res.Body);
|
|
18
|
-
return JSON.parse(body.toString('utf-8'));
|
|
20
|
+
const url = `${s3BaseUrl(bucket)}/versions/${encodeURIComponent(version)}/modules.json`;
|
|
21
|
+
return (await client.get(url).json());
|
|
19
22
|
}
|
|
20
23
|
export async function fetchModuleTarball(bucket, version, tarball, expectedSha256) {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const res = await client.send(cmd);
|
|
25
|
-
const buf = await streamToBuffer(res.Body);
|
|
24
|
+
const url = `${s3BaseUrl(bucket)}/versions/${encodeURIComponent(version)}/${encodeURIComponent(tarball)}`;
|
|
25
|
+
const arrayBuffer = await client.get(url).arrayBuffer();
|
|
26
|
+
const buf = Buffer.from(arrayBuffer);
|
|
26
27
|
const actual = createHash('sha256').update(buf).digest('hex');
|
|
27
28
|
if (actual !== expectedSha256) {
|
|
28
29
|
throw new Error(`Checksum mismatch for ${tarball}: expected ${expectedSha256}, got ${actual}`);
|
|
@@ -30,23 +31,13 @@ export async function fetchModuleTarball(bucket, version, tarball, expectedSha25
|
|
|
30
31
|
return buf;
|
|
31
32
|
}
|
|
32
33
|
export async function fetchScaffoldFile(bucket, version, path) {
|
|
33
|
-
const
|
|
34
|
-
const cmd = new GetObjectCommand({ Bucket: bucket, Key: `versions/${version}/${path}` });
|
|
34
|
+
const url = `${s3BaseUrl(bucket)}/versions/${encodeURIComponent(version)}/${encodeURIComponent(path)}`;
|
|
35
35
|
try {
|
|
36
|
-
|
|
37
|
-
const body = await streamToBuffer(res.Body);
|
|
38
|
-
return body.toString('utf-8');
|
|
36
|
+
return await client.get(url).text();
|
|
39
37
|
}
|
|
40
38
|
catch (err) {
|
|
41
|
-
if (err.
|
|
39
|
+
if (err.response?.status === 404)
|
|
42
40
|
return null;
|
|
43
41
|
throw err;
|
|
44
42
|
}
|
|
45
43
|
}
|
|
46
|
-
async function streamToBuffer(stream) {
|
|
47
|
-
const chunks = [];
|
|
48
|
-
for await (const chunk of stream) {
|
|
49
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
50
|
-
}
|
|
51
|
-
return Buffer.concat(chunks);
|
|
52
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mettlecast/domain-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"build": "tsc -p tsconfig.json",
|
|
17
17
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
18
18
|
"dev": "tsx ./src/cli.ts",
|
|
19
|
+
"bun": "bun run src/cli.ts",
|
|
19
20
|
"test": "vitest"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
@@ -26,11 +27,13 @@
|
|
|
26
27
|
"commander": "^12.0.0",
|
|
27
28
|
"dotenv": "^16.0.0",
|
|
28
29
|
"fastify": "^5.0.0",
|
|
30
|
+
"ky": "^1.7.0",
|
|
29
31
|
"pino": "^9.0.0",
|
|
30
32
|
"pino-pretty": "^11.0.0",
|
|
31
33
|
"semver": "^7.0.0",
|
|
32
34
|
"tar": "^7.0.0",
|
|
33
35
|
"tsx": "^4.0.0",
|
|
36
|
+
"zod": "^4.0.0",
|
|
34
37
|
"zod-to-json-schema": "^3.0.0"
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, writeFile, mkdir, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { runAddApi } from '../../commands/add-api.js';
|
|
6
|
+
|
|
7
|
+
describe('add-api command', () => {
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
let domainDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'tib-add-api-test-'));
|
|
13
|
+
domainDir = join(tmpDir, 'demo');
|
|
14
|
+
await mkdir(join(domainDir, 'api'), { recursive: true });
|
|
15
|
+
await writeFile(
|
|
16
|
+
join(domainDir, 'domain.config.ts'),
|
|
17
|
+
`export default { id: 'demo', tenancy: 'required' };\n`,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
try {
|
|
23
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
24
|
+
} catch {
|
|
25
|
+
// ignore cleanup errors
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('scaffolds an API file and a fixture JSON with valid JSON body', async () => {
|
|
30
|
+
await runAddApi({
|
|
31
|
+
domain: 'demo',
|
|
32
|
+
id: 'ping-thing',
|
|
33
|
+
tenancy: 'required',
|
|
34
|
+
domainsDir: tmpDir,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const apiFile = join(domainDir, 'api', 'ping-thing.ts');
|
|
38
|
+
const fixtureFile = join(domainDir, 'api', '__tests__', 'ping-thing.fixture.json');
|
|
39
|
+
|
|
40
|
+
const apiContent = await readFile(apiFile, 'utf8');
|
|
41
|
+
const fixtureContent = await readFile(fixtureFile, 'utf8');
|
|
42
|
+
|
|
43
|
+
// The fixture file must be valid JSON.
|
|
44
|
+
expect(() => JSON.parse(fixtureContent)).not.toThrow();
|
|
45
|
+
const parsed = JSON.parse(fixtureContent) as { event: { body: string } };
|
|
46
|
+
expect(parsed.event.body).toBe('{}');
|
|
47
|
+
|
|
48
|
+
// The API file must contain .default() on input and output.
|
|
49
|
+
expect(apiContent).toMatch(/input:\s*z\.object\(\{\}\)\.default\(\{\}\)/);
|
|
50
|
+
expect(apiContent).toMatch(/output:\s*z\.object\(\{\s*ok:\s*z\.boolean\(\)\s*\}\)\.default\(\{\s*ok:\s*true\s*\}\)/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('scaffolds a simple-crud pattern API with .default() on schemas', async () => {
|
|
54
|
+
await runAddApi({
|
|
55
|
+
domain: 'demo',
|
|
56
|
+
id: 'widget',
|
|
57
|
+
tenancy: 'required',
|
|
58
|
+
pattern: 'simple-crud',
|
|
59
|
+
domainsDir: tmpDir,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const apiFile = join(domainDir, 'api', 'widget.ts');
|
|
63
|
+
const fixtureFile = join(domainDir, 'api', '__tests__', 'widget.fixture.json');
|
|
64
|
+
|
|
65
|
+
const apiContent = await readFile(apiFile, 'utf8');
|
|
66
|
+
const fixtureContent = await readFile(fixtureFile, 'utf8');
|
|
67
|
+
|
|
68
|
+
expect(() => JSON.parse(fixtureContent)).not.toThrow();
|
|
69
|
+
const parsed = JSON.parse(fixtureContent) as { event: { body: string } };
|
|
70
|
+
expect(parsed.event.body).toBe('{}');
|
|
71
|
+
|
|
72
|
+
// Both the per-id handler and the list handler must carry .default() on schemas.
|
|
73
|
+
expect(apiContent).toMatch(/z\.object\(\{\s*id:\s*z\.string\(\)\.uuid\(\)\.optional\(\),?\s*\}\)\.default\(\{\}\)/);
|
|
74
|
+
expect(apiContent).toMatch(/\.default\(\{\s*id:\s*'00000000-0000-0000-0000-000000000000'/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('scaffolds a create-with-event pattern API with .default() including required name', async () => {
|
|
78
|
+
await runAddApi({
|
|
79
|
+
domain: 'demo',
|
|
80
|
+
id: 'order',
|
|
81
|
+
tenancy: 'required',
|
|
82
|
+
pattern: 'create-with-event',
|
|
83
|
+
domainsDir: tmpDir,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const apiFile = join(domainDir, 'api', 'order.ts');
|
|
87
|
+
const fixtureFile = join(domainDir, 'api', '__tests__', 'order.fixture.json');
|
|
88
|
+
|
|
89
|
+
const apiContent = await readFile(apiFile, 'utf8');
|
|
90
|
+
const fixtureContent = await readFile(fixtureFile, 'utf8');
|
|
91
|
+
|
|
92
|
+
expect(() => JSON.parse(fixtureContent)).not.toThrow();
|
|
93
|
+
const parsed = JSON.parse(fixtureContent) as { event: { body: string } };
|
|
94
|
+
expect(JSON.parse(parsed.event.body)).toEqual({ name: 'Example' });
|
|
95
|
+
|
|
96
|
+
expect(apiContent).toMatch(/z\.object\(\{\s*name:\s*z\.string\(\)\.min\(1\),?\s*payload:\s*z\.record\(z\.unknown\(\)\)\.optional\(\),?\s*\}\)\.default\(\{\s*name:\s*'Example'\s*\}\)/);
|
|
97
|
+
expect(apiContent).toMatch(/\.default\(\{\s*id:\s*'00000000-0000-0000-0000-000000000000',\s*status:\s*'created',?\s*\}\)/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('scaffolds a system-admin pattern with .default() on both APIs', async () => {
|
|
101
|
+
await runAddApi({
|
|
102
|
+
domain: 'demo',
|
|
103
|
+
id: 'console',
|
|
104
|
+
tenancy: 'system',
|
|
105
|
+
pattern: 'system-admin',
|
|
106
|
+
domainsDir: tmpDir,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const apiFile = join(domainDir, 'api', 'console.ts');
|
|
110
|
+
const apiContent = await readFile(apiFile, 'utf8');
|
|
111
|
+
|
|
112
|
+
// First API: input defaults to command: 'status'; output defaults to { ok: true }.
|
|
113
|
+
expect(apiContent).toMatch(/z\.object\(\{\s*command:\s*z\.enum\(\[.*?\]\),?\s*domainId:\s*z\.string\(\)\.optional\(\),?\s*\}\)\.default\(\{\s*command:\s*'status'\s*\}\)/);
|
|
114
|
+
expect(apiContent).toMatch(/z\.object\(\{\s*ok:\s*z\.boolean\(\),?\s*data:\s*z\.record\(z\.unknown\(\)\)\.optional\(\),?\s*\}\)\.default\(\{\s*ok:\s*true\s*\}\)/);
|
|
115
|
+
|
|
116
|
+
// Health API: .default({}) replaces .optional() on input.
|
|
117
|
+
expect(apiContent).toMatch(/input:\s*z\.object\(\{\}\)\.default\(\{\}\)/);
|
|
118
|
+
// Health output: .default({ status: 'healthy', uptime: 0, domains: [] }).
|
|
119
|
+
expect(apiContent).toMatch(/\.default\(\{\s*status:\s*'healthy',\s*uptime:\s*0,\s*domains:\s*\[\]\s*\}\)/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects adding the same API twice', async () => {
|
|
123
|
+
await runAddApi({
|
|
124
|
+
domain: 'demo',
|
|
125
|
+
id: 'duplicate',
|
|
126
|
+
tenancy: 'required',
|
|
127
|
+
domainsDir: tmpDir,
|
|
128
|
+
});
|
|
129
|
+
await expect(
|
|
130
|
+
runAddApi({
|
|
131
|
+
domain: 'demo',
|
|
132
|
+
id: 'duplicate',
|
|
133
|
+
tenancy: 'required',
|
|
134
|
+
domainsDir: tmpDir,
|
|
135
|
+
}),
|
|
136
|
+
).rejects.toThrow(/already exists/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('rejects invalid kebab-case ids', async () => {
|
|
140
|
+
await expect(
|
|
141
|
+
runAddApi({
|
|
142
|
+
domain: 'demo',
|
|
143
|
+
id: 'BadId',
|
|
144
|
+
tenancy: 'required',
|
|
145
|
+
domainsDir: tmpDir,
|
|
146
|
+
}),
|
|
147
|
+
).rejects.toThrow(/Invalid API id/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('rejects non-existent domain', async () => {
|
|
151
|
+
await expect(
|
|
152
|
+
runAddApi({
|
|
153
|
+
domain: 'missing',
|
|
154
|
+
id: 'whatever',
|
|
155
|
+
tenancy: 'required',
|
|
156
|
+
domainsDir: tmpDir,
|
|
157
|
+
}),
|
|
158
|
+
).rejects.toThrow(/Domain "missing" not found/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import type { ChildProcess } from 'node:child_process';
|
|
7
|
+
import type { FastifyInstance } from 'fastify';
|
|
8
|
+
|
|
9
|
+
interface MockChildProcess extends EventEmitter, Pick<ChildProcess, 'kill' | 'killed'> {}
|
|
10
|
+
|
|
11
|
+
function makeMockChild(): MockChildProcess {
|
|
12
|
+
const emitter = new EventEmitter() as MockChildProcess;
|
|
13
|
+
emitter.killed = false;
|
|
14
|
+
emitter.kill = vi.fn((signal?: NodeJS.Signals): boolean => {
|
|
15
|
+
emitter.killed = true;
|
|
16
|
+
emitter.emit('exit', null, signal ?? 'SIGTERM');
|
|
17
|
+
return true;
|
|
18
|
+
});
|
|
19
|
+
return emitter;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeMockFastify() {
|
|
23
|
+
return { close: vi.fn().mockResolvedValue(undefined) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mocks = vi.hoisted(() => {
|
|
27
|
+
const child = makeMockChild();
|
|
28
|
+
const fastify = makeMockFastify();
|
|
29
|
+
return {
|
|
30
|
+
child,
|
|
31
|
+
fastify,
|
|
32
|
+
spawnFn: vi.fn().mockReturnValue(child as unknown as ChildProcess),
|
|
33
|
+
createApiServer: vi.fn().mockResolvedValue(fastify as unknown as FastifyInstance),
|
|
34
|
+
buildRegistry: vi.fn().mockResolvedValue({
|
|
35
|
+
registry: {
|
|
36
|
+
domain: { id: 'mock-domain' },
|
|
37
|
+
apis: [],
|
|
38
|
+
webhooks: [],
|
|
39
|
+
subscribers: [],
|
|
40
|
+
schedules: [],
|
|
41
|
+
jobs: [],
|
|
42
|
+
actions: [],
|
|
43
|
+
integrations: [],
|
|
44
|
+
events: [],
|
|
45
|
+
},
|
|
46
|
+
warnings: [],
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
vi.mock('../../server/api-server.js', () => ({ createApiServer: mocks.createApiServer }));
|
|
52
|
+
vi.mock('../../builder/build-registry.js', () => ({ buildRegistry: mocks.buildRegistry }));
|
|
53
|
+
|
|
54
|
+
import { startDev } from '../../commands/dev.js';
|
|
55
|
+
|
|
56
|
+
describe('dev command — startDev + graceful shutdown', () => {
|
|
57
|
+
let tmpDir: string;
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'tib-dev-'));
|
|
61
|
+
mocks.spawnFn.mockClear();
|
|
62
|
+
mocks.createApiServer.mockClear();
|
|
63
|
+
mocks.buildRegistry.mockClear();
|
|
64
|
+
mocks.fastify.close.mockClear();
|
|
65
|
+
(mocks.child.kill as ReturnType<typeof vi.fn>).mockClear();
|
|
66
|
+
mocks.child.killed = false;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(async () => {
|
|
70
|
+
try {
|
|
71
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
72
|
+
} catch {
|
|
73
|
+
// ignore
|
|
74
|
+
}
|
|
75
|
+
vi.restoreAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('starts Fastify on the requested port and skips Vite when no frontend/ dir exists', async () => {
|
|
79
|
+
// No frontend/ dir created
|
|
80
|
+
const handle = await startDev({
|
|
81
|
+
domainRoot: tmpDir,
|
|
82
|
+
projectRoot: tmpDir,
|
|
83
|
+
port: 3000,
|
|
84
|
+
frontendPort: 3001,
|
|
85
|
+
spawnFn: mocks.spawnFn as unknown as typeof import('node:child_process').spawn,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// createApiServer was called with port 3000
|
|
89
|
+
expect(mocks.createApiServer).toHaveBeenCalledTimes(1);
|
|
90
|
+
const callArgs = mocks.createApiServer.mock.calls[0]?.[0] as { port: number };
|
|
91
|
+
expect(callArgs.port).toBe(3000);
|
|
92
|
+
|
|
93
|
+
// Vite was NOT spawned (no frontend/ dir)
|
|
94
|
+
expect(mocks.spawnFn).not.toHaveBeenCalled();
|
|
95
|
+
expect(handle.vite).toBeNull();
|
|
96
|
+
expect(handle.server).toBe(mocks.fastify as unknown as FastifyInstance);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('spawns Vite on frontendPort when frontend/ directory exists', async () => {
|
|
100
|
+
await mkdir(join(tmpDir, 'frontend'), { recursive: true });
|
|
101
|
+
|
|
102
|
+
const handle = await startDev({
|
|
103
|
+
domainRoot: tmpDir,
|
|
104
|
+
projectRoot: tmpDir,
|
|
105
|
+
port: 3000,
|
|
106
|
+
frontendPort: 3001,
|
|
107
|
+
spawnFn: mocks.spawnFn as unknown as typeof import('node:child_process').spawn,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(mocks.spawnFn).toHaveBeenCalledTimes(1);
|
|
111
|
+
const [cmd, args, opts] = mocks.spawnFn.mock.calls[0] as [string, string[], { cwd: string }];
|
|
112
|
+
expect(cmd).toBe('npx');
|
|
113
|
+
expect(args).toContain('vite');
|
|
114
|
+
expect(args).toContain('3001');
|
|
115
|
+
expect(opts.cwd).toBe(join(tmpDir, 'frontend'));
|
|
116
|
+
expect(handle.vite).toBe(mocks.child as unknown as ChildProcess);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('shutdown() kills the Vite child and closes the Fastify server when both are running', async () => {
|
|
120
|
+
await mkdir(join(tmpDir, 'frontend'), { recursive: true });
|
|
121
|
+
|
|
122
|
+
const handle = await startDev({
|
|
123
|
+
domainRoot: tmpDir,
|
|
124
|
+
projectRoot: tmpDir,
|
|
125
|
+
spawnFn: mocks.spawnFn as unknown as typeof import('node:child_process').spawn,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await handle.shutdown();
|
|
129
|
+
|
|
130
|
+
expect(mocks.child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
131
|
+
expect(mocks.fastify.close).toHaveBeenCalledTimes(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('shutdown() only closes the server when no Vite was started (no error)', async () => {
|
|
135
|
+
const handle = await startDev({
|
|
136
|
+
domainRoot: tmpDir,
|
|
137
|
+
projectRoot: tmpDir,
|
|
138
|
+
spawnFn: mocks.spawnFn as unknown as typeof import('node:child_process').spawn,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(handle.vite).toBeNull();
|
|
142
|
+
await expect(handle.shutdown()).resolves.toBeUndefined();
|
|
143
|
+
expect(mocks.fastify.close).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(mocks.spawnFn).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('shutdown() is idempotent — calling it twice does not double-close or re-kill', async () => {
|
|
148
|
+
await mkdir(join(tmpDir, 'frontend'), { recursive: true });
|
|
149
|
+
|
|
150
|
+
const handle = await startDev({
|
|
151
|
+
domainRoot: tmpDir,
|
|
152
|
+
projectRoot: tmpDir,
|
|
153
|
+
spawnFn: mocks.spawnFn as unknown as typeof import('node:child_process').spawn,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await handle.shutdown();
|
|
157
|
+
await handle.shutdown();
|
|
158
|
+
|
|
159
|
+
expect(mocks.child.kill).toHaveBeenCalledTimes(1);
|
|
160
|
+
expect(mocks.fastify.close).toHaveBeenCalledTimes(1);
|
|
161
|
+
});
|
|
162
|
+
});
|