@mettlecast/domain-cli 0.2.0 → 0.2.2

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 +5 -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 +5 -3
  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
@@ -9,7 +9,32 @@ export function inferPolicyFromPath(filePath) {
9
9
  filePath.match(/^\.tib\/infra\//) ||
10
10
  filePath === 'mc-deploy.yml' ||
11
11
  filePath.match(/^\.github\/workflows\//)) {
12
- return 'owned';
12
+ return 'managed';
13
+ }
14
+ // managed: core scaffold files not prefixed with infra/
15
+ const managedFilePatterns = [
16
+ /^CLAUDE\.md$/,
17
+ /^\.husky\//,
18
+ /^\.mc\/scaffold-config\.json$/,
19
+ /^\.mc\/modules-hashes\.json$/,
20
+ /^\.npmrc$/,
21
+ /^cdk\.json$/,
22
+ /^\.gitignore$/,
23
+ /^eslint\.config\.js$/,
24
+ /^package\.json$/,
25
+ /^publish-knowledge\.(yml|mjs)$/,
26
+ /^mc-destroy\.yml$/,
27
+ ];
28
+ if (managedFilePatterns.some((p) => p.test(filePath))) {
29
+ return 'managed';
30
+ }
31
+ // editable: user-customisable config files
32
+ const editableFilePatterns = [
33
+ /^vite\.config\.ts$/,
34
+ /^tailwind\.config\.ts$/,
35
+ ];
36
+ if (editableFilePatterns.some((p) => p.test(filePath))) {
37
+ return 'editable';
13
38
  }
14
39
  // seed: specific frontend paths
15
40
  const seedPatterns = [
@@ -24,7 +49,7 @@ export function inferPolicyFromPath(filePath) {
24
49
  return 'seed';
25
50
  }
26
51
  // tracked: everything else
27
- return 'tracked';
52
+ return 'editable';
28
53
  }
29
54
  export async function readManifest(projectRoot) {
30
55
  const manifestPath = path.join(projectRoot, MANIFEST_PATH);
@@ -43,6 +68,23 @@ export async function readManifest(projectRoot) {
43
68
  cliLogger.info('manifest: migrating v1→v2 with inferred policies');
44
69
  await writeManifest(projectRoot, manifest);
45
70
  }
71
+ // v2→v3 migration: rename 'owned'→'managed', 'tracked'→'editable'
72
+ let needsV3Migration = false;
73
+ for (const entry of manifest.files) {
74
+ const rawPolicy = entry['policy'];
75
+ if (rawPolicy === 'owned') {
76
+ entry['policy'] = 'managed';
77
+ needsV3Migration = true;
78
+ }
79
+ else if (rawPolicy === 'tracked') {
80
+ entry['policy'] = 'editable';
81
+ needsV3Migration = true;
82
+ }
83
+ }
84
+ if (needsV3Migration) {
85
+ cliLogger.info('manifest: migrating v2→v3 with renamed policies');
86
+ await writeManifest(projectRoot, manifest);
87
+ }
46
88
  return manifest;
47
89
  }
48
90
  catch (err) {
@@ -86,8 +128,8 @@ export function removeManifestFile(manifest, filePath) {
86
128
  export function getManifestFile(manifest, filePath) {
87
129
  return manifest.files.find((f) => f.path === filePath);
88
130
  }
89
- export function isScaffoldOwned(manifest, filePath) {
131
+ export function isScaffoldManaged(manifest, filePath) {
90
132
  const entry = manifest.files.find((f) => f.path === filePath);
91
- // Scaffold-managed means in manifest AND policy is not 'seed'
92
- return entry != null && entry.policy !== 'seed';
133
+ // Returns true only when the file is in the manifest with policy 'managed'
134
+ return entry != null && entry.policy === 'managed';
93
135
  }
@@ -29,6 +29,11 @@ export interface CostOptions {
29
29
  xRaySamplingRate?: number;
30
30
  reservedConcurrencyPerDomain?: Record<string, number>;
31
31
  }
32
+ /** Monitoring / observability feature opt-ins. */
33
+ export interface MonitoringOptions {
34
+ /** Replace basic MonitoringStack with full ObservabilityStack (Grafana + Cost Explorer + CloudWatch). ~$9–18/month */
35
+ enhanced: boolean;
36
+ }
32
37
  /** Lifecycle tunables — Phase B. Schema only in Phase A. */
33
38
  export interface LifecycleOptions {
34
39
  dlqRetentionDays?: 7 | 14;
@@ -81,6 +86,8 @@ export interface ScaffoldConfig {
81
86
  costOptions?: CostOptions;
82
87
  /** Lifecycle tunables. Phase B — schema present, CDK not yet wired. */
83
88
  lifecycleOptions?: LifecycleOptions;
89
+ /** Monitoring feature opt-ins. All default false. */
90
+ monitoringOptions?: MonitoringOptions;
84
91
  /** Per-environment URL configuration for custom domains. */
85
92
  environments?: EnvironmentsConfig;
86
93
  /** When true, CORS responses include Allow-Credentials: true (requires non-wildcard origins). */
@@ -16,6 +16,9 @@ const DEFAULT_SCAFFOLD_CONFIG = {
16
16
  },
17
17
  firstDeployedAt: {},
18
18
  logRetentionDays: 30,
19
+ monitoringOptions: {
20
+ enhanced: false,
21
+ },
19
22
  };
20
23
  const CONFIG_PATH = '.mc/scaffold-config.json';
21
24
  export async function readScaffoldConfig(projectRoot) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -135,7 +135,7 @@ describe('upgrade command', () => {
135
135
  sha256: oldChecksum,
136
136
  wasTemplate: false,
137
137
  installedAt: '2026-01-01T00:00:00Z',
138
- policy: 'tracked',
138
+ policy: 'editable',
139
139
  },
140
140
  ],
141
141
  });
@@ -242,7 +242,7 @@ describe('upgrade command', () => {
242
242
  sha256: manifestChecksum, // Original checksum in manifest
243
243
  wasTemplate: false,
244
244
  installedAt: '2026-01-01T00:00:00Z',
245
- policy: 'tracked',
245
+ policy: 'editable',
246
246
  },
247
247
  ],
248
248
  });
@@ -300,7 +300,7 @@ describe('upgrade command', () => {
300
300
 
301
301
  const tarballContent = createMockTarball([
302
302
  { path: 'MANIFEST.json', content: JSON.stringify([
303
- { path: filePath, sha256: newChecksum, isTemplate: false, policy: 'tracked' },
303
+ { path: filePath, sha256: newChecksum, isTemplate: false, policy: 'editable' },
304
304
  ]) },
305
305
  { path: filePath, content: newContent },
306
306
  ]);
@@ -346,7 +346,7 @@ describe('upgrade command', () => {
346
346
  sha256: oldChecksum,
347
347
  wasTemplate: false,
348
348
  installedAt: '2026-01-01T00:00:00Z',
349
- policy: 'owned',
349
+ policy: 'managed',
350
350
  },
351
351
  ],
352
352
  });
@@ -449,7 +449,7 @@ describe('upgrade command', () => {
449
449
  sha256: checksum,
450
450
  wasTemplate: false,
451
451
  installedAt: '2026-01-01T00:00:00Z',
452
- policy: 'owned',
452
+ policy: 'managed',
453
453
  },
454
454
  ],
455
455
  });
