@mettlecast/domain-cli 0.2.21 → 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 (71) hide show
  1. package/dist/builder/load-module.js +5 -1
  2. package/dist/cli.js +60 -3
  3. package/dist/commands/add-api.d.ts +1 -1
  4. package/dist/commands/add-api.js +45 -17
  5. package/dist/commands/add-fixture-factory.d.ts +16 -0
  6. package/dist/commands/add-fixture-factory.js +60 -0
  7. package/dist/commands/add-module.js +4 -5
  8. package/dist/commands/add-seed-page.js +4 -5
  9. package/dist/commands/build-flows.js +1 -1
  10. package/dist/commands/dev.d.ts +30 -3
  11. package/dist/commands/dev.js +52 -11
  12. package/dist/commands/doctor.d.ts +22 -0
  13. package/dist/commands/doctor.js +341 -6
  14. package/dist/commands/generate-openapi.d.ts +21 -0
  15. package/dist/commands/generate-openapi.js +117 -0
  16. package/dist/commands/generate-sdk.d.ts +25 -0
  17. package/dist/commands/generate-sdk.js +98 -0
  18. package/dist/commands/init.d.ts +14 -0
  19. package/dist/commands/init.js +62 -0
  20. package/dist/commands/reseed-page.js +5 -6
  21. package/dist/commands/upgrade.d.ts +2 -0
  22. package/dist/commands/upgrade.js +28 -6
  23. package/dist/commands/why.d.ts +47 -0
  24. package/dist/commands/why.js +129 -0
  25. package/dist/templates/api-skeleton.d.ts +5 -1
  26. package/dist/templates/api-skeleton.js +28 -6
  27. package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
  28. package/dist/templates/patterns/api/create-with-event.js +14 -1
  29. package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
  30. package/dist/templates/patterns/api/idempotent-mutation.js +20 -0
  31. package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
  32. package/dist/templates/patterns/api/paginated-list.js +12 -2
  33. package/dist/templates/patterns/api/simple-crud.d.ts +5 -0
  34. package/dist/templates/patterns/api/simple-crud.js +22 -4
  35. package/dist/templates/patterns/api/streaming-list.d.ts +27 -0
  36. package/dist/templates/patterns/api/streaming-list.js +91 -0
  37. package/dist/templates/patterns/api/system-admin.d.ts +5 -0
  38. package/dist/templates/patterns/api/system-admin.js +14 -4
  39. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
  40. package/dist/templates/patterns/api/webhook-receiver-style.js +22 -0
  41. package/dist/utils/s3-fetch.js +23 -32
  42. package/package.json +4 -1
  43. package/src/__tests__/commands/add-api.test.ts +160 -0
  44. package/src/__tests__/commands/dev.test.ts +162 -0
  45. package/src/__tests__/commands/why.test.ts +199 -0
  46. package/src/__tests__/doctor.test.ts +336 -1
  47. package/src/__tests__/smoke/scaffold.test.ts +574 -0
  48. package/src/builder/load-module.ts +5 -1
  49. package/src/cli.ts +67 -5
  50. package/src/commands/add-api.ts +68 -19
  51. package/src/commands/add-fixture-factory.ts +75 -0
  52. package/src/commands/add-module.ts +4 -5
  53. package/src/commands/add-seed-page.ts +4 -5
  54. package/src/commands/build-flows.ts +2 -2
  55. package/src/commands/dev.ts +78 -11
  56. package/src/commands/doctor.ts +379 -12
  57. package/src/commands/generate-openapi.ts +154 -0
  58. package/src/commands/generate-sdk.ts +125 -0
  59. package/src/commands/init.ts +78 -0
  60. package/src/commands/reseed-page.ts +5 -6
  61. package/src/commands/upgrade.ts +26 -6
  62. package/src/commands/why.ts +171 -0
  63. package/src/templates/api-skeleton.ts +32 -6
  64. package/src/templates/patterns/api/create-with-event.ts +15 -1
  65. package/src/templates/patterns/api/idempotent-mutation.ts +21 -0
  66. package/src/templates/patterns/api/paginated-list.ts +13 -2
  67. package/src/templates/patterns/api/simple-crud.ts +23 -4
  68. package/src/templates/patterns/api/streaming-list.ts +91 -0
  69. package/src/templates/patterns/api/system-admin.ts +15 -4
  70. package/src/templates/patterns/api/webhook-receiver-style.ts +23 -0
  71. package/src/utils/s3-fetch.ts +26 -34
