@mettlecast/domain-cli 0.2.0 → 0.2.1

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/dist/builder/build-registry.js +3 -0
  2. package/dist/builder/load-module.js +15 -2
  3. package/dist/commands/create-project.js +1 -1
  4. package/dist/commands/doctor.js +10 -10
  5. package/dist/commands/upgrade-frontend.js +0 -2
  6. package/dist/commands/upgrade.js +4 -0
  7. package/dist/templates/patterns/api/create-with-event.js +3 -7
  8. package/dist/templates/patterns/api/idempotent-mutation.js +17 -40
  9. package/dist/templates/patterns/api/paginated-list.js +1 -9
  10. package/dist/templates/patterns/api/simple-crud.js +2 -2
  11. package/dist/templates/patterns/api/system-admin.js +1 -4
  12. package/dist/templates/patterns/api/webhook-receiver-style.js +7 -53
  13. package/dist/templates/patterns/subscriber/audit-relay.js +14 -24
  14. package/dist/templates/patterns/subscriber/cascade-deletion.js +6 -22
  15. package/dist/templates/patterns/subscriber/single-step-projection.js +6 -10
  16. package/dist/templates/subscriber-skeleton.js +1 -1
  17. package/dist/utils/install-file.d.ts +5 -6
  18. package/dist/utils/install-file.js +9 -25
  19. package/dist/utils/manifest.d.ts +2 -2
  20. package/dist/utils/manifest.js +47 -5
  21. package/dist/utils/scaffold-config.d.ts +7 -0
  22. package/dist/utils/scaffold-config.js +3 -0
  23. package/package.json +1 -1
  24. package/src/__tests__/commands/upgrade.test.ts +8 -8
  25. package/src/__tests__/doctor.test.ts +16 -11
  26. package/src/__tests__/scaffold-src/part-a-layout.test.ts +0 -8
  27. package/src/__tests__/scripts/package-scaffold.test.ts +14 -14
  28. package/src/__tests__/utils/install-file.test.ts +58 -35
  29. package/src/__tests__/utils/manifest.test.ts +74 -29
  30. package/src/builder/build-registry.ts +2 -0
  31. package/src/builder/load-module.ts +16 -2
  32. package/src/commands/add-module.ts +1 -1
  33. package/src/commands/create-project.ts +2 -2
  34. package/src/commands/doctor.ts +11 -11
  35. package/src/commands/upgrade-frontend.ts +0 -2
  36. package/src/commands/upgrade.ts +7 -2
  37. package/src/templates/dashboard-pages/account/api-keys.tsx +1 -1
  38. package/src/templates/dashboard-pages/account/audit-log.tsx +1 -1
  39. package/src/templates/dashboard-pages/account/members.tsx +1 -1
  40. package/src/templates/dashboard-pages/account/profile.tsx +1 -1
  41. package/src/templates/dashboard-pages/account/workspace-settings.tsx +1 -1
  42. package/src/templates/dashboard-pages/auth/accept-invitation.tsx +1 -1
  43. package/src/templates/dashboard-pages/auth/choose-org.tsx +1 -1
  44. package/src/templates/dashboard-pages/auth/forgot-password.tsx +1 -1
  45. package/src/templates/dashboard-pages/auth/login.tsx +1 -1
  46. package/src/templates/dashboard-pages/auth/mfa-setup.tsx +1 -1
  47. package/src/templates/dashboard-pages/auth/mfa-verify.tsx +1 -1
  48. package/src/templates/dashboard-pages/auth/reset-password.tsx +1 -1
  49. package/src/templates/dashboard-pages/auth/signup.tsx +1 -1
  50. package/src/templates/dashboard-pages/auth/verify-email.tsx +1 -1
  51. package/src/templates/patterns/api/create-with-event.ts +3 -7
  52. package/src/templates/patterns/api/idempotent-mutation.ts +17 -40
  53. package/src/templates/patterns/api/paginated-list.ts +1 -9
  54. package/src/templates/patterns/api/simple-crud.ts +2 -2
  55. package/src/templates/patterns/api/system-admin.ts +1 -4
  56. package/src/templates/patterns/api/webhook-receiver-style.ts +7 -53
  57. package/src/templates/patterns/subscriber/audit-relay.ts +14 -24
  58. package/src/templates/patterns/subscriber/cascade-deletion.ts +6 -22
  59. package/src/templates/patterns/subscriber/single-step-projection.ts +6 -10
  60. package/src/templates/subscriber-skeleton.ts +1 -1
  61. package/src/utils/install-file.ts +11 -29
  62. package/src/utils/manifest.ts +51 -6
  63. package/src/utils/scaffold-config.ts +11 -0
