@mettlecast/domain-cli 0.2.22 → 0.2.23

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 (69) hide show
  1. package/dist/cli.js +60 -3
  2. package/dist/commands/add-api.d.ts +1 -1
  3. package/dist/commands/add-api.js +45 -17
  4. package/dist/commands/add-fixture-factory.d.ts +16 -0
  5. package/dist/commands/add-fixture-factory.js +60 -0
  6. package/dist/commands/add-module.js +4 -5
  7. package/dist/commands/add-seed-page.js +4 -5
  8. package/dist/commands/build-flows.js +1 -1
  9. package/dist/commands/dev.d.ts +30 -3
  10. package/dist/commands/dev.js +52 -11
  11. package/dist/commands/doctor.d.ts +22 -0
  12. package/dist/commands/doctor.js +341 -6
  13. package/dist/commands/generate-openapi.d.ts +21 -0
  14. package/dist/commands/generate-openapi.js +117 -0
  15. package/dist/commands/generate-sdk.d.ts +25 -0
  16. package/dist/commands/generate-sdk.js +98 -0
  17. package/dist/commands/init.d.ts +14 -0
  18. package/dist/commands/init.js +62 -0
  19. package/dist/commands/reseed-page.js +5 -6
  20. package/dist/commands/upgrade.d.ts +2 -0
  21. package/dist/commands/upgrade.js +28 -6
  22. package/dist/commands/why.d.ts +47 -0
  23. package/dist/commands/why.js +129 -0
  24. package/dist/templates/api-skeleton.d.ts +5 -1
  25. package/dist/templates/api-skeleton.js +28 -6
  26. package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
  27. package/dist/templates/patterns/api/create-with-event.js +14 -1
  28. package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
  29. package/dist/templates/patterns/api/idempotent-mutation.js +20 -0
  30. package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
  31. package/dist/templates/patterns/api/paginated-list.js +12 -2
  32. package/dist/templates/patterns/api/simple-crud.d.ts +5 -0
  33. package/dist/templates/patterns/api/simple-crud.js +22 -4
  34. package/dist/templates/patterns/api/streaming-list.d.ts +27 -0
  35. package/dist/templates/patterns/api/streaming-list.js +91 -0
  36. package/dist/templates/patterns/api/system-admin.d.ts +5 -0
  37. package/dist/templates/patterns/api/system-admin.js +14 -4
  38. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
  39. package/dist/templates/patterns/api/webhook-receiver-style.js +22 -0
  40. package/dist/utils/s3-fetch.js +23 -32
  41. package/package.json +4 -1
  42. package/src/__tests__/commands/add-api.test.ts +160 -0
  43. package/src/__tests__/commands/dev.test.ts +162 -0
  44. package/src/__tests__/commands/why.test.ts +199 -0
  45. package/src/__tests__/doctor.test.ts +336 -1
  46. package/src/__tests__/smoke/scaffold.test.ts +574 -0
  47. package/src/cli.ts +67 -5
  48. package/src/commands/add-api.ts +68 -19
  49. package/src/commands/add-fixture-factory.ts +75 -0
  50. package/src/commands/add-module.ts +4 -5
  51. package/src/commands/add-seed-page.ts +4 -5
  52. package/src/commands/build-flows.ts +2 -2
  53. package/src/commands/dev.ts +78 -11
  54. package/src/commands/doctor.ts +379 -12
  55. package/src/commands/generate-openapi.ts +154 -0
  56. package/src/commands/generate-sdk.ts +125 -0
  57. package/src/commands/init.ts +78 -0
  58. package/src/commands/reseed-page.ts +5 -6
  59. package/src/commands/upgrade.ts +26 -6
  60. package/src/commands/why.ts +171 -0
  61. package/src/templates/api-skeleton.ts +32 -6
  62. package/src/templates/patterns/api/create-with-event.ts +15 -1
  63. package/src/templates/patterns/api/idempotent-mutation.ts +21 -0
  64. package/src/templates/patterns/api/paginated-list.ts +13 -2
  65. package/src/templates/patterns/api/simple-crud.ts +23 -4
  66. package/src/templates/patterns/api/streaming-list.ts +91 -0
  67. package/src/templates/patterns/api/system-admin.ts +15 -4
  68. package/src/templates/patterns/api/webhook-receiver-style.ts +23 -0
  69. package/src/utils/s3-fetch.ts +26 -34
@@ -6,12 +6,34 @@
6
6
  import { writeFile, access, mkdir } from 'node:fs/promises';
7
7
  import { join, resolve } from 'node:path';
8
8
  import { apiSkeletonTemplate, apiFixtureSkeleton } from '../templates/api-skeleton.js';
9
- import { simpleCrudTemplate, simpleCrudListTemplate } from '../templates/patterns/api/simple-crud.js';
10
- import { paginatedListTemplate } from '../templates/patterns/api/paginated-list.js';
11
- import { createWithEventTemplate } from '../templates/patterns/api/create-with-event.js';
12
- import { idempotentMutationTemplate } from '../templates/patterns/api/idempotent-mutation.js';
13
- import { webhookReceiverStyleTemplate } from '../templates/patterns/api/webhook-receiver-style.js';
14
- import { systemAdminTemplate } from '../templates/patterns/api/system-admin.js';
9
+ import {
10
+ simpleCrudTemplate,
11
+ simpleCrudListTemplate,
12
+ simpleCrudExampleBody,
13
+ } from '../templates/patterns/api/simple-crud.js';
14
+ import {
15
+ paginatedListTemplate,
16
+ paginatedListExampleBody,
17
+ } from '../templates/patterns/api/paginated-list.js';
18
+ import {
19
+ createWithEventTemplate,
20
+ createWithEventExampleBody,
21
+ } from '../templates/patterns/api/create-with-event.js';
22
+ import {
23
+ idempotentMutationTemplate,
24
+ idempotentMutationExampleBody,
25
+ } from '../templates/patterns/api/idempotent-mutation.js';
26
+ import {
27
+ webhookReceiverStyleTemplate,
28
+ webhookReceiverStyleExampleBody,
29
+ } from '../templates/patterns/api/webhook-receiver-style.js';
30
+ import {
31
+ systemAdminTemplate,
32
+ systemAdminExampleBody,
33
+ } from '../templates/patterns/api/system-admin.js';
34
+ import {
35
+ streamingListPattern,
36
+ } from '../templates/patterns/api/streaming-list.js';
15
37
  import { cliLogger } from '../utils/logger.js';
16
38
 
17
39
  /**
@@ -24,6 +46,7 @@ export const API_PATTERNS = [
24
46
  'idempotent-mutation',
25
47
  'webhook-receiver-style',
26
48
  'system-admin',
49
+ 'streaming-list',
27
50
  ] as const;
28
51
 
29
52
  export type ApiPattern = (typeof API_PATTERNS)[number];
@@ -87,8 +110,11 @@ export async function runAddApi(opts: AddApiOptions): Promise<void> {
87
110
 
88
111
  // Resolve template content
89
112
  let apiContent: string;
113
+ let exampleBody: string = '{}';
90
114
  if (opts.pattern) {
91
- apiContent = resolveApiPattern(opts.pattern, opts.domain, opts.id, opts.tenancy);
115
+ const resolved = resolveApiPattern(opts.pattern, opts.domain, opts.id, opts.tenancy);
116
+ apiContent = resolved.apiContent;
117
+ exampleBody = resolved.exampleBody;
92
118
  cliLogger.info({ pattern: opts.pattern }, 'Using pattern template');
93
119
  } else {
94
120
  apiContent = apiSkeletonTemplate(opts.domain, opts.id, opts.tenancy);
@@ -97,12 +123,12 @@ export async function runAddApi(opts: AddApiOptions): Promise<void> {
97
123
  // Write API file
98
124
  await writeFile(apiFilePath, apiContent);
99
125
 
100
- // Create fixture
126
+ // Create fixture with the input schema's default shape as the example payload
101
127
  const apiTestDir = join(domainDir, 'api', '__tests__');
102
128
  await mkdir(apiTestDir, { recursive: true });
103
129
  await writeFile(
104
130
  join(apiTestDir, `${opts.id}.fixture.json`),
105
- apiFixtureSkeleton(opts.domain, opts.id)
131
+ apiFixtureSkeleton(opts.domain, opts.id, exampleBody)
106
132
  );
107
133
 
108
134
  cliLogger.info({ domain: opts.domain, api: opts.id, method: opts.method ?? 'GET', pattern: opts.pattern ?? 'skeleton' }, 'API added');
@@ -123,22 +149,45 @@ function resolveApiPattern(
123
149
  domain: string,
124
150
  id: string,
125
151
  tenancy: string,
126
- ): string {
152
+ ): { apiContent: string; exampleBody: string } {
127
153
  switch (pattern) {
128
154
  case 'simple-crud':
129
- return simpleCrudTemplate(domain, id, tenancy) +
130
- '\n' +
131
- simpleCrudListTemplate(domain, id, tenancy);
155
+ return {
156
+ apiContent: simpleCrudTemplate(domain, id, tenancy) +
157
+ '\n' +
158
+ simpleCrudListTemplate(domain, id, tenancy),
159
+ exampleBody: simpleCrudExampleBody,
160
+ };
132
161
  case 'paginated-list':
133
- return paginatedListTemplate(domain, id, tenancy);
162
+ return {
163
+ apiContent: paginatedListTemplate(domain, id, tenancy),
164
+ exampleBody: paginatedListExampleBody,
165
+ };
134
166
  case 'create-with-event':
135
- return createWithEventTemplate(domain, id, tenancy);
167
+ return {
168
+ apiContent: createWithEventTemplate(domain, id, tenancy),
169
+ exampleBody: createWithEventExampleBody,
170
+ };
136
171
  case 'idempotent-mutation':
137
- return idempotentMutationTemplate(domain, id, tenancy);
172
+ return {
173
+ apiContent: idempotentMutationTemplate(domain, id, tenancy),
174
+ exampleBody: idempotentMutationExampleBody,
175
+ };
138
176
  case 'webhook-receiver-style':
139
- return webhookReceiverStyleTemplate(domain, id, tenancy);
140
- case 'system-admin':
141
- return systemAdminTemplate(domain, id, tenancy);
177
+ return {
178
+ apiContent: webhookReceiverStyleTemplate(domain, id, tenancy),
179
+ exampleBody: webhookReceiverStyleExampleBody,
180
+ };
181
+ case 'system-admin':
182
+ return {
183
+ apiContent: systemAdminTemplate(domain, id, tenancy),
184
+ exampleBody: systemAdminExampleBody,
185
+ };
186
+ case 'streaming-list':
187
+ return {
188
+ apiContent: streamingListPattern(domain, id, 'item'),
189
+ exampleBody: '{}',
190
+ };
142
191
  default:
143
192
  throw new Error(`Unknown API pattern "${pattern}". Available: ${API_PATTERNS.join(', ')}`);
144
193
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * add-fixture-factory — generate a test data factory alongside an
3
+ * existing API handler. The factory provides `valid()` and `invalid()`
4
+ * functions with realistic test data powered by @faker-js/faker.
5
+ *
6
+ * Usage: npx mc-domain-module add-fixture-factory <domain> <api-id>
7
+ */
8
+
9
+ import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { cliLogger } from '../utils/logger.js';
12
+
13
+ export interface AddFixtureFactoryOptions {
14
+ domain: string;
15
+ apiId: string;
16
+ projectRoot?: string;
17
+ }
18
+
19
+ const FACTORY_TEMPLATE = `// Generated fixture factory for {domain}.{apiId}
20
+ // Uses @faker-js/faker for realistic test data. Override any field
21
+ // by passing overrides to ` + '`valid()`' + `.
22
+ import { faker } from '@faker-js/faker';
23
+
24
+ /** The input shape for {domain}.{apiId} — derive from the Zod schema. */
25
+ type Input = Record<string, unknown>;
26
+
27
+ /** Full, valid input with sensible defaults. */
28
+ export function valid(overrides: Partial<Input> = {}): Input {
29
+ return {
30
+ name: faker.company.name(),
31
+ email: faker.internet.email(),
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ /** Intentionally invalid input — use to test validation failures. */
37
+ export function invalid(): Partial<Input> {
38
+ return {
39
+ name: '', // empty — should fail minLength if required
40
+ email: 'not-an-email',
41
+ };
42
+ }
43
+ `;
44
+
45
+ export async function runAddFixtureFactory(options: AddFixtureFactoryOptions): Promise<string> {
46
+ const projectRoot = options.projectRoot ?? process.cwd();
47
+ const domain = options.domain;
48
+ const apiId = options.apiId;
49
+
50
+ const apiFile = join(projectRoot, 'domains', domain, 'api', `${apiId}.ts`);
51
+ await access(apiFile).catch(() => {
52
+ throw new Error(`API file not found: ${apiFile}. Run add-api first.`);
53
+ });
54
+
55
+ // Read the API file to extract the input type name
56
+ const content = await readFile(apiFile, 'utf8');
57
+ const outputTypeArg = content.match(/import\s+\{\s*[\w\s,]*\s*\}\s+from\s+['"]@mettlecast/);
58
+
59
+ const factoryFile = join(projectRoot, 'domains', domain, 'api', '__tests__', `${apiId}.factory.ts`);
60
+ const factoryContent = FACTORY_TEMPLATE
61
+ .replace(/\{domain\}/g, domain)
62
+ .replace(/\{apiId\}/g, apiId);
63
+
64
+ await mkdir(join(projectRoot, 'domains', domain, 'api', '__tests__'), { recursive: true });
65
+ await writeFile(factoryFile, factoryContent, 'utf8');
66
+
67
+ cliLogger.info({ factoryFile }, 'add-fixture-factory: factory written');
68
+ return factoryFile;
69
+ }
70
+
71
+ export async function runAddFixtureFactoryCli(domain: string, apiId: string, opts: { projectRoot?: string } = {}): Promise<void> {
72
+ const factoryFile = await runAddFixtureFactory({ domain, apiId, ...opts });
73
+ // eslint-disable-next-line no-console
74
+ console.log(`Fixture factory written to: ${factoryFile}`);
75
+ }
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { Readable } from 'node:stream';
5
5
  import { Parser as TarParser } from 'tar';
6
+ import ky from 'ky';
6
7
  import { readScaffoldConfig, writeScaffoldConfig } from '../utils/scaffold-config.js';
7
8
  import { readManifest, writeManifest, upsertManifestFile, inferPolicyFromPath } from '../utils/manifest.js';
8
9
  import { fetchModulesJson, fetchModuleTarball } from '../utils/s3-fetch.js';
@@ -99,18 +100,16 @@ async function createGitHubPR(
99
100
  token: string
100
101
  ): Promise<{ prUrl: string; prNumber: number } | null> {
101
102
  try {
102
- const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, {
103
- method: 'POST',
103
+ const response = await ky.post(`https://api.github.com/repos/${owner}/${repo}/pulls`, {
104
104
  headers: {
105
105
  Authorization: `Bearer ${token}`,
106
- 'Content-Type': 'application/json',
107
106
  },
108
- body: JSON.stringify({
107
+ json: {
109
108
  title,
110
109
  body,
111
110
  head: branchName,
112
111
  base: 'develop',
113
- }),
112
+ },
114
113
  });
115
114
 
116
115
  if (!response.ok) {
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process';
4
4
  import { createGunzip } from 'node:zlib';
5
5
  import { Readable } from 'node:stream';
6
6
  import { extract } from 'tar';
7
+ import ky from 'ky';
7
8
  import { cliLogger } from '../utils/logger.js';
8
9
  import {
9
10
  fetchVersionsJson,
@@ -58,20 +59,18 @@ async function createGitHubPR(
58
59
  body: string
59
60
  ): Promise<{ url: string; number: number }> {
60
61
  const url = `https://api.github.com/repos/${owner}/${repo}/pulls`;
61
- const res = await fetch(url, {
62
- method: 'POST',
62
+ const res = await ky.post(url, {
63
63
  headers: {
64
64
  Authorization: `Bearer ${token}`,
65
65
  Accept: 'application/vnd.github+json',
66
- 'Content-Type': 'application/json',
67
66
  'X-GitHub-Api-Version': '2022-11-28',
68
67
  },
69
- body: JSON.stringify({
68
+ json: {
70
69
  title,
71
70
  body,
72
71
  head: branch,
73
72
  base: 'develop',
74
- }),
73
+ },
75
74
  });
76
75
 
77
76
  if (!res.ok) {
@@ -70,12 +70,12 @@ const FlowStepSchema = z.union([
70
70
  const FlowConfigSchema = z.object({
71
71
  id: z.string().regex(/^[a-z][a-z0-9-]*$/),
72
72
  owningDomain: z.string().regex(/^[a-z][a-z0-9-]*$/).optional(),
73
- type: z.enum(['express', 'standard']).optional(),
73
+ type: z.enum(['express', 'standard'] as const).optional(),
74
74
  name: z.string().min(1),
75
75
  steps: z.array(FlowStepSchema).min(1),
76
76
  trigger: z.object({
77
77
  type: z.literal('event'),
78
- eventId: z.string().includes('.'),
78
+ eventId: z.string().refine(val => val.includes('.'), { message: 'eventId must be namespaced (e.g. domain.event)' }),
79
79
  semverRange: z.string().min(1),
80
80
  }).optional(),
81
81
  });
@@ -1,4 +1,7 @@
1
- import { resolve } from 'node:path';
1
+ import { resolve, join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { spawn, type ChildProcess } from 'node:child_process';
4
+ import type { FastifyInstance } from 'fastify';
2
5
  import { buildRegistry } from '../builder/build-registry.js';
3
6
  import { createApiServer } from '../server/api-server.js';
4
7
  import { cliLogger } from '../utils/logger.js';
@@ -11,16 +14,38 @@ export interface DevOptions {
11
14
  domainRoot: string;
12
15
  /** Port for the local HTTP server. Defaults to 3000. */
13
16
  port?: number;
17
+ /** Absolute path to the project root. Defaults to process.cwd(). */
18
+ projectRoot?: string;
19
+ /** Port for the Vite frontend dev server. Defaults to 3001. */
20
+ frontendPort?: number;
21
+ /** Override the spawn function (used for tests). Defaults to node:child_process.spawn. */
22
+ spawnFn?: typeof spawn;
14
23
  }
15
24
 
16
25
  /**
17
- * Execute the dev command: build the domain registry and start the local Fastify
18
- * HTTP server. Keeps the process running. Shuts down gracefully on SIGINT/SIGTERM.
19
- * @param options - DevOptions specifying domain root and optional port.
26
+ * Handle returned by startDev, exposing the live processes and a shutdown function.
20
27
  */
21
- export async function runDev(options: DevOptions): Promise<void> {
28
+ export interface DevHandle {
29
+ /** The Fastify API server instance. */
30
+ server: FastifyInstance;
31
+ /** The Vite child process, or null when no frontend/ directory was found. */
32
+ vite: ChildProcess | null;
33
+ /** Shut down both processes gracefully. Idempotent. */
34
+ shutdown: () => Promise<void>;
35
+ }
36
+
37
+ /**
38
+ * Start the dev servers (Fastify API + Vite frontend if present) and return a handle.
39
+ * This is the testable entry point — the signal-handling logic lives in runDev.
40
+ * @param options - DevOptions specifying domain root, ports, and project root.
41
+ * @returns A DevHandle with the running server, vite child (or null), and shutdown.
42
+ */
43
+ export async function startDev(options: DevOptions): Promise<DevHandle> {
44
+ const projectRoot = options.projectRoot ?? process.cwd();
22
45
  const domainRoot = resolve(options.domainRoot);
23
46
  const port = options.port ?? 3000;
47
+ const frontendPort = options.frontendPort ?? 3001;
48
+ const spawnFn = options.spawnFn ?? spawn;
24
49
 
25
50
  cliLogger.info({ domainRoot }, 'Building registry for dev server');
26
51
  const { registry, warnings } = await buildRegistry(domainRoot);
@@ -36,12 +61,54 @@ export async function runDev(options: DevOptions): Promise<void> {
36
61
 
37
62
  const server = await createApiServer({ port, registry, domainRoot });
38
63
 
39
- async function shutdown(): Promise<void> {
40
- cliLogger.info('Shutting down dev server');
41
- await server.close();
42
- process.exit(0);
64
+ // Spawn Vite if frontend/ exists; skip with warning otherwise.
65
+ const frontendDir = join(projectRoot, 'frontend');
66
+ let vite: ChildProcess | null = null;
67
+ if (existsSync(frontendDir)) {
68
+ cliLogger.info(
69
+ { frontendDir, port: frontendPort },
70
+ 'Starting Vite frontend dev server'
71
+ );
72
+ vite = spawnFn('npx', ['vite', '--port', String(frontendPort), '--strictPort'], {
73
+ cwd: frontendDir,
74
+ stdio: 'inherit',
75
+ });
76
+ vite.on('error', (err: Error) => {
77
+ cliLogger.warn({ err: err.message }, 'Vite dev server failed to start');
78
+ });
79
+ } else {
80
+ cliLogger.warn(
81
+ { frontendDir },
82
+ 'No frontend/ directory found — skipping Vite dev server'
83
+ );
43
84
  }
44
85
 
45
- process.on('SIGINT', () => { void shutdown(); });
46
- process.on('SIGTERM', () => { void shutdown(); });
86
+ let shuttingDown = false;
87
+ const shutdown = async (): Promise<void> => {
88
+ if (shuttingDown) return;
89
+ shuttingDown = true;
90
+ cliLogger.info('Shutting down dev servers');
91
+ if (vite && !vite.killed) {
92
+ vite.kill('SIGTERM');
93
+ }
94
+ await server.close();
95
+ };
96
+
97
+ return { server, vite, shutdown };
98
+ }
99
+
100
+ /**
101
+ * Execute the dev command: build the domain registry, start the local Fastify
102
+ * HTTP server, and (if frontend/ exists) start a Vite dev server alongside it.
103
+ * Keeps the process running. Shuts down both children gracefully on SIGINT/SIGTERM.
104
+ * @param options - DevOptions specifying domain root, ports, and project root.
105
+ */
106
+ export async function runDev(options: DevOptions): Promise<void> {
107
+ const handle = await startDev(options);
108
+
109
+ const onSignal = (): void => {
110
+ void handle.shutdown().then(() => process.exit(0));
111
+ };
112
+ process.on('SIGINT', onSignal);
113
+ process.on('SIGTERM', onSignal);
47
114
  }