@mettlecast/domain-cli 0.2.58 → 0.2.60

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 (49) hide show
  1. package/dist/builder/build-registry.js +128 -13
  2. package/dist/builder/load-module.js +3 -3
  3. package/dist/cli.js +2 -2
  4. package/dist/commands/build-catalog.d.ts +2 -2
  5. package/dist/commands/build-catalog.js +5 -5
  6. package/dist/commands/build-flows.js +1 -1
  7. package/dist/commands/build.js +5 -5
  8. package/dist/commands/check-hashes.js +2 -2
  9. package/dist/commands/create-project.js +1 -1
  10. package/dist/commands/doctor.d.ts +5 -7
  11. package/dist/commands/doctor.js +44 -165
  12. package/dist/commands/power-tune.js +2 -2
  13. package/dist/commands/show-dns.d.ts +1 -1
  14. package/dist/commands/show-dns.js +5 -5
  15. package/dist/commands/upgrade.js +17 -11
  16. package/dist/commands/validate.js +183 -0
  17. package/dist/utils/header-inject.js +2 -2
  18. package/dist/utils/install-file.d.ts +1 -1
  19. package/dist/utils/install-file.js +1 -1
  20. package/dist/utils/manifest.js +1 -2
  21. package/dist/utils/scaffold-config.d.ts +2 -2
  22. package/dist/utils/scaffold-config.js +1 -1
  23. package/package.json +1 -1
  24. package/src/__tests__/commands/check-hashes.test.ts +9 -9
  25. package/src/__tests__/commands/upgrade.test.ts +7 -7
  26. package/src/__tests__/doctor.test.ts +60 -67
  27. package/src/__tests__/package-freshness.test.ts +114 -0
  28. package/src/__tests__/scaffold-src/part-a-layout.test.ts +10 -10
  29. package/src/__tests__/scripts/package-scaffold.test.ts +5 -5
  30. package/src/__tests__/utils/install-file.test.ts +2 -2
  31. package/src/__tests__/utils/manifest.test.ts +2 -2
  32. package/src/__tests__/validate.test.ts +652 -1
  33. package/src/builder/build-registry.ts +147 -15
  34. package/src/builder/load-module.ts +3 -3
  35. package/src/cli.ts +3 -3
  36. package/src/commands/build-catalog.ts +5 -5
  37. package/src/commands/build-flows.ts +1 -1
  38. package/src/commands/build.ts +5 -5
  39. package/src/commands/check-hashes.ts +2 -2
  40. package/src/commands/create-project.ts +1 -1
  41. package/src/commands/doctor.ts +52 -181
  42. package/src/commands/power-tune.ts +2 -2
  43. package/src/commands/show-dns.ts +5 -5
  44. package/src/commands/upgrade.ts +16 -10
  45. package/src/commands/validate.ts +226 -1
  46. package/src/utils/header-inject.ts +2 -2
  47. package/src/utils/install-file.ts +1 -1
  48. package/src/utils/manifest.ts +1 -2
  49. package/src/utils/scaffold-config.ts +3 -3