@@ -507,7 +507,7 @@ describe('upgrade command', () => {
507
507
 
508
508
  const tarballContent = createMockTarball([
509
509
  { path: 'MANIFEST.json', content: JSON.stringify([
510
- { path: filePath, sha256: checksum, isTemplate: false, policy: 'owned' },
510
+ { path: filePath, sha256: checksum, isTemplate: false, policy: 'managed' },
511
511
  ]) },
512
512
  { path: filePath, content },
513
513
  ]);
@@ -549,7 +549,7 @@ describe('upgrade command', () => {
549
549
  sha256: manifestChecksum,
550
550
  wasTemplate: false,
551
551
  installedAt: '2026-01-01T00:00:00Z',
552
- policy: 'tracked',
552
+ policy: 'editable',
553
553
  },
554
554
  ],
555
555
  });
@@ -607,7 +607,7 @@ describe('upgrade command', () => {
607
607
 
608
608
  const tarballContent = createMockTarball([
609
609
  { path: 'MANIFEST.json', content: JSON.stringify([
610
- { path: filePath, sha256: newChecksum, isTemplate: false, policy: 'tracked' },
610
+ { path: filePath, sha256: newChecksum, isTemplate: false, policy: 'editable' },
611
611
  ]) },
612
612
  { path: filePath, content: newContent },
613
613
  ]);
@@ -114,6 +114,7 @@ describe('runDoctor', () => {
114
114
  describe('layout validation checks', () => {
115
115
  it('PASS when manifest has owned files in .mc/', async () => {
116
116
  await mkdir(join(tempDir, '.tib'), { recursive: true });
117
+ await mkdir(join(tempDir, '.mc'), { recursive: true });
117
118
  await mkdir(join(tempDir, 'domains'), { recursive: true });
118
119
  await mkdir(join(tempDir, '.husky'), { recursive: true });
119
120
  await mkdir(join(tempDir, '.tib', 'infra', 'modules'), { recursive: true });
@@ -132,10 +133,10 @@ describe('runDoctor', () => {
132
133
  enabledModules: ['backend-lambda'],
133
134
  files: [
134
135
  { path: '.mc/infra/modules/app.ts', module: 'backend-lambda', moduleVersion: '1.0.0',
135
- sha256: 'abc', wasTemplate: true, installedAt: '2026-01-01T00:00:00Z', policy: 'owned' as const }
136
+ sha256: 'abc', wasTemplate: true, installedAt: '2026-01-01T00:00:00Z', policy: 'managed' as const }
136
137
  ]
137
138
  };
138
- await writeFile(join(tempDir, '.tib', 'manifest.json'), JSON.stringify(manifest));
139
+ await writeFile(join(tempDir, '.mc', 'manifest.json'), JSON.stringify(manifest));
139
140
 
140
141
  const report = await runDoctor({ projectRoot: tempDir });
141
142
  const check = report.checks.find(c => c.name === 'Layout: owned files in .mc/');
@@ -144,6 +145,7 @@ describe('runDoctor', () => {
144
145
 
145
146
  it('FAIL when owned file is outside .mc/ (old layout)', async () => {
146
147
  await mkdir(join(tempDir, '.tib'), { recursive: true });
148
+ await mkdir(join(tempDir, '.mc'), { recursive: true });
147
149
  await mkdir(join(tempDir, 'domains'), { recursive: true });
148
150
  await mkdir(join(tempDir, '.husky'), { recursive: true });
149
151
  await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
@@ -162,10 +164,10 @@ describe('runDoctor', () => {
162
164
  enabledModules: ['backend-lambda'],
163
165
  files: [
164
166
  { path: 'infra/modules/app.ts', module: 'backend-lambda', moduleVersion: '1.0.0',
165
- sha256: 'abc', wasTemplate: true, installedAt: '2026-01-01T00:00:00Z', policy: 'owned' as const }
167
+ sha256: 'abc', wasTemplate: true, installedAt: '2026-01-01T00:00:00Z', policy: 'managed' as const }
166
168
  ]
167
169
  };
168
- await writeFile(join(tempDir, '.tib', 'manifest.json'), JSON.stringify(manifest));
170
+ await writeFile(join(tempDir, '.mc', 'manifest.json'), JSON.stringify(manifest));
169
171
 
170
172
  const report = await runDoctor({ projectRoot: tempDir });
171
173
  const check = report.checks.find(c => c.name === 'Layout: owned files in .mc/');
@@ -174,6 +176,7 @@ describe('runDoctor', () => {
174
176
 
175
177
  it('FAIL when manifest entry points at missing file', async () => {
176
178
  await mkdir(join(tempDir, '.tib'), { recursive: true });
179
+ await mkdir(join(tempDir, '.mc'), { recursive: true });
177
180
  await mkdir(join(tempDir, 'domains'), { recursive: true });
178
181
  await mkdir(join(tempDir, '.husky'), { recursive: true });
179
182
 
@@ -190,10 +193,10 @@ describe('runDoctor', () => {
190
193
  enabledModules: ['backend-lambda'],
191
194
  files: [
192
195
  { path: '.mc/infra/missing-file.ts', module: 'backend-lambda', moduleVersion: '1.0.0',
193
- sha256: 'abc', wasTemplate: false, installedAt: '2026-01-01T00:00:00Z', policy: 'owned' as const }
196
+ sha256: 'abc', wasTemplate: false, installedAt: '2026-01-01T00:00:00Z', policy: 'managed' as const }
194
197
  ]
195
198
  };
196
- await writeFile(join(tempDir, '.tib', 'manifest.json'), JSON.stringify(manifest));
199
+ await writeFile(join(tempDir, '.mc', 'manifest.json'), JSON.stringify(manifest));
197
200
 
198
201
  const report = await runDoctor({ projectRoot: tempDir });
199
202
  const check = report.checks.find(c => c.name === 'Layout: manifest entries exist on disk');
@@ -204,6 +207,7 @@ describe('runDoctor', () => {
204
207
  describe('--relocate flag', () => {
205
208
  it('moves scaffold-owned files from old layout to .mc/', async () => {
206
209
  await mkdir(join(tempDir, '.tib'), { recursive: true });
210
+ await mkdir(join(tempDir, '.mc'), { recursive: true });
207
211
  await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
208
212
  await mkdir(join(tempDir, 'domains'), { recursive: true });
209
213
  await mkdir(join(tempDir, '.husky'), { recursive: true });
@@ -226,16 +230,16 @@ describe('runDoctor', () => {
226
230
  enabledModules: ['backend-lambda'],
227
231
  files: [
228
232
  { path: 'infra/modules/app.ts', module: 'backend-lambda', moduleVersion: '1.0.0',
229
- sha256, wasTemplate: true, installedAt: '2026-01-01T00:00:00Z', policy: 'owned' as const }
233
+ sha256, wasTemplate: true, installedAt: '2026-01-01T00:00:00Z', policy: 'managed' as const }
230
234
  ]
231
235
  };
232
- await writeFile(join(tempDir, '.tib', 'manifest.json'), JSON.stringify(manifest));
236
+ await writeFile(join(tempDir, '.mc', 'manifest.json'), JSON.stringify(manifest));
233
237
 
234
238
  const report = await runDoctor({ projectRoot: tempDir, relocate: true });
235
239
 
236
240
  // File moved
237
241
  const { existsSync } = await import('node:fs');
238
- expect(existsSync(join(tempDir, '.tib', 'infra', 'modules', 'app.ts'))).toBe(true);
242
+ expect(existsSync(join(tempDir, '.mc', 'infra', 'modules', 'app.ts'))).toBe(true);
239
243
  expect(existsSync(join(tempDir, 'infra', 'modules', 'app.ts'))).toBe(false);
240
244
 
241
245
  // Summary pass
@@ -245,6 +249,7 @@ describe('runDoctor', () => {
245
249
 
246
250
  it('leaves hand-edited files in place with warning on --relocate', async () => {
247
251
  await mkdir(join(tempDir, '.tib'), { recursive: true });
252
+ await mkdir(join(tempDir, '.mc'), { recursive: true });
248
253
  await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
249
254
 
250
255
  const originalContent = '// @mc-scaffold: backend-lambda@1.0.0\noriginal\n';
@@ -265,10 +270,10 @@ describe('runDoctor', () => {
265
270
  enabledModules: ['backend-lambda'],
266
271
  files: [
267
272
  { path: 'infra/modules/app.ts', module: 'backend-lambda', moduleVersion: '1.0.0',
268
- sha256: originalSha, wasTemplate: true, installedAt: '2026-01-01T00:00:00Z', policy: 'owned' as const }
273
+ sha256: originalSha, wasTemplate: true, installedAt: '2026-01-01T00:00:00Z', policy: 'managed' as const }
269
274
  ]
270
275
  };
271
- await writeFile(join(tempDir, '.tib', 'manifest.json'), JSON.stringify(manifest));
276
+ await writeFile(join(tempDir, '.mc', 'manifest.json'), JSON.stringify(manifest));
272
277
 
273
278
  await runDoctor({ projectRoot: tempDir, relocate: true });
274
279
 
@@ -35,12 +35,4 @@ describe('scaffold-src layout (Part A)', () => {
35
35
  expect(content).toContain('tib-ci.yml');
36
36
  });
37
37
 
38
- it('backend-lambda tarball-manifest.json maps .mc/deploy.yml.tpl to .github/workflows/mc-deploy.yml', () => {
39
- const tmPath = join(backendLambdaDir, 'tarball-manifest.json');
40
- expect(existsSync(tmPath)).toBe(true);
41
- const tm = JSON.parse(readFileSync(tmPath, 'utf-8'));
42
- const deployOverride = tm.fileOverrides.find((e: { path: string }) => e.path === '.mc/deploy.yml.tpl');
43
- expect(deployOverride?.installedAs).toBe('.github/workflows/mc-deploy.yml');
44
- expect(deployOverride?.policy).toBe('owned');
45
- });
46
38
  });
@@ -4,7 +4,7 @@ describe('package-scaffold policy inference', () => {
4
4
  function inferPolicyFromPath(filePath: string): string {
5
5
  if (/^infra\//.test(filePath) || /^\.tib\/infra\//.test(filePath) ||
6
6
  filePath === 'mc-deploy.yml' || /^\.github\/workflows\//.test(filePath)) {
7
- return 'owned';
7
+ return 'managed';
8
8
  }
9
9
  const seedPatterns = [
10
10
  /^frontend\/src\/pages\/auth\//,
@@ -15,18 +15,18 @@ describe('package-scaffold policy inference', () => {
15
15
  /^frontend\/src\/styles\/brand\.css$/,
16
16
  ];
17
17
  if (seedPatterns.some(p => p.test(filePath))) return 'seed';
18
- return 'tracked';
18
+ return 'editable';
19
19
  }
20
20
 
21
- it('returns owned for .mc/infra/ paths', () => {
22
- expect(inferPolicyFromPath('.mc/infra/modules/app.ts')).toBe('owned');
23
- expect(inferPolicyFromPath('.mc/infra/cdk.json')).toBe('owned');
24
- expect(inferPolicyFromPath('.mc/infra/tsconfig.json')).toBe('owned');
21
+ it('returns managed for .mc/infra/ paths', () => {
22
+ expect(inferPolicyFromPath('.mc/infra/modules/app.ts')).toBe('managed');
23
+ expect(inferPolicyFromPath('.mc/infra/cdk.json')).toBe('managed');
24
+ expect(inferPolicyFromPath('.mc/infra/tsconfig.json')).toBe('managed');
25
25
  });
26
26
 
27
- it('returns owned for .github/workflows/ paths', () => {
28
- expect(inferPolicyFromPath('.github/workflows/tib-ci.yml')).toBe('owned');
29
- expect(inferPolicyFromPath('.github/workflows/mc-deploy.yml')).toBe('owned');
27
+ it('returns managed for .github/workflows/ paths', () => {
28
+ expect(inferPolicyFromPath('.github/workflows/tib-ci.yml')).toBe('managed');
29
+ expect(inferPolicyFromPath('.github/workflows/mc-deploy.yml')).toBe('managed');
30
30
  });
31
31
 
32
32
  it('returns seed for frontend page paths', () => {
@@ -34,12 +34,12 @@ describe('package-scaffold policy inference', () => {
34
34
  expect(inferPolicyFromPath('frontend/src/styles/brand.css')).toBe('seed');
35
35
  });
36
36
 
37
- it('returns tracked for domain files', () => {
38
- expect(inferPolicyFromPath('domains/payments/api/charge.ts')).toBe('tracked');
39
- expect(inferPolicyFromPath('packages/domain-cli/src/utils/manifest.ts')).toBe('tracked');
37
+ it('returns editable for domain files', () => {
38
+ expect(inferPolicyFromPath('domains/payments/api/charge.ts')).toBe('editable');
39
+ expect(inferPolicyFromPath('packages/domain-cli/src/utils/manifest.ts')).toBe('editable');
40
40
  });
41
41
 
42
- it('returns owned for mc-deploy.yml', () => {
43
- expect(inferPolicyFromPath('mc-deploy.yml')).toBe('owned');
42
+ it('returns managed for mc-deploy.yml', () => {
43
+ expect(inferPolicyFromPath('mc-deploy.yml')).toBe('managed');
44
44
  });
45
45
  });
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { mkdtemp, rmdir, writeFile, readFile, mkdir } from 'node:fs/promises';
3
- import { join } from 'node:path';
2
+ import { mkdtemp, rmdir, writeFile, readFile, mkdir, access } from 'node:fs/promises';
3
+ import { join, dirname } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
+ import { createHash } from 'node:crypto';
5
6
  import { installScaffoldFile, type FilePolicy } from '../../utils/install-file.js';
6
7
  import { computeChecksumString } from '../../utils/checksum.js';
7
8
  import type { ManifestFileEntry } from '../../utils/manifest.js';
@@ -52,15 +53,15 @@ describe('installScaffoldFile', () => {
52
53
  });
53
54
  });
54
55
 
55
- describe('owned policy', () => {
56
+ describe('managed policy', () => {
56
57
  it('overwrites existing file with updated status', async () => {
57
- const filePath = join(tmpDir, 'test-owned.txt');
58
+ const filePath = join(tmpDir, 'test-managed.txt');
58
59
  const oldContent = 'old content';
59
60
  const newContent = 'new content';
60
61
 
61
62
  await writeFile(filePath, oldContent, 'utf-8');
62
63
 
63
- const result = await installScaffoldFile(filePath, newContent, 'owned', undefined);
64
+ const result = await installScaffoldFile(filePath, newContent, 'managed', undefined);
64
65
 
65
66
  expect(result.status).toBe('updated');
66
67
 
@@ -70,10 +71,10 @@ describe('installScaffoldFile', () => {
70
71
  });
71
72
 
72
73
  it('writes new file with added status', async () => {
73
- const filePath = join(tmpDir, 'test-owned-new.txt');
74
+ const filePath = join(tmpDir, 'test-managed-new.txt');
74
75
  const content = 'new content';
75
76
 
76
- const result = await installScaffoldFile(filePath, content, 'owned', undefined);
77
+ const result = await installScaffoldFile(filePath, content, 'managed', undefined);
77
78
 
78
79
  expect(result.status).toBe('added');
79
80
 
@@ -83,25 +84,25 @@ describe('installScaffoldFile', () => {
83
84
  });
84
85
  });
85
86
 
86
- describe('tracked policy', () => {
87
+ describe('editable policy', () => {
87
88
  it('returns unchanged when checksum matches', async () => {
88
- const filePath = join(tmpDir, 'test-tracked.txt');
89
+ const filePath = join(tmpDir, 'test-editable.txt');
89
90
  const content = 'content';
90
91
  const checksum = computeChecksumString(content);
91
92
 
92
93
  await writeFile(filePath, content, 'utf-8');
93
94
 
94
95
  const currentEntry: ManifestFileEntry = {
95
- path: 'test-tracked.txt',
96
+ path: 'test-editable.txt',
96
97
  module: 'core',
97
98
  moduleVersion: '1.0.0',
98
99
  sha256: checksum,
99
100
  wasTemplate: false,
100
101
  installedAt: '2026-01-01T00:00:00Z',
101
- policy: 'tracked',
102
+ policy: 'editable',
102
103
  };
103
104
 
104
- const result = await installScaffoldFile(filePath, content, 'tracked', currentEntry);
105
+ const result = await installScaffoldFile(filePath, content, 'editable', currentEntry);
105
106
 
106
107
  expect(result.status).toBe('unchanged');
107
108
 
@@ -110,8 +111,8 @@ describe('installScaffoldFile', () => {
110
111
  expect(fileContent).toBe(content);
111
112
  });
112
113
 
113
- it('overwrites when no drift and sha256 differs', async () => {
114
- const filePath = join(tmpDir, 'test-tracked-update.txt');
114
+ it('returns update-available when no drift and sha256 differs', async () => {
115
+ const filePath = join(tmpDir, 'test-editable-update.txt');
115
116
  const oldContent = 'old content';
116
117
  const newContent = 'new content';
117
118
  const oldChecksum = computeChecksumString(oldContent);
@@ -119,26 +120,52 @@ describe('installScaffoldFile', () => {
119
120
  await writeFile(filePath, oldContent, 'utf-8');
120
121
 
121
122
  const currentEntry: ManifestFileEntry = {
122
- path: 'test-tracked-update.txt',
123
+ path: 'test-editable-update.txt',
123
124
  module: 'core',
124
125
  moduleVersion: '1.0.0',
125
126
  sha256: oldChecksum,
126
127
  wasTemplate: false,
127
128
  installedAt: '2026-01-01T00:00:00Z',
128
- policy: 'tracked',
129
+ policy: 'editable',
129
130
  };
130
131
 
131
- const result = await installScaffoldFile(filePath, newContent, 'tracked', currentEntry);
132
+ const result = await installScaffoldFile(filePath, newContent, 'editable', currentEntry);
132
133
 
133
- expect(result.status).toBe('updated');
134
+ expect(result.status).toBe('update-available');
134
135
 
135
- // File should be updated
136
+ // File should NOT be overwritten
136
137
  const content = await readFile(filePath, 'utf-8');
137
- expect(content).toBe(newContent);
138
+ expect(content).toBe(oldContent);
138
139
  });
139
140
 
140
- it('writes conflict file on drift', async () => {
141
- const filePath = join(tmpDir, 'test-tracked-drift.txt');
141
+ it('returns update-available when editable content differs and file exists', async () => {
142
+ const existingContent = 'old content';
143
+ const newContent = 'updated content';
144
+ const filePath = join(tmpDir, 'lib', 'utils.ts');
145
+ await mkdir(dirname(filePath), { recursive: true });
146
+ await writeFile(filePath, existingContent, 'utf-8');
147
+
148
+ const currentEntry = {
149
+ path: 'lib/utils.ts',
150
+ module: 'frontend',
151
+ moduleVersion: '1.0.0',
152
+ sha256: createHash('sha256').update(existingContent).digest('hex'),
153
+ wasTemplate: false,
154
+ installedAt: '2026-01-01T00:00:00Z',
155
+ policy: 'editable' as const,
156
+ };
157
+
158
+ const result = await installScaffoldFile(filePath, newContent, 'editable', currentEntry);
159
+ expect(result.status).toBe('update-available');
160
+ // Verify NO .tib-upgrade file written
161
+ await expect(access(filePath + '.tib-upgrade')).rejects.toThrow();
162
+ // Verify original file NOT overwritten
163
+ const diskContent = await readFile(filePath, 'utf-8');
164
+ expect(diskContent).toBe(existingContent);
165
+ });
166
+
167
+ it('returns update-available when file drifted', async () => {
168
+ const filePath = join(tmpDir, 'test-editable-drift.txt');
142
169
  const manifestContent = 'original content';
143
170
  const driftedContent = 'user modified content';
144
171
  const newContent = 'new content from upgrade';
@@ -148,33 +175,29 @@ describe('installScaffoldFile', () => {
148
175
  await writeFile(filePath, driftedContent, 'utf-8');
149
176
 
150
177
  const currentEntry: ManifestFileEntry = {
151
- path: 'test-tracked-drift.txt',
178
+ path: 'test-editable-drift.txt',
152
179
  module: 'core',
153
180
  moduleVersion: '1.0.0',
154
181
  sha256: manifestChecksum,
155
182
  wasTemplate: false,
156
183
  installedAt: '2026-01-01T00:00:00Z',
157
- policy: 'tracked',
184
+ policy: 'editable',
158
185
  };
159
186
 
160
- const result = await installScaffoldFile(filePath, newContent, 'tracked', currentEntry);
187
+ const result = await installScaffoldFile(filePath, newContent, 'editable', currentEntry);
161
188
 
162
- expect(result.status).toBe('conflict');
189
+ expect(result.status).toBe('update-available');
163
190
 
164
191
  // Original file should not be modified
165
192
  const originalContent = await readFile(filePath, 'utf-8');
166
193
  expect(originalContent).toBe(driftedContent);
167
-
168
- // .tib-upgrade file should be created
169
- const conflictFile = await readFile(`${filePath}.tib-upgrade`, 'utf-8');
170
- expect(conflictFile).toBe(newContent);
171
194
  });
172
195
 
173
- it('adds new tracked file when no currentEntry', async () => {
174
- const filePath = join(tmpDir, 'test-tracked-new.txt');
175
- const content = 'new tracked file';
196
+ it('adds new editable file when no currentEntry', async () => {
197
+ const filePath = join(tmpDir, 'test-editable-new.txt');
198
+ const content = 'new editable file';
176
199
 
177
- const result = await installScaffoldFile(filePath, content, 'tracked', undefined);
200
+ const result = await installScaffoldFile(filePath, content, 'editable', undefined);
178
201
 
179
202
  expect(result.status).toBe('added');
180
203
 
@@ -189,7 +212,7 @@ describe('installScaffoldFile', () => {
189
212
  const filePath = join(tmpDir, 'test-dryrun.txt');
190
213
  const content = 'new content';
191
214
 
192
- const result = await installScaffoldFile(filePath, content, 'owned', undefined, { dryRun: true });
215
+ const result = await installScaffoldFile(filePath, content, 'managed', undefined, { dryRun: true });
193
216
 
194
217
  expect(result.status).toBe('added');
195
218