@@ -44,7 +44,7 @@ export const ${varName} = defineApi({
44
44
  output: ${varName}Output,
45
45
  handler: async (input, ctx) => {
46
46
  // Create / Read / Update / Delete based on method
47
- await ctx.auditLog({ action: '${id}.v1', tenantId: ctx.tenantId, input });
47
+ await ctx.audit.log('${id}.v1', ctx.tenant.id, { input });
48
48
  return {
49
49
  id: input.id ?? crypto.randomUUID(),
50
50
  createdAt: new Date().toISOString(),
@@ -90,7 +90,7 @@ export const ${varName}List = defineApi({
90
90
  input: ${varName}ListInput,
91
91
  output: ${varName}ListOutput,
92
92
  handler: async (input, ctx) => {
93
- await ctx.auditLog({ action: '${id}-list.v1', tenantId: ctx.tenantId });
93
+ await ctx.audit.log('${id}-list.v1', ctx.tenant.id);
94
94
  return { items: [], nextCursor: undefined };
95
95
  },
96
96
  },
@@ -48,10 +48,7 @@ export const ${varName} = defineApi({
48
48
  input: ${varName}Input,
49
49
  output: ${varName}Output,
50
50
  handler: async (input, ctx) => {
51
- await ctx.auditLog({
52
- action: '${id}.system',
53
- meta: { command: input.command },
54
- });
51
+ await ctx.audit.log('${id}.system', 'system', { command: input.command });
55
52
 
56
53
  switch (input.command) {
57
54
  case 'status':
@@ -12,7 +12,6 @@ export function webhookReceiverStyleTemplate(domain: string, id: string, tenancy
12
12
 
13
13
  return `import { z } from 'zod';
14
14
  import { defineApi } from '@mettlecast/domain-runtime';
15
- import crypto from 'crypto';
16
15
 
17
16
  // ── Supported webhook providers ───────────────────────────────
18
17
 
@@ -38,40 +37,6 @@ const ${varName}Output = z.object({
38
37
  provider: ProviderEnum,
39
38
  });
40
39
 
41
- // ── Signature verification ────────────────────────────────────
42
-
43
- function verifySignature(
44
- provider: z.infer<typeof ProviderEnum>,
45
- payload: string,
46
- signatureHeader: string,
47
- secret: string,
48
- ): boolean {
49
- switch (provider) {
50
- case 'stripe':
51
- // Stripe uses HMAC-SHA256
52
- return crypto.timingSafeEqual(
53
- Buffer.from(signatureHeader),
54
- crypto.createHmac('sha256', secret).update(payload).digest(),
55
- );
56
- case 'github':
57
- // GitHub uses HMAC-SHA256 with 'sha256=' prefix
58
- const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
59
- return crypto.timingSafeEqual(
60
- Buffer.from(signatureHeader),
61
- Buffer.from(expected),
62
- );
63
- case 'slack':
64
- // Slack uses HMAC-SHA256 with 'v0=' prefix
65
- const slackExpected = 'v0=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
66
- return crypto.timingSafeEqual(
67
- Buffer.from(signatureHeader),
68
- Buffer.from(slackExpected),
69
- );
70
- default:
71
- return true; // custom provider — verify externally
72
- }
73
- }
74
-
75
40
  // ── API definition ───────────────────────────────────────────
76
41
 
77
42
  export const ${varName} = defineApi({
@@ -84,19 +49,8 @@ export const ${varName} = defineApi({
84
49
  input: ${varName}Input,
85
50
  output: ${varName}Output,
86
51
  handler: async (input, ctx) => {
87
- // ── Retrieve webhook secret ───────────────────────────
88
- const secret = await ctx.secrets.get(
89
- '${domain.toUpperCase()}_WEBHOOK_SECRET',
90
- { provider: input.provider },
91
- );
92
-
93
- // ── Verify signature ──────────────────────────────────
94
- const signatureHeader = ctx.headers?.['x-webhook-signature'] ?? '';
95
- const rawPayload = input.rawBody ?? JSON.stringify(input.payload);
96
-
97
- if (!verifySignature(input.provider, rawPayload, signatureHeader, secret)) {
98
- throw new Error('Invalid webhook signature');
99
- }
52
+ // Signature verification belongs in a Lambda authorizer (x-webhook-signature header
53
+ // is not accessible here). Wire an HttpLambdaAuthorizer in DomainStack for production.
100
54
 
101
55
  const eventId = crypto.randomUUID();
102
56
 
@@ -106,13 +60,13 @@ export const ${varName} = defineApi({
106
60
  provider: input.provider,
107
61
  eventType: input.eventType,
108
62
  payload: input.payload,
109
- tenantId: ctx.tenantId,
63
+ tenantId: ctx.tenant.id,
110
64
  }, 1);
111
65
 
112
- await ctx.auditLog({
113
- action: '${id}.webhook.received',
114
- tenantId: ctx.tenantId,
115
- meta: { provider: input.provider, eventType: input.eventType, eventId },
66
+ await ctx.audit.log('${id}.webhook.received', ctx.tenant.id, {
67
+ provider: input.provider,
68
+ eventType: input.eventType,
69
+ eventId,
116
70
  });
117
71
 
118
72
  return {
@@ -23,31 +23,21 @@ export const ${varName} = defineSubscriber({
23
23
  event: '${event}',
24
24
  semverRange: '^1',
25
25
  handler: async (event, ctx) => {
26
- const auditRecord = {
27
- pk: 'AUDIT#' + event.data.tenantId,
28
- sk: 'EVENT#' + event.id,
29
- eventType: event.type,
30
- eventVersion: event.version,
31
- payload: event.data,
32
- timestamp: event.timestamp,
33
- source: event.source,
34
- correlationId: event.correlationId,
35
- ttl: Math.floor(Date.now() / 1000) + 90 * 86400, // 90-day retention
36
- };
26
+ const ev = event as { id?: string; type?: string; version?: number; data?: unknown; timestamp?: string };
27
+ const eventId = ev.id ?? crypto.randomUUID();
37
28
 
38
- // ── Write to audit store ─────────────────────────────────
39
- await ctx.db.put(auditRecord);
40
-
41
- // ── Optionally forward to external sink ──────────────────
42
- if (ctx.integrations?.auditSink) {
43
- await ctx.fetch(ctx.integrations.auditSink, {
44
- method: 'POST',
45
- body: JSON.stringify(auditRecord),
46
- headers: { 'content-type': 'application/json' },
47
- });
48
- }
49
-
50
- ctx.metrics?.putMetric('${id}.audit.relayed', 1, 'Count');
29
+ // ── Write immutable audit record ─────────────────────────
30
+ await ctx.store.put(
31
+ 'AUDIT#' + eventId,
32
+ {
33
+ eventId,
34
+ eventType: ev.type,
35
+ eventVersion: ev.version,
36
+ payload: ev.data,
37
+ timestamp: ev.timestamp ?? new Date().toISOString(),
38
+ },
39
+ { ttl: Math.floor(Date.now() / 1000) + 90 * 86400 }, // 90-day retention
40
+ );
51
41
  },
52
42
  });
53
43
  `;
@@ -25,38 +25,22 @@ export const ${varName} = defineSubscriber({
25
25
  event: '${event}',
26
26
  semverRange: '^1',
27
27
  handler: async (event, ctx) => {
28
- const { entityId, tenantId } = event.data;
28
+ const data = (event as { data: { entityId: string; deletedBy?: string } }).data;
29
29
 
30
30
  // ── Find all children ────────────────────────────────────
31
- const children = await ctx.db.query({
32
- pk: 'TENANT#' + tenantId,
33
- skPrefix: 'CHILD#' + entityId,
34
- });
31
+ const { items: children } = await ctx.store.query({ skPrefix: 'CHILD#' + data.entityId });
35
32
 
36
33
  // ── Soft-delete each child ───────────────────────────────
37
34
  const now = new Date().toISOString();
38
35
  for (const child of children) {
39
- await ctx.db.update({
40
- key: { pk: child.pk, sk: child.sk },
41
- updates: {
42
- deletedAt: now,
43
- deletedBy: event.data.deletedBy ?? 'system',
44
- },
36
+ await ctx.store.update(child['sk'] as string, {
37
+ deletedAt: now,
38
+ deletedBy: data.deletedBy ?? 'system',
45
39
  });
46
40
  }
47
41
 
48
42
  // ── Audit log ────────────────────────────────────────────
49
- await ctx.auditLog({
50
- action: 'cascade.delete',
51
- tenantId,
52
- entityId,
53
- meta: {
54
- childrenDeleted: children.length,
55
- triggeredBy: event.id,
56
- },
57
- });
58
-
59
- ctx.metrics?.putMetric('${id}.cascade.deleted', children.length, 'Count');
43
+ await ctx.audit.log('cascade.delete', data.entityId, { childrenDeleted: children.length });
60
44
  },
61
45
  });
62
46
  `;
@@ -20,19 +20,15 @@ export const ${varName} = defineSubscriber({
20
20
  event: '${event}',
21
21
  semverRange: '^1',
22
22
  handler: async (event, ctx) => {
23
- const { tenantId, ...eventData } = event.data;
23
+ const { tenantId: _tenantId, ...eventData } = (event as { data: Record<string, unknown> }).data;
24
+ const eventId = (event as { id?: string }).id ?? 'unknown';
24
25
 
25
26
  // ── Upsert projection ────────────────────────────────────
26
27
  // Each event updates the projection row for fast reads.
27
- await ctx.db.put({
28
- pk: 'TENANT#' + tenantId,
29
- sk: 'PROJECTION#' + event.id,
30
- ...eventData,
31
- lastEventId: event.id,
32
- projectedAt: new Date().toISOString(),
33
- });
34
-
35
- ctx.metrics?.putMetric('${id}.projection.applied', 1, 'Count');
28
+ await ctx.store.put(
29
+ 'PROJECTION#' + eventId,
30
+ { ...eventData, lastEventId: eventId, projectedAt: new Date().toISOString() },
31
+ );
36
32
  },
37
33
  });
38
34
  `;
@@ -21,7 +21,7 @@ export const ${subscriberId.replace(/-([a-z])/g, (_, c) => c.toUpperCase())} = d
21
21
  event: '${eventId}',
22
22
  semverRange: '*',
23
23
  handler: async (_event, _ctx) => {
24
- // TODO: Implement subscriber handler
24
+ // receive _event.data (typed payload) and use _ctx.store, _ctx.publish, _ctx.audit
25
25
  },
26
26
  });
27
27
  `;
@@ -1,21 +1,20 @@
1
1
  import { writeFile, mkdir, access } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { computeChecksumString, computeChecksumFile } from './checksum.js';
3
+ import { computeChecksumString } from './checksum.js';
4
4
  import type { ManifestFileEntry } from './manifest.js';
5
5
 
6
- export type FilePolicy = 'owned' | 'tracked' | 'seed';
6
+ export type FilePolicy = 'managed' | 'editable' | 'seed';
7
7
 
8
8
  export interface InstallResult {
9
- status: 'added' | 'updated' | 'conflict' | 'skipped' | 'unchanged';
9
+ status: 'added' | 'updated' | 'conflict' | 'skipped' | 'unchanged' | 'update-available';
10
10
  }
11
11
 
12
12
  /**
13
13
  * Install a scaffold file according to its policy.
14
14
  *
15
- * - owned: always overwrite. Return 'added' if new, 'updated' if existed.
16
- * - tracked: check sha256. If same → 'unchanged'. If different, check disk for drift.
17
- * If drifted write .tib-upgrade file 'conflict'. If not drifted overwrite → 'updated'.
18
- * If no currentEntry → write new → 'added'.
15
+ * - managed: always overwrite. Return 'added' if new, 'updated' if existed.
16
+ * - editable: check sha256. If same → 'unchanged'. If different 'update-available'
17
+ * (does not write .tib-upgrade or overwrite user opts in via Updates tab).
19
18
  * - seed: if file exists → 'skipped'. Else write → 'added'.
20
19
  */
21
20
  export async function installScaffoldFile(
@@ -38,8 +37,8 @@ export async function installScaffoldFile(
38
37
  }
39
38
  };
40
39
 
41
- if (policy === 'owned') {
42
- // owned: always overwrite, no sha256 check
40
+ if (policy === 'managed') {
41
+ // managed: always overwrite, no sha256 check
43
42
  const exists = await fileExists(absPath);
44
43
  if (!dryRun) {
45
44
  await mkdir(dirname(absPath), { recursive: true });
@@ -61,7 +60,7 @@ export async function installScaffoldFile(
61
60
  return { status: 'added' };
62
61
  }
63
62
 
64
- // policy === 'tracked'
63
+ // policy === 'editable'
65
64
  if (!currentEntry) {
66
65
  // New file: write it
67
66
  if (!dryRun) {
@@ -77,23 +76,6 @@ export async function installScaffoldFile(
77
76
  return { status: 'unchanged' };
78
77
  }
79
78
 
80
- // Content changed: check for drift
81
- const diskChecksum = await computeChecksumFile(absPath);
82
- const drifted = diskChecksum !== null && diskChecksum !== currentEntry.sha256;
83
-
84
- if (drifted) {
85
- // Drift detected: write .tib-upgrade file, don't touch original
86
- if (!dryRun) {
87
- await mkdir(dirname(absPath), { recursive: true });
88
- await writeFile(`${absPath}.tib-upgrade`, content, 'utf-8');
89
- }
90
- return { status: 'conflict' };
91
- }
92
-
93
- // No drift: safe to overwrite
94
- if (!dryRun) {
95
- await mkdir(dirname(absPath), { recursive: true });
96
- await writeFile(absPath, content, 'utf-8');
97
- }
98
- return { status: 'updated' };
79
+ // Content changed: report update available, do NOT modify disk
80
+ return { status: 'update-available' };
99
81
  }
@@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { cliLogger } from './logger.js';
4
4
 
5
- export type FilePolicy = 'owned' | 'tracked' | 'seed';
5
+ export type FilePolicy = 'managed' | 'editable' | 'seed';
6
6
 
7
7
  export interface ManifestFileEntry {
8
8
  path: string;
@@ -36,7 +36,34 @@ export function inferPolicyFromPath(filePath: string): FilePolicy {
36
36
  filePath === 'mc-deploy.yml' ||
37
37
  filePath.match(/^\.github\/workflows\//)
38
38
  ) {
39
- return 'owned';
39
+ return 'managed';
40
+ }
41
+
42
+ // managed: core scaffold files not prefixed with infra/
43
+ const managedFilePatterns = [
44
+ /^CLAUDE\.md$/,
45
+ /^\.husky\//,
46
+ /^\.mc\/scaffold-config\.json$/,
47
+ /^\.mc\/modules-hashes\.json$/,
48
+ /^\.npmrc$/,
49
+ /^cdk\.json$/,
50
+ /^\.gitignore$/,
51
+ /^eslint\.config\.js$/,
52
+ /^package\.json$/,
53
+ /^publish-knowledge\.(yml|mjs)$/,
54
+ /^mc-destroy\.yml$/,
55
+ ];
56
+ if (managedFilePatterns.some((p) => p.test(filePath))) {
57
+ return 'managed';
58
+ }
59
+
60
+ // editable: user-customisable config files
61
+ const editableFilePatterns = [
62
+ /^vite\.config\.ts$/,
63
+ /^tailwind\.config\.ts$/,
64
+ ];
65
+ if (editableFilePatterns.some((p) => p.test(filePath))) {
66
+ return 'editable';
40
67
  }
41
68
 
42
69
  // seed: specific frontend paths
@@ -53,7 +80,7 @@ export function inferPolicyFromPath(filePath: string): FilePolicy {
53
80
  }
54
81
 
55
82
  // tracked: everything else
56
- return 'tracked';
83
+ return 'editable';
57
84
  }
58
85
 
59
86
  export async function readManifest(projectRoot: string): Promise<TibManifest | null> {
@@ -76,6 +103,24 @@ export async function readManifest(projectRoot: string): Promise<TibManifest | n
76
103
  await writeManifest(projectRoot, manifest);
77
104
  }
78
105
 
106
+ // v2→v3 migration: rename 'owned'→'managed', 'tracked'→'editable'
107
+ let needsV3Migration = false;
108
+ for (const entry of manifest.files as unknown as Array<Record<string, unknown>>) {
109
+ const rawPolicy = entry['policy'] as string | undefined;
110
+ if (rawPolicy === 'owned') {
111
+ entry['policy'] = 'managed';
112
+ needsV3Migration = true;
113
+ } else if (rawPolicy === 'tracked') {
114
+ entry['policy'] = 'editable';
115
+ needsV3Migration = true;
116
+ }
117
+ }
118
+
119
+ if (needsV3Migration) {
120
+ cliLogger.info('manifest: migrating v2→v3 with renamed policies');
121
+ await writeManifest(projectRoot, manifest);
122
+ }
123
+
79
124
  return manifest;
80
125
  } catch (err: unknown) {
81
126
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
@@ -130,8 +175,8 @@ export function getManifestFile(
130
175
  return manifest.files.find((f) => f.path === filePath);
131
176
  }
132
177
 
133
- export function isScaffoldOwned(manifest: TibManifest, filePath: string): boolean {
178
+ export function isScaffoldManaged(manifest: TibManifest, filePath: string): boolean {
134
179
  const entry = manifest.files.find((f) => f.path === filePath);
135
- // Scaffold-managed means in manifest AND policy is not 'seed'
136
- return entry != null && entry.policy !== 'seed';
180
+ // Returns true only when the file is in the manifest with policy 'managed'
181
+ return entry != null && entry.policy === 'managed';
137
182
  }
@@ -43,6 +43,12 @@ export interface CostOptions {
43
43
  reservedConcurrencyPerDomain?: Record<string, number>;
44
44
  }
45
45
 
46
+ /** Monitoring / observability feature opt-ins. */
47
+ export interface MonitoringOptions {
48
+ /** Replace basic MonitoringStack with full ObservabilityStack (Grafana + Cost Explorer + CloudWatch). ~$9–18/month */
49
+ enhanced: boolean;
50
+ }
51
+
46
52
  /** Lifecycle tunables — Phase B. Schema only in Phase A. */
47
53
  export interface LifecycleOptions {
48
54
  dlqRetentionDays?: 7 | 14;
@@ -95,6 +101,8 @@ export interface ScaffoldConfig {
95
101
  costOptions?: CostOptions;
96
102
  /** Lifecycle tunables. Phase B — schema present, CDK not yet wired. */
97
103
  lifecycleOptions?: LifecycleOptions;
104
+ /** Monitoring feature opt-ins. All default false. */
105
+ monitoringOptions?: MonitoringOptions;
98
106
  /** Per-environment URL configuration for custom domains. */
99
107
  environments?: EnvironmentsConfig;
100
108
  /** When true, CORS responses include Allow-Credentials: true (requires non-wildcard origins). */
@@ -117,6 +125,9 @@ const DEFAULT_SCAFFOLD_CONFIG: Omit<ScaffoldConfig, 'domainIds' | 'flowIds'> = {
117
125
  },
118
126
  firstDeployedAt: {},
119
127
  logRetentionDays: 30,
128
+ monitoringOptions: {
129
+ enhanced: false,
130
+ },
120
131
  };
121
132
 
122
133
  const CONFIG_PATH = '.mc/scaffold-config.json';