@@ -17,6 +17,105 @@ import type {
17
17
  import { walkDomainDir } from '../utils/file-helpers.js';
18
18
  import { loadModuleExports, type RawPrimitiveExport } from './load-module.js';
19
19
 
20
+ type ActionBackendAccess = 'private' | 'domain' | 'platform';
21
+
22
+ type ActionExposure =
23
+ | { type: 'internal' }
24
+ | {
25
+ type: 'api';
26
+ path: string;
27
+ method: string;
28
+ auth: 'required' | 'none' | 'service';
29
+ tenancy: 'required' | 'none' | 'system';
30
+ roles?: string[];
31
+ securityException?: { reason: string };
32
+ authDeclared?: boolean;
33
+ tenancyDeclared?: boolean;
34
+ };
35
+
36
+ /**
37
+ * Coerce a raw value into a valid backendAccess scope, defaulting to
38
+ * 'private' when the value is missing or unrecognized. Used by the
39
+ * builder when reading the new-style `backendAccess` field directly.
40
+ */
41
+ function toBackendAccess(raw: unknown): ActionBackendAccess {
42
+ return raw === 'domain' || raw === 'platform' ? raw : 'private';
43
+ }
44
+
45
+ /**
46
+ * Map a legacy `visibility` scope to a `backendAccess` scope for the
47
+ * action-first migration. The legacy `workspace` scope (which previously
48
+ * permitted unauthenticated Function URL exposure) collapses to `domain`
49
+ * so that all cross-domain callers must go through `ctx.actions`.
50
+ */
51
+ function visibilityToBackendAccess(visibility: unknown): ActionBackendAccess {
52
+ if (visibility === 'workspace') return 'domain';
53
+ if (visibility === 'domain') return 'domain';
54
+ return 'private';
55
+ }
56
+
57
+ /**
58
+ * Best-effort reverse mapping from `backendAccess` to the legacy
59
+ * `visibility` field. Used to keep the deprecated field populated so
60
+ * existing CDK constructs that read it continue to behave the same way.
61
+ *
62
+ * - private -> private
63
+ * - domain -> domain
64
+ * - platform -> workspace (closest legacy equivalent for platform-level)
65
+ */
66
+ function backendAccessToVisibility(backendAccess: ActionBackendAccess): 'private' | 'domain' | 'workspace' {
67
+ if (backendAccess === 'platform') return 'workspace';
68
+ return backendAccess;
69
+ }
70
+
71
+ /**
72
+ * Type guard + sanitizer for an action's `exposure` field. Returns a
73
+ * well-typed `ActionExposure` (extended with `authDeclared` /
74
+ * `tenancyDeclared` tracking flags) or the provided fallback when the
75
+ * raw value does not match a supported exposure shape.
76
+ *
77
+ * The `authDeclared` / `tenancyDeclared` flags record whether the source
78
+ * code explicitly declared each field, or whether the builder fell back
79
+ * to the safe default. They are validation-only and consumed by the
80
+ * domain CLI's validate command (Wave 6 Task 6.1) to enforce the
81
+ * action-first security model (#4619).
82
+ */
83
+ function toExposure(raw: unknown, fallback: ActionExposure): ActionExposure {
84
+ if (!raw || typeof raw !== 'object') return fallback;
85
+ const candidate = raw as { type?: unknown };
86
+ if (candidate.type === 'internal') return { type: 'internal' };
87
+ if (candidate.type !== 'api') return fallback;
88
+ // Best-effort validation of api exposure fields; any missing required
89
+ // string field falls back to the supplied default exposure.
90
+ const api = raw as { path?: unknown; method?: unknown; auth?: unknown; tenancy?: unknown; roles?: unknown; securityException?: unknown };
91
+ if (typeof api.path !== 'string' || typeof api.method !== 'string') return fallback;
92
+ const authRaw = api.auth;
93
+ const tenancyRaw = api.tenancy;
94
+ const auth: 'required' | 'none' | 'service' =
95
+ authRaw === 'required' || authRaw === 'none' || authRaw === 'service' ? authRaw : 'required';
96
+ const tenancy: 'required' | 'none' | 'system' =
97
+ tenancyRaw === 'required' || tenancyRaw === 'none' || tenancyRaw === 'system' ? tenancyRaw : 'required';
98
+ const out: ActionExposure & { type: 'api'; authDeclared?: boolean; tenancyDeclared?: boolean } = {
99
+ type: 'api',
100
+ path: api.path,
101
+ method: api.method,
102
+ auth,
103
+ tenancy,
104
+ authDeclared: authRaw === 'required' || authRaw === 'none' || authRaw === 'service',
105
+ tenancyDeclared: tenancyRaw === 'required' || tenancyRaw === 'none' || tenancyRaw === 'system',
106
+ };
107
+ if (Array.isArray(api.roles)) {
108
+ out.roles = api.roles.filter((r): r is string => typeof r === 'string');
109
+ }
110
+ if (api.securityException && typeof api.securityException === 'object') {
111
+ const reason = (api.securityException as { reason?: unknown }).reason;
112
+ if (typeof reason === 'string') {
113
+ out.securityException = { reason };
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
20
119
  /** Returns true if a value looks like a JSON Schema object (has a 'type' or '$schema' property). */
21
120
  function isJsonSchema(v: unknown): boolean {
22
121
  return v !== null && typeof v === 'object' && !Array.isArray(v) &&
@@ -79,7 +178,7 @@ export async function buildRegistry(domainRoot: string): Promise<BuildResult> {
79
178
  const domainRaw = domainExports.find(e => e['_kind'] === 'domain');
80
179
  if (!domainRaw) {
81
180
  // Emit any suppressed tsx load errors to stderr before throwing so they appear in CI logs.
82
- for (const w of warnings) process.stderr.write(`[tib validate] ${w}\n`);
181
+ for (const w of warnings) process.stderr.write(`[mc-domain-module validate] ${w}\n`);
83
182
  throw new Error(`buildRegistry: no 'domain' export found in ${paths.domain}`);
84
183
  }
85
184
 
@@ -199,22 +298,55 @@ export async function buildRegistry(domainRoot: string): Promise<BuildResult> {
199
298
  }))
200
299
  );
201
300
 
202
- const actions: ActionRegistryEntry[] = paths.actions.flatMap((filePath, i) =>
301
+ const actions = paths.actions.flatMap((filePath, i) =>
203
302
  (actionExports[i] ?? [])
204
303
  .filter(e => e['_kind'] === 'action')
205
- .map(e => ({
206
- id: String(e['id']),
207
- kind: 'action' as const,
208
- handlerFile: relPath(filePath),
209
- visibility: String(e['visibility']) as 'private' | 'domain' | 'workspace',
210
- idempotent: Boolean(e['idempotent'] ?? false),
211
- description: typeof e['description'] === 'string' ? e['description'] : undefined,
212
- deployment: deployment(e),
213
- outboundAccess: outboundAccess(e),
214
- inputSchema: isJsonSchema(e['input']) ? (e['input'] as SchemaSnapshot) : undefined,
215
- outputSchema: isJsonSchema(e['output']) ? (e['output'] as SchemaSnapshot) : undefined,
216
- }))
217
- );
304
+ .map(e => {
305
+ // Resolve backendAccess. New-style actions carry `backendAccess`
306
+ // directly. Legacy actions only carry `visibility`; in that case
307
+ // we collapse workspace -> domain per the migration spec, and we
308
+ // also remember the legacy flag so we can default exposure to
309
+ // `{ type: 'internal' }` for actions that have not opted in yet.
310
+ const hasBackendAccess = 'backendAccess' in e;
311
+ const rawBackendAccess = e['backendAccess'];
312
+ const rawVisibility = e['visibility'];
313
+ const backendAccess: ActionBackendAccess = hasBackendAccess
314
+ ? toBackendAccess(rawBackendAccess)
315
+ : visibilityToBackendAccess(rawVisibility);
316
+ const legacyVisibility = backendAccessToVisibility(backendAccess);
317
+
318
+ // Resolve exposure. New-style actions must declare their exposure;
319
+ // legacy actions that did not opt in default to `{ type: 'internal' }`
320
+ // so existing internal-only behavior is preserved during migration.
321
+ const rawExposure = e['exposure'];
322
+ const exposure: ActionExposure = toExposure(rawExposure, { type: 'internal' });
323
+ // Record whether the source explicitly declared the `exposure` field.
324
+ // Validation-only; consumed by the domain CLI's validate command
325
+ // (Wave 6 Task 6.1) to enforce `ACTION_EXPOSURE_REQUIRED`.
326
+ const exposureDeclared = rawExposure !== undefined && rawExposure !== null
327
+ && typeof rawExposure === 'object';
328
+
329
+ return {
330
+ id: String(e['id']),
331
+ kind: 'action' as const,
332
+ handlerFile: relPath(filePath),
333
+ backendAccess,
334
+ exposure,
335
+ exposureDeclared,
336
+ // Keep the legacy field populated so CDK constructs that still
337
+ // read `visibility` (e.g. action-construct.ts) keep working
338
+ // through the migration window. New constructs should read
339
+ // `backendAccess` and `exposure` instead.
340
+ visibility: legacyVisibility,
341
+ idempotent: Boolean(e['idempotent'] ?? false),
342
+ description: typeof e['description'] === 'string' ? e['description'] : undefined,
343
+ deployment: deployment(e),
344
+ outboundAccess: outboundAccess(e),
345
+ inputSchema: isJsonSchema(e['input']) ? (e['input'] as SchemaSnapshot) : undefined,
346
+ outputSchema: isJsonSchema(e['output']) ? (e['output'] as SchemaSnapshot) : undefined,
347
+ };
348
+ })
349
+ ) as ActionRegistryEntry[];
218
350
 
219
351
  const integrations: IntegrationRegistryEntry[] = paths.integrations.flatMap((_filePath, i) =>
220
352
  (integrationExports[i] ?? [])
@@ -113,9 +113,9 @@ process.stdout.write(JSON.stringify(results));
113
113
  export async function loadModuleExports(absoluteFilePath: string): Promise<RawPrimitiveExport[]> {
114
114
  // Use a subdir of the project root rather than OS tmpdir so that ESM import
115
115
  // resolution can walk up and find node_modules packages like zod-to-json-schema.
116
- const tibTmpDir = join(process.cwd(), '.tib', 'tmp');
117
- await mkdir(tibTmpDir, { recursive: true }).catch(() => undefined);
118
- const tempPath = join(tibTmpDir, `tib-load-${randomBytes(8).toString('hex')}.mts`);
116
+ const mcTmpDir = join(process.cwd(), '.mc', 'tmp');
117
+ await mkdir(mcTmpDir, { recursive: true }).catch(() => undefined);
118
+ const tempPath = join(mcTmpDir, `mc-load-${randomBytes(8).toString('hex')}.mts`);
119
119
  await writeFile(tempPath, makeEvalScript(absoluteFilePath), 'utf8');
120
120
 
121
121
  try {
package/src/cli.ts CHANGED
@@ -82,9 +82,9 @@ program
82
82
  program
83
83
  .command('build-catalog')
84
84
  .description('Merge all per-domain registry files into .mc/domain-registry.json for TIB sync')
85
- .option('--tib-dir <path>', 'Path to the .tib directory (defaults to .tib in cwd)')
86
- .action(async (opts: { tibDir?: string }) => {
87
- await runBuildCatalog(opts.tibDir);
85
+ .option('--mc-dir <path>', 'Path to the .mc registry directory (defaults to .mc in cwd)')
86
+ .action(async (opts: { mcDir?: string }) => {
87
+ await runBuildCatalog(opts.mcDir);
88
88
  });
89
89
 
90
90
  program
@@ -95,23 +95,23 @@ export interface DomainCatalog {
95
95
  /**
96
96
  * Build the combined domain catalog from all per-domain registry files.
97
97
  * Reads .mc/{domain}-registry.json files and merges them into .mc/domain-registry.json.
98
- * @param tibDir - Path to the .tib directory. Defaults to .tib in cwd.
98
+ * @param registryDir - Path to the .mc registry directory. Defaults to .mc in cwd.
99
99
  * @returns The written catalog.
100
100
  */
101
- export async function runBuildCatalog(tibDir?: string): Promise<DomainCatalog> {
102
- const dir = tibDir ? resolve(tibDir) : join(process.cwd(), '.tib');
101
+ export async function runBuildCatalog(registryDir?: string): Promise<DomainCatalog> {
102
+ const dir = registryDir ? resolve(registryDir) : join(process.cwd(), '.mc');
103
103
 
104
104
  // Find all per-domain registry files
105
105
  let files: string[];
106
106
  try {
107
107
  files = await readdir(dir);
108
108
  } catch {
109
- throw new Error(`build-catalog: .tib directory not found at ${dir}. Run tib build first.`);
109
+ throw new Error(`build-catalog: .mc registry directory not found at ${dir}. Run mc-domain-module build first.`);
110
110
  }
111
111
 
112
112
  const registryFiles = files.filter(f => f.endsWith('-registry.json') && f !== 'domain-registry.json');
113
113
  if (registryFiles.length === 0) {
114
- throw new Error(`build-catalog: no domain registry files found in ${dir}. Run tib build <domain> first.`);
114
+ throw new Error(`build-catalog: no domain registry files found in ${dir}. Run mc-domain-module build <domain> first.`);
115
115
  }
116
116
 
117
117
  const catalog: DomainCatalog = {
@@ -285,7 +285,7 @@ async function loadFlowsFromDir(dir: string, owningDomainOverride?: string): Pro
285
285
  */
286
286
  export async function runBuildFlows(options: BuildFlowsOptions): Promise<string> {
287
287
  const projectRoot = options.projectRoot ?? process.cwd();
288
- const outFile = options.outFile ?? join(projectRoot, '.tib', 'flows-registry.json');
288
+ const outFile = options.outFile ?? join(projectRoot, '.mc', 'flows-registry.json');
289
289
 
290
290
  cliLogger.info({ projectRoot }, 'Building flows registry');
291
291
 
@@ -27,7 +27,7 @@ export async function runBuild(options: BuildOptions): Promise<string> {
27
27
  const domainId = basename(domainRoot);
28
28
  const outFile = options.outFile
29
29
  ? resolve(options.outFile)
30
- : join(process.cwd(), '.tib', `${domainId}-registry.json`);
30
+ : join(process.cwd(), '.mc', `${domainId}-registry.json`);
31
31
 
32
32
  cliLogger.info({ domainRoot }, 'Building domain registry');
33
33
 
@@ -43,15 +43,15 @@ export async function runBuild(options: BuildOptions): Promise<string> {
43
43
  cliLogger.info({ outFile, apis: registry.apis.length, events: registry.events.length }, 'Registry written');
44
44
 
45
45
  // Discover all sibling registries and emit aggregated types
46
- const tibDir = join(process.cwd(), '.tib');
47
- const registryFiles = (await readdir(tibDir)).filter(f => f.endsWith('-registry.json'));
46
+ const registryDir = join(outFile, '..');
47
+ const registryFiles = (await readdir(registryDir)).filter(f => f.endsWith('-registry.json'));
48
48
  const allRegistries: DomainRegistry[] = [];
49
49
  for (const f of registryFiles) {
50
- const raw = await readFile(join(tibDir, f), 'utf8');
50
+ const raw = await readFile(join(registryDir, f), 'utf8');
51
51
  allRegistries.push(JSON.parse(raw) as DomainRegistry);
52
52
  }
53
53
  const typesContent = buildActionsTypes(allRegistries);
54
- const typesFile = join(tibDir, 'actions-types.d.ts');
54
+ const typesFile = join(registryDir, 'actions-types.d.ts');
55
55
  await writeFile(typesFile, typesContent);
56
56
 
57
57
  cliLogger.info({ outFile: typesFile }, 'Types file written');
@@ -57,14 +57,14 @@ interface ModulesHashesManifest {
57
57
  */
58
58
  export async function runCheckHashes(opts: CheckHashesOptions = {}): Promise<CheckHashesResult> {
59
59
  const root = opts.projectRoot ?? process.cwd();
60
- const manifestPath = join(root, '.tib', 'modules-hashes.json');
60
+ const manifestPath = join(root, '.mc', 'modules-hashes.json');
61
61
  const modulesDir = join(root, 'infra', 'modules');
62
62
 
63
63
  let manifest: ModulesHashesManifest;
64
64
  try {
65
65
  manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as ModulesHashesManifest;
66
66
  } catch (err) {
67
- throw new Error(`tib check-hashes: cannot read ${manifestPath} (${(err as Error).message}). Re-scaffold to regenerate the manifest.`);
67
+ throw new Error(`mc-domain-module check-hashes: cannot read ${manifestPath} (${(err as Error).message}). Re-scaffold to regenerate the manifest.`);
68
68
  }
69
69
 
70
70
  const drifted: DriftedFile[] = [];
@@ -217,7 +217,7 @@ export async function runCreateProject(opts: CreateProjectOptions): Promise<void
217
217
 
218
218
  // 5. Prepare output directory
219
219
  await mkdir(outputDir, { recursive: true });
220
- await mkdir(join(outputDir, '.tib'), { recursive: true });
220
+ await mkdir(join(outputDir, '.mc'), { recursive: true });
221
221
 
222
222
  // 6. Get CLI version from package.json
223
223
  let cliVersion = '0.0.0';
@@ -18,15 +18,13 @@ export const DOCTOR_FIX_FLAG = '--fix';
18
18
  export interface DoctorOptions {
19
19
  /** Root directory of the project (defaults to cwd). */
20
20
  projectRoot?: string;
21
- /** Flag to run relocation of old-layout scaffold files to new layout. */
21
+ /** Deprecated legacy flag retained as a no-op for older automation. */
22
22
  relocate?: boolean;
23
23
  /**
24
- * Auto-remediate before reporting. --fix is a superset of --relocate: it
25
- * first runs the relocation routine (auto-moves owned scaffold files outside
26
- * .mc/ into .mc/ and updates .mc/manifest.json), then continues with the
27
- * full doctor report. On a project that is already clean, --fix is a no-op
28
- * (0 files moved, exit 0). Does NOT auto-generate missing fixtures
29
- * (deferred).
24
+ * Auto-remediate before reporting. The legacy relocation routine is now a
25
+ * no-op because current scaffold manifests intentionally track managed files
26
+ * in their installed project locations (infra/modules, domains, .github, etc.).
27
+ * Does NOT auto-generate missing fixtures (deferred).
30
28
  */
31
29
  fix?: boolean;
32
30
  /**
@@ -224,16 +222,15 @@ async function checkGeneratedClientsFresh(projectRoot: string): Promise<DoctorCh
224
222
  export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
225
223
  const projectRoot = opts.projectRoot ?? process.cwd();
226
224
 
227
- // --relocate runs ONLY the relocation routine (existing behaviour).
228
- // --fix is a superset: it runs relocation first, then the full report.
225
+ // --relocate is retained as a safe no-op for older automation.
226
+ // --fix runs that no-op preflight first, then the full report.
229
227
  // When both are set, --fix takes precedence so the report still runs.
230
228
  if (opts.relocate && !opts.fix) {
231
229
  return runRelocate(projectRoot);
232
230
  }
233
231
 
234
- // --fix preflight: run the relocation routine and merge its checks into
235
- // the doctor report. On a clean project (no files outside .mc/ to move)
236
- // runRelocate returns a no-op summary check (0 moved, 0 warned, 8 skipped).
232
+ // --fix preflight: run the legacy relocation no-op and merge its check into
233
+ // the doctor report.
237
234
  let preflightChecks: DoctorCheck[] = [];
238
235
  if (opts.fix) {
239
236
  const relocationReport = await runRelocate(projectRoot);
@@ -568,14 +565,22 @@ async function checkApisHaveTenancy(projectRoot: string): Promise<DoctorCheck> {
568
565
 
569
566
  async function checkScaffoldConfigMatchesDisk(projectRoot: string): Promise<DoctorCheck> {
570
567
  try {
571
- const configPath = join(projectRoot, '.tib', 'scaffold-config.json');
568
+ const configPath = join(projectRoot, '.mc', 'scaffold-config.json');
572
569
  const domainsDir = join(projectRoot, 'domains');
573
570
 
574
571
  let configDomains: string[] = [];
575
572
  try {
576
573
  const configContent = await readFile(configPath, 'utf8');
577
574
  const config = JSON.parse(configContent) as { domainIds?: string[] };
578
- configDomains = config.domainIds ?? [];
575
+ if (!Array.isArray(config.domainIds)) {
576
+ return {
577
+ name: 'scaffold-config.json matches on-disk',
578
+ status: 'PASS',
579
+ message: '.mc/scaffold-config.json has no domainIds array — domain list sync check skipped',
580
+ kNodeRef: 'K:runbook:add-domain',
581
+ };
582
+ }
583
+ configDomains = config.domainIds;
579
584
  } catch {
580
585
  return {
581
586
  name: 'scaffold-config.json matches on-disk',
@@ -1205,7 +1210,7 @@ async function checkDomainBrainReachable(projectRoot: string): Promise<DoctorChe
1205
1210
  */
1206
1211
  async function checkActionsHaveTypes(projectRoot: string): Promise<DoctorCheck> {
1207
1212
  try {
1208
- const typesPath = join(projectRoot, '.tib', 'actions-types.d.ts');
1213
+ const typesPath = join(projectRoot, '.mc', 'actions-types.d.ts');
1209
1214
  const domainsDir = join(projectRoot, 'domains');
1210
1215
  const discoveredActions: string[] = [];
1211
1216
 
@@ -1435,7 +1440,7 @@ async function checkSeedPagesHaveNoHeaders(projectRoot: string): Promise<DoctorC
1435
1440
 
1436
1441
  /**
1437
1442
  * Check Part C: Layout validation checks
1438
- * 1. owned files in unexpected locations
1443
+ * 1. managed files are not registered in obsolete legacy locations
1439
1444
  * 2. tracked/seed files inside .mc/
1440
1445
  * 3. manifest entries pointing at missing files
1441
1446
  * 4. scaffold-tracked files (with @mc-scaffold: header) that lack manifest entries
@@ -1452,26 +1457,29 @@ async function checkLayoutPolicy(projectRoot: string): Promise<DoctorCheck[]> {
1452
1457
  return [];
1453
1458
  }
1454
1459
 
1455
- // Check 1: owned files in unexpected locations
1456
- // Policy: owned files should be in .mc/ OR .github/workflows/tib-*
1457
- const ownedOutsideTib = manifest.files.filter(f =>
1458
- f.policy === 'managed' &&
1459
- !f.path.startsWith('.mc/') &&
1460
- !/^\.github\/workflows\/tib-/.test(f.path)
1460
+ // Check 1: managed files should not be registered in obsolete legacy
1461
+ // locations. Current scaffold manifests intentionally track managed files at
1462
+ // their installed paths (infra/modules, domains, .github, CLAUDE.md, etc.).
1463
+ const legacyManagedLocations = manifest.files.filter(f =>
1464
+ f.policy === 'managed' && (
1465
+ f.path.startsWith('.tib/') ||
1466
+ f.path.startsWith('.mc/infra/') ||
1467
+ f.path === 'mc-deploy.yml'
1468
+ )
1461
1469
  );
1462
- if (ownedOutsideTib.length === 0) {
1470
+ if (legacyManagedLocations.length === 0) {
1463
1471
  results.push({
1464
- name: 'Layout: owned files in .mc/',
1472
+ name: 'Layout: no managed files in legacy locations',
1465
1473
  status: 'PASS',
1466
- message: 'All owned scaffold files are under .mc/ or .github/workflows/tib-*',
1474
+ message: 'Managed scaffold files use current manifest paths',
1467
1475
  });
1468
1476
  } else {
1469
- for (const f of ownedOutsideTib) {
1477
+ for (const f of legacyManagedLocations) {
1470
1478
  results.push({
1471
- name: 'Layout: owned files in .mc/',
1479
+ name: 'Layout: no managed files in legacy locations',
1472
1480
  status: 'FAIL',
1473
- message: `Owned scaffold file outside .mc/: ${f.path}`,
1474
- fixHint: `Run 'tib doctor --relocate' to migrate ${f.path} to .mc/`,
1481
+ message: `Managed scaffold file registered in legacy location: ${f.path}`,
1482
+ fixHint: 'Run `npx mc-domain-module upgrade` to refresh scaffold metadata',
1475
1483
  file: f.path,
1476
1484
  });
1477
1485
  }
@@ -1521,7 +1529,7 @@ async function checkLayoutPolicy(projectRoot: string): Promise<DoctorCheck[]> {
1521
1529
  name: 'Layout: manifest entries exist on disk',
1522
1530
  status: 'FAIL',
1523
1531
  message: `${missingFiles.length} manifest entry/entries point to missing files:\n ${missingFiles.join('\n ')}`,
1524
- fixHint: 'Run tib doctor --relocate or tib upgrade to fix missing files',
1532
+ fixHint: 'Run `npx mc-domain-module upgrade` to refresh scaffold metadata, or restore the missing files',
1525
1533
  });
1526
1534
  }
1527
1535
 
@@ -1566,7 +1574,7 @@ async function checkLayoutPolicy(projectRoot: string): Promise<DoctorCheck[]> {
1566
1574
  name: 'Layout: scaffold-tracked files have manifest entries',
1567
1575
  status: 'WARN',
1568
1576
  message: `${untracked.length} file(s) with @mc-scaffold: header not in manifest:\n ${untracked.join('\n ')}`,
1569
- fixHint: 'Run tib upgrade or tib doctor --relocate to register these files',
1577
+ fixHint: 'Run `npx mc-domain-module upgrade` to refresh scaffold metadata',
1570
1578
  });
1571
1579
  }
1572
1580
 
@@ -1574,156 +1582,19 @@ async function checkLayoutPolicy(projectRoot: string): Promise<DoctorCheck[]> {
1574
1582
  }
1575
1583
 
1576
1584
  /**
1577
- * Old path new path mapping for known scaffold files
1578
- */
1579
- const RELOCATION_MAP: Array<{ oldPath: string; newPath: string; policy: 'managed' | 'editable' | 'seed' }> = [
1580
- { oldPath: 'infra/modules/app.ts', newPath: '.mc/infra/modules/app.ts', policy: 'managed' },
1581
- { oldPath: 'infra/modules/shared/SharedStack.ts', newPath: '.mc/infra/modules/shared/SharedStack.ts', policy: 'managed' },
1582
- { oldPath: 'infra/modules/domains/dispatch-middleware.ts', newPath: '.mc/infra/modules/domains/dispatch-middleware.ts', policy: 'managed' },
1583
- { oldPath: 'infra/modules/PowerTuningStack.ts', newPath: '.mc/infra/modules/PowerTuningStack.ts', policy: 'managed' },
1584
- { oldPath: 'infra/cdk.json', newPath: '.mc/infra/cdk.json', policy: 'managed' },
1585
- { oldPath: 'infra/tsconfig.json', newPath: '.mc/infra/tsconfig.json', policy: 'managed' },
1586
- { oldPath: 'infra/package.json', newPath: '.mc/infra/package.json', policy: 'managed' },
1587
- { oldPath: 'mc-deploy.yml', newPath: '.github/workflows/mc-deploy.yml', policy: 'managed' },
1588
- ];
1589
-
1590
- /**
1591
- * Run relocation of old-layout scaffold files to new layout
1585
+ * Legacy relocation entrypoint retained for older automation.
1586
+ *
1587
+ * Earlier scaffold versions tried to move generated infra into .mc/infra. The
1588
+ * current scaffold contract keeps generated infra under infra/modules and uses
1589
+ * .mc/manifest.json plus .mc/modules-hashes.json for ownership/drift tracking.
1590
+ * Moving files here would corrupt modern projects, so relocation is now a no-op.
1592
1591
  */
1593
1592
  async function runRelocate(projectRoot: string): Promise<DoctorReport> {
1594
- const checks: DoctorCheck[] = [];
1595
-
1596
- const { readManifest, writeManifest, upsertManifestFile } = await import('../utils/manifest.js');
1597
- const { readFile, writeFile, mkdir, unlink } = await import('node:fs/promises');
1598
- const { computeChecksumFile, computeChecksumString } = await import('../utils/checksum.js');
1599
- const { join, dirname } = await import('node:path');
1600
-
1601
- const manifest = await readManifest(projectRoot);
1602
- if (!manifest) {
1603
- checks.push({
1604
- name: 'Relocate: manifest present',
1605
- status: 'FAIL',
1606
- message: '.mc/manifest.json not found — cannot relocate without a manifest',
1607
- fixHint: 'This project may not be a TIB scaffold project',
1608
- });
1609
- return { checks, exitCode: 1, pass: false };
1610
- }
1611
-
1612
- let moved = 0;
1613
- let warned = 0;
1614
- let skipped = 0;
1615
-
1616
- for (const { oldPath, newPath, policy } of RELOCATION_MAP) {
1617
- const oldAbsPath = join(projectRoot, oldPath);
1618
- const newAbsPath = join(projectRoot, newPath);
1619
-
1620
- // Check if old file exists
1621
- const { access: fsAccess } = await import('node:fs/promises');
1622
- try { await fsAccess(oldAbsPath); } catch { skipped++; continue; }
1623
-
1624
- // Check if it has the scaffold header (confirm it's scaffold-owned)
1625
- let content: string;
1626
- try {
1627
- content = await readFile(oldAbsPath, 'utf-8');
1628
- } catch {
1629
- skipped++;
1630
- continue;
1631
- }
1632
-
1633
- if (!content.includes('@mc-scaffold:')) {
1634
- // File exists but no header — user-owned, leave it
1635
- checks.push({
1636
- name: 'Relocate: checking ' + oldPath,
1637
- status: 'WARN',
1638
- message: `${oldPath} has no @mc-scaffold: header — left in place (user-owned)`,
1639
- file: oldPath,
1640
- });
1641
- warned++;
1642
- continue;
1643
- }
1644
-
1645
- // Check for user drift
1646
- const manifestEntry = manifest.files.find(f => f.path === oldPath);
1647
- if (manifestEntry) {
1648
- const diskSha = await computeChecksumFile(oldAbsPath);
1649
- if (diskSha && diskSha !== manifestEntry.sha256) {
1650
- // File has been modified — leave with warning
1651
- checks.push({
1652
- name: 'Relocate: checking ' + oldPath,
1653
- status: 'WARN',
1654
- message: `${oldPath} has local modifications — left in place. Migrate manually: move to ${newPath}`,
1655
- file: oldPath,
1656
- });
1657
- warned++;
1658
- continue;
1659
- }
1660
- }
1661
-
1662
- // Check if destination already exists
1663
- try {
1664
- await fsAccess(newAbsPath);
1665
- checks.push({
1666
- name: 'Relocate: ' + oldPath,
1667
- status: 'WARN',
1668
- message: `${newPath} already exists — skipping move of ${oldPath}`,
1669
- file: newPath,
1670
- });
1671
- warned++;
1672
- continue;
1673
- } catch { /* destination doesn't exist, good */ }
1674
-
1675
- // Move the file
1676
- try {
1677
- await mkdir(dirname(newAbsPath), { recursive: true });
1678
- // Copy + delete (rename may fail across filesystems)
1679
- await writeFile(newAbsPath, content, 'utf-8');
1680
-
1681
- // Update manifest: remove old entry, add new with new path + policy
1682
- manifest.files = manifest.files.filter(f => f.path !== oldPath);
1683
- const sha256 = manifestEntry?.sha256 ?? computeChecksumString(content);
1684
- upsertManifestFile(manifest, {
1685
- path: newPath,
1686
- module: manifestEntry?.module ?? 'domain',
1687
- moduleVersion: manifestEntry?.moduleVersion ?? '1.0.0',
1688
- sha256,
1689
- wasTemplate: manifestEntry?.wasTemplate ?? false,
1690
- installedAt: manifestEntry?.installedAt ?? new Date().toISOString(),
1691
- policy,
1692
- });
1693
-
1694
- // Delete old file
1695
- await unlink(oldAbsPath);
1696
-
1697
- checks.push({
1698
- name: 'Relocate: ' + oldPath,
1699
- status: 'PASS',
1700
- message: `Moved ${oldPath} → ${newPath} (policy: ${policy})`,
1701
- file: newPath,
1702
- });
1703
- moved++;
1704
- } catch (err) {
1705
- checks.push({
1706
- name: 'Relocate: ' + oldPath,
1707
- status: 'FAIL',
1708
- message: `Failed to move ${oldPath} → ${newPath}: ${String(err)}`,
1709
- file: oldPath,
1710
- });
1711
- }
1712
- }
1713
-
1714
- // Write updated manifest
1715
- if (moved > 0) {
1716
- await writeManifest(projectRoot, manifest);
1717
- }
1718
-
1719
- // Summary check
1720
- checks.push({
1721
- name: 'Relocate: summary',
1722
- status: warned > 0 ? 'WARN' : 'PASS',
1723
- message: `Relocation complete: ${moved} file(s) moved, ${warned} warning(s), ${skipped} skipped (not present)`,
1724
- });
1725
-
1726
- const hasFail = checks.some(c => c.status === 'FAIL');
1727
- const exitCode = hasFail ? 1 : 0;
1728
- return { checks, exitCode, pass: exitCode === 0 };
1593
+ void projectRoot;
1594
+ const checks: DoctorCheck[] = [{
1595
+ name: 'Relocate: deprecated no-op',
1596
+ status: 'PASS',
1597
+ message: 'No files moved. Current scaffold layout keeps generated files at their installed paths and tracks ownership via .mc/manifest.json.',
1598
+ }];
1599
+ return { checks, exitCode: 0, pass: true };
1729
1600
  }
@@ -5,13 +5,13 @@ import { cliLogger } from '../utils/logger.js';
5
5
 
6
6
  export async function runPowerTune(domain: string, primitive: string): Promise<void> {
7
7
  // Load registry to find Lambda ARN
8
- const registryPath = path.join(process.cwd(), '.tib', `${domain}-registry.json`);
8
+ const registryPath = path.join(process.cwd(), '.mc', `${domain}-registry.json`);
9
9
 
10
10
  let registry: { domain: { id: string }; functionArns?: Record<string, string> };
11
11
  try {
12
12
  registry = JSON.parse(readFileSync(registryPath, 'utf8'));
13
13
  } catch {
14
- cliLogger.error({}, `Registry not found at ${registryPath}. Run: tib build domains/${domain}`);
14
+ cliLogger.error({}, `Registry not found at ${registryPath}. Run: npx mc-domain-module build domains/${domain}`);
15
15
  process.exit(1);
16
16
  }
17
17