package/src/cli.ts CHANGED
@@ -31,6 +31,11 @@ import { runReseedPage } from './commands/reseed-page.js';
31
31
  import { runAddSeedPage } from './commands/add-seed-page.js';
32
32
  import { runShowDns } from './commands/show-dns.js';
33
33
  import { runBuildUi } from './commands/build-ui.js';
34
+ import { runWhyCli } from './commands/why.js';
35
+ import { runGenerateOpenapiCli } from './commands/generate-openapi.js';
36
+ import { runGenerateSdkCli } from './commands/generate-sdk.js';
37
+ import { runAddFixtureFactoryCli } from './commands/add-fixture-factory.js';
38
+ import { runInitCli } from './commands/init.js';
34
39
 
35
40
  /**
36
41
  * Root CLI program for the TIB Domain Module local developer toolchain.
@@ -152,12 +157,19 @@ program
152
157
  .description('Run all structural checks for the current TIB project (strict by default — any FAIL causes exit code 1)')
153
158
  .option('--strict', 'DEPRECATED: Doctor is always strict now. This flag is a no-op.', false)
154
159
  .option('--json', 'Output JSON instead of pretty table', false)
155
- .action(async (opts: { strict: boolean; json: boolean }) => {
160
+ .option('--fix', 'Attempt to automatically fix issues that have a safe automatic fix', false)
161
+ .action(async (opts: { strict: boolean; json: boolean; fix: boolean }) => {
156
162
  if (opts.strict) {
157
163
  // eslint-disable-next-line no-console
158
164
  console.warn('⚠ --strict is deprecated: doctor is always strict now. This flag is a no-op.');
159
165
  }
160
- const report = await runDoctor({ projectRoot: process.cwd() });
166
+ // `fix` is part of DoctorOptions in Wave 1 (Task doctor-fix). Cast through
167
+ // `unknown` so this file type-checks today and after the doctor-fix worker
168
+ // adds the `fix?: boolean` field to DoctorOptions.
169
+ const report = await runDoctor({
170
+ projectRoot: process.cwd(),
171
+ fix: opts.fix,
172
+ } as unknown as Parameters<typeof runDoctor>[0]);
161
173
  if (opts.json) {
162
174
  // eslint-disable-next-line no-console
163
175
  console.log(JSON.stringify(report, null, 2));
@@ -284,13 +296,14 @@ program
284
296
 
285
297
  program
286
298
  .command('upgrade [version]')
287
- .description('Upgrade scaffold to a new version — diffs S3 tarballs, raises a GitHub PR with exact file changes')
299
+ .description('Upgrade, check for drift, or dry-run scaffold to a new version — diffs S3 tarballs and (when not --check or --dry-run) raises a GitHub PR with exact file changes')
288
300
  .option('--dry-run', 'Preview changes without writing files or creating PR', false)
301
+ .option('--check', 'Read-only CI gate: compute upgrade diff, exit 0 for no drift or 1 if drift detected. Writes nothing.', false)
289
302
  .option('--project-dir <path>', 'Path to the project to upgrade')
290
303
  .option('--github-token <token>', 'GitHub token for PR creation (overrides GITHUB_TOKEN env var)')
291
304
  .option('--frontend-components', 'Scope upgrade to scaffold-tracked frontend files only (opens a PR with Chromatic visual diff)', false)
292
- .action(async (version: string | undefined, opts: { dryRun: boolean; projectDir?: string; githubToken?: string; frontendComponents: boolean }) => {
293
- await runUpgrade(version, { dryRun: opts.dryRun, projectDir: opts.projectDir ?? process.cwd(), githubToken: opts.githubToken, frontendComponents: opts.frontendComponents });
305
+ .action(async (version: string | undefined, opts: { dryRun: boolean; check: boolean; projectDir?: string; githubToken?: string; frontendComponents: boolean }) => {
306
+ await runUpgrade(version, { dryRun: opts.dryRun, check: opts.check, projectDir: opts.projectDir ?? process.cwd(), githubToken: opts.githubToken, frontendComponents: opts.frontendComponents });
294
307
  });
295
308
 
296
309
  // ── New W4 commands ───────────────────────────────────────────────
@@ -389,6 +402,55 @@ program
389
402
  await runBuildUi({ projectRoot: opts.projectRoot });
390
403
  });
391
404
 
405
+ program
406
+ .command('why')
407
+ .description('Print current scaffold version, enabled modules, and pending upgrade diff to stdout')
408
+ .option('--project-root <path>', 'Path to the project root (defaults to cwd)')
409
+ .option('--scaffold-bucket <name>', 'S3 bucket to fetch versions.json from (defaults to mc-scaffold)')
410
+ .option('--json', 'Output JSON instead of a human-readable report', false)
411
+ .action(async (opts: { projectRoot?: string; scaffoldBucket?: string; json: boolean }) => {
412
+ await runWhyCli({
413
+ projectRoot: opts.projectRoot,
414
+ scaffoldBucket: opts.scaffoldBucket,
415
+ json: opts.json,
416
+ });
417
+ });
418
+
419
+ program
420
+ .command('generate-openapi <domain>')
421
+ .description('Generate an OpenAPI 3.1 spec from a domain registry (.mc/<domain>-registry.json)')
422
+ .option('--project-root <path>', 'Path to the project root (defaults to cwd)')
423
+ .option('--output <path>', 'Override the output file path')
424
+ .action(async (domain: string, opts: { projectRoot?: string; output?: string }) => {
425
+ await runGenerateOpenapiCli(domain, { projectRoot: opts.projectRoot, output: opts.output });
426
+ });
427
+
428
+ program
429
+ .command('generate-sdk <domain>')
430
+ .description('Generate a typed TypeScript API client from a domain\'s OpenAPI spec, returning Result<T, AppError> from every call')
431
+ .option('--project-root <path>', 'Path to the project root (defaults to cwd)')
432
+ .option('--input <path>', 'Path to the OpenAPI spec (defaults to domains/<domain>/api/openapi.generated.json)')
433
+ .option('--output <path>', 'Override the output directory')
434
+ .action(async (domain: string, opts: { projectRoot?: string; input?: string; output?: string }) => {
435
+ await runGenerateSdkCli(domain, { projectRoot: opts.projectRoot, input: opts.input, output: opts.output });
436
+ });
437
+
438
+ program
439
+ .command('add-fixture-factory <domain> <api-id>')
440
+ .description('Generate a test data factory with @faker-js/faker for an existing API handler')
441
+ .option('--project-root <path>', 'Path to the project root (defaults to cwd)')
442
+ .action(async (domain: string, apiId: string, opts: { projectRoot?: string }) => {
443
+ await runAddFixtureFactoryCli(domain, apiId, { projectRoot: opts.projectRoot });
444
+ });
445
+
446
+ program
447
+ .command('init')
448
+ .description('Bootstrap a freshly scaffolded project: npm install, build CLI, run doctor baseline')
449
+ .option('--project-root <path>', 'Path to the project root (defaults to cwd)')
450
+ .action(async (opts: { projectRoot?: string }) => {
451
+ await runInitCli({ projectRoot: opts.projectRoot });
452
+ });
453
+
392
454
  program.parseAsync(process.argv).catch((err: unknown) => {
393
455
  // eslint-disable-next-line no-console
394
456
  console.error(err);
@@ -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
  }