@massu/core 1.2.1 → 1.3.0

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.
@@ -8,12 +8,31 @@
8
8
  * Copies all massu assets from the npm package into the project's .claude/
9
9
  * directory. Existing massu files are updated; non-massu files are preserved.
10
10
  * Handles subdirectories recursively (e.g., golden-path/references/).
11
+ *
12
+ * v1.3.0 — Stack-aware variants + local-edit protection (manifest):
13
+ * - Variant resolution at the top level of `commands/`: a template named
14
+ * `<base>.<variant>.md` is preferred over `<base>.md` when the consumer's
15
+ * `massu.config.yaml` declares a matching language. See `pickVariant`.
16
+ * - Local edits are preserved across reinstalls via a per-consumer manifest
17
+ * (`<claudeDir>/.massu/install-manifest.json`) that records the SHA-256 of
18
+ * each file at last install. See "Layer 3: Local-edit protection" in the
19
+ * 2026-04-26 plan doc.
11
20
  */
12
21
 
13
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
22
+ import {
23
+ existsSync,
24
+ readFileSync,
25
+ writeFileSync,
26
+ mkdirSync,
27
+ readdirSync,
28
+ statSync,
29
+ renameSync,
30
+ } from 'fs';
14
31
  import { resolve, dirname, relative, join } from 'path';
15
32
  import { fileURLToPath } from 'url';
33
+ import { createHash } from 'crypto';
16
34
  import { getConfig } from '../config.ts';
35
+ import type { Config } from '../config.ts';
17
36
 
18
37
  const __filename = fileURLToPath(import.meta.url);
19
38
  const __dirname = dirname(__filename);
@@ -68,6 +87,179 @@ export function resolveCommandsDir(): string | null {
68
87
  return resolveAssetDir('commands');
69
88
  }
70
89
 
90
+ // ============================================================
91
+ // Manifest (local-edit protection)
92
+ // ============================================================
93
+
94
+ const MANIFEST_VERSION = 1;
95
+ const MANIFEST_RELPATH = join('.massu', 'install-manifest.json');
96
+
97
+ /** Manifest file shape — see plan §"Manifest JSON shape". */
98
+ export interface Manifest {
99
+ version: number;
100
+ generatedBy: string;
101
+ generatedAt: string;
102
+ /** key: path relative to the consumer's claudeDir; value: SHA-256 hex digest. */
103
+ entries: Record<string, string>;
104
+ }
105
+
106
+ /** SHA-256 hex digest of a string. */
107
+ export function hashContent(content: string): string {
108
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
109
+ }
110
+
111
+ /** Load the manifest from `<claudeDir>/.massu/install-manifest.json`, or return an empty manifest. */
112
+ export function loadManifest(claudeDir: string): Manifest {
113
+ const path = resolve(claudeDir, MANIFEST_RELPATH);
114
+ if (!existsSync(path)) {
115
+ return emptyManifest();
116
+ }
117
+ try {
118
+ const raw = readFileSync(path, 'utf-8');
119
+ const parsed = JSON.parse(raw) as Manifest;
120
+ if (!parsed || typeof parsed !== 'object' || !parsed.entries) {
121
+ return emptyManifest();
122
+ }
123
+ return parsed;
124
+ } catch {
125
+ return emptyManifest();
126
+ }
127
+ }
128
+
129
+ /** Write the manifest atomically: tempfile + renameSync. */
130
+ export function saveManifest(claudeDir: string, manifest: Manifest): void {
131
+ const dir = resolve(claudeDir, '.massu');
132
+ if (!existsSync(dir)) {
133
+ mkdirSync(dir, { recursive: true });
134
+ }
135
+ const finalPath = resolve(dir, 'install-manifest.json');
136
+ const tempPath = finalPath + '.tmp';
137
+ manifest.generatedAt = new Date().toISOString();
138
+ writeFileSync(tempPath, JSON.stringify(manifest, null, 2), 'utf-8');
139
+ renameSync(tempPath, finalPath);
140
+ }
141
+
142
+ function emptyManifest(): Manifest {
143
+ return {
144
+ version: MANIFEST_VERSION,
145
+ generatedBy: '@massu/core',
146
+ generatedAt: new Date().toISOString(),
147
+ entries: {},
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Run a function with the manifest loaded; persist atomically afterward.
153
+ * Used by both `installAll` and the legacy `installCommands` so any caller
154
+ * of either entry point gets the manifest written exactly once per run.
155
+ */
156
+ export function runWithManifest<T>(claudeDir: string, fn: (m: Manifest) => T): T {
157
+ const manifest = loadManifest(claudeDir);
158
+ const result = fn(manifest);
159
+ saveManifest(claudeDir, manifest);
160
+ return result;
161
+ }
162
+
163
+ // ============================================================
164
+ // Variant Resolution (Phase 1)
165
+ // ============================================================
166
+
167
+ /** Discriminated-union return shape for `pickVariant`. */
168
+ export type PickVariantResult =
169
+ | { kind: 'hit'; suffix: string } // found a variant (suffix may be "")
170
+ | { kind: 'miss' } // no candidate found, caller SKIPS the file
171
+ | { kind: 'fallback'; reason: string }; // misconfig / safe fallback, caller copies UNSUFFIXED default
172
+
173
+ /** Well-known language keys for the passthrough-fallback step in `pickVariant`. */
174
+ const PASSTHROUGH_LANG_KEYS = [
175
+ 'typescript',
176
+ 'javascript',
177
+ 'python',
178
+ 'swift',
179
+ 'rust',
180
+ 'go',
181
+ ] as const;
182
+
183
+ /**
184
+ * Choose the variant suffix for a base template name.
185
+ *
186
+ * Priority order (per plan §"Variant resolution algorithm"):
187
+ * 1. `framework.primary` (or `framework.type` if primary undefined)
188
+ * 2. Each declared `framework.languages.<lang>` entry with a non-empty `framework`,
189
+ * in YAML declaration order.
190
+ * 3. Passthrough fallback: well-known top-level `framework.<lang>` blocks with a
191
+ * non-empty `framework` field, in fixed order, excluding entries already covered.
192
+ * 4. The unsuffixed default ("").
193
+ *
194
+ * The function NEVER throws. It returns a discriminated union so the caller can
195
+ * distinguish "skip this file" from "copy the default" — see plan §"Error semantics".
196
+ */
197
+ export function pickVariant(
198
+ baseName: string,
199
+ sourceDir: string,
200
+ framework: Config['framework'],
201
+ ): PickVariantResult {
202
+ const candidates: string[] = [];
203
+
204
+ // 1. framework.primary (or fall back to framework.type)
205
+ const primary = framework.primary ?? framework.type;
206
+ if (primary && primary !== 'multi') {
207
+ candidates.push(primary);
208
+ }
209
+
210
+ // 2. framework.languages declaration order
211
+ if (framework.languages) {
212
+ for (const lang of Object.keys(framework.languages)) {
213
+ const entry = framework.languages[lang];
214
+ if (entry && typeof entry.framework === 'string' && entry.framework.length > 0) {
215
+ if (!candidates.includes(lang)) {
216
+ candidates.push(lang);
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ // 3. Passthrough fallback — `framework.<lang>` (top-level passthrough block).
223
+ // Zod's `.passthrough()` preserves these at runtime even though they are not
224
+ // typed members of `Config['framework']`. Reading via a Record-shaped widening
225
+ // avoids `any`/`@ts-ignore`.
226
+ const passthrough = framework as unknown as Record<string, unknown>;
227
+ for (const lang of PASSTHROUGH_LANG_KEYS) {
228
+ if (candidates.includes(lang)) continue;
229
+ const block = passthrough[lang];
230
+ if (block && typeof block === 'object') {
231
+ const fw = (block as { framework?: unknown }).framework;
232
+ if (typeof fw === 'string' && fw.length > 0) {
233
+ candidates.push(lang);
234
+ }
235
+ }
236
+ }
237
+
238
+ // 4. Probe disk
239
+ for (const cand of candidates) {
240
+ const path = resolve(sourceDir, `${baseName}.${cand}.md`);
241
+ if (existsSync(path)) {
242
+ return { kind: 'hit', suffix: `.${cand}` };
243
+ }
244
+ }
245
+ // Unsuffixed default
246
+ const defaultPath = resolve(sourceDir, `${baseName}.md`);
247
+ if (existsSync(defaultPath)) {
248
+ return { kind: 'hit', suffix: '' };
249
+ }
250
+
251
+ // No hit. Risk #7: framework.type=multi without primary → safe fallback.
252
+ if (framework.type === 'multi' && !framework.primary) {
253
+ process.stderr.write(
254
+ 'massu: warning - framework.type=multi but framework.primary is undefined; ' +
255
+ 'falling back to default templates\n',
256
+ );
257
+ return { kind: 'fallback', reason: 'multi-without-primary' };
258
+ }
259
+
260
+ return { kind: 'miss' };
261
+ }
262
+
71
263
  // ============================================================
72
264
  // Recursive File Sync
73
265
  // ============================================================
@@ -76,14 +268,36 @@ interface SyncStats {
76
268
  installed: number;
77
269
  updated: number;
78
270
  skipped: number;
271
+ kept: number;
272
+ }
273
+
274
+ /** Returns true if a top-level entry name has the `<base>.<variant>.md` shape. */
275
+ function isVariantFilename(entry: string): boolean {
276
+ // Match exactly one inner dot before `.md`. `_shared-preamble.md` (no inner dot) survives.
277
+ return /^[^.]+\.[^.]+\.md$/.test(entry);
79
278
  }
80
279
 
81
280
  /**
82
281
  * Recursively sync all .md files from sourceDir to targetDir.
83
- * Creates subdirectories as needed. Preserves non-massu files.
282
+ *
283
+ * At top level (`topLevel === true`), apply variant resolution:
284
+ * - Skip entries that match `<base>.<variant>.md` (the variant siblings are
285
+ * selected indirectly via `pickVariant` so they never land in the consumer
286
+ * dir directly).
287
+ * - For each base entry `<base>.md`, call `pickVariant` to choose the source.
288
+ *
289
+ * At depth ≥ 1 (subdirectory recursion), copy files as-is — no variant logic,
290
+ * no dot-skip filter (so future authors can use dotted filenames in subdirs).
84
291
  */
85
- function syncDirectory(sourceDir: string, targetDir: string): SyncStats {
86
- const stats: SyncStats = { installed: 0, updated: 0, skipped: 0 };
292
+ export function syncDirectory(
293
+ sourceDir: string,
294
+ targetDir: string,
295
+ framework: Config['framework'],
296
+ manifest: Manifest,
297
+ manifestKeyPrefix: string,
298
+ topLevel: boolean = true,
299
+ ): SyncStats {
300
+ const stats: SyncStats = { installed: 0, updated: 0, skipped: 0, kept: 0 };
87
301
 
88
302
  if (!existsSync(targetDir)) {
89
303
  mkdirSync(targetDir, { recursive: true });
@@ -93,30 +307,109 @@ function syncDirectory(sourceDir: string, targetDir: string): SyncStats {
93
307
 
94
308
  for (const entry of entries) {
95
309
  const sourcePath = resolve(sourceDir, entry);
96
- const targetPath = resolve(targetDir, entry);
97
310
  const entryStat = statSync(sourcePath);
98
311
 
99
312
  if (entryStat.isDirectory()) {
100
- // Recurse into subdirectories
101
- const subStats = syncDirectory(sourcePath, targetPath);
313
+ // Recurse depth > 0 disables variant filtering for nested files.
314
+ const subTargetDir = resolve(targetDir, entry);
315
+ const subPrefix = manifestKeyPrefix === ''
316
+ ? entry
317
+ : `${manifestKeyPrefix}/${entry}`;
318
+ const subStats = syncDirectory(
319
+ sourcePath,
320
+ subTargetDir,
321
+ framework,
322
+ manifest,
323
+ subPrefix,
324
+ false,
325
+ );
102
326
  stats.installed += subStats.installed;
103
327
  stats.updated += subStats.updated;
104
328
  stats.skipped += subStats.skipped;
105
- } else if (entry.endsWith('.md')) {
106
- const sourceContent = readFileSync(sourcePath, 'utf-8');
107
-
108
- if (existsSync(targetPath)) {
109
- const existingContent = readFileSync(targetPath, 'utf-8');
110
- if (existingContent === sourceContent) {
111
- stats.skipped++;
112
- continue;
113
- }
114
- writeFileSync(targetPath, sourceContent, 'utf-8');
115
- stats.updated++;
116
- } else {
117
- writeFileSync(targetPath, sourceContent, 'utf-8');
118
- stats.installed++;
329
+ stats.kept += subStats.kept;
330
+ continue;
331
+ }
332
+
333
+ if (!entry.endsWith('.md')) continue;
334
+
335
+ let sourceFilename = entry;
336
+ let baseName = entry.slice(0, -'.md'.length);
337
+
338
+ if (topLevel) {
339
+ // Skip variant siblings — they are selected indirectly via the base name.
340
+ if (isVariantFilename(entry)) continue;
341
+
342
+ const choice = pickVariant(baseName, sourceDir, framework);
343
+ if (choice.kind === 'miss') {
344
+ // No file to copy.
345
+ continue;
119
346
  }
347
+ // 'hit' or 'fallback' both copy a file:
348
+ // - 'hit' uses the chosen suffix (may be "")
349
+ // - 'fallback' copies the unsuffixed default (same as suffix === "")
350
+ const suffix = choice.kind === 'hit' ? choice.suffix : '';
351
+ sourceFilename = suffix === '' ? `${baseName}.md` : `${baseName}${suffix}.md`;
352
+ }
353
+
354
+ const resolvedSourcePath = resolve(sourceDir, sourceFilename);
355
+ if (!existsSync(resolvedSourcePath)) {
356
+ // Defensive: pickVariant said hit, but file vanished between probe and read.
357
+ continue;
358
+ }
359
+
360
+ // Target filename is always the BASE name (variant suffix is internal to the package).
361
+ const targetFilename = topLevel ? `${baseName}.md` : entry;
362
+ const targetPath = resolve(targetDir, targetFilename);
363
+ const sourceContent = readFileSync(resolvedSourcePath, 'utf-8');
364
+ const sourceHash = hashContent(sourceContent);
365
+
366
+ const manifestKey = manifestKeyPrefix === ''
367
+ ? targetFilename
368
+ : `${manifestKeyPrefix}/${targetFilename}`;
369
+ const lastInstalledHash = manifest.entries[manifestKey];
370
+
371
+ if (existsSync(targetPath)) {
372
+ const existingContent = readFileSync(targetPath, 'utf-8');
373
+ const existingHash = hashContent(existingContent);
374
+
375
+ if (existingHash === sourceHash) {
376
+ // Already byte-identical to upstream. Ensure manifest reflects that.
377
+ manifest.entries[manifestKey] = sourceHash;
378
+ stats.skipped++;
379
+ continue;
380
+ }
381
+
382
+ if (lastInstalledHash === undefined) {
383
+ // First-install ambiguity: file exists but no manifest entry.
384
+ // Treat as user-edited: keep, record existing hash, print one-line notice.
385
+ manifest.entries[manifestKey] = existingHash;
386
+ process.stderr.write(
387
+ `First-install heuristic: keeping existing ${targetPath} (differs from upstream).\n` +
388
+ ` To accept upstream: rm ${targetPath} && npx massu install-commands\n`,
389
+ );
390
+ stats.kept++;
391
+ continue;
392
+ }
393
+
394
+ if (existingHash !== lastInstalledHash) {
395
+ // User edited it after the last install. Preserve.
396
+ process.stderr.write(
397
+ `${targetFilename} has local edits - kept your version.\n` +
398
+ ` To accept upstream: rm ${targetPath} && npx massu install-commands\n` +
399
+ ` To diff: diff ${targetPath} <(npx massu show-template ${baseName})\n`,
400
+ );
401
+ stats.kept++;
402
+ continue;
403
+ }
404
+
405
+ // existingHash === lastInstalledHash and sourceHash differs → safe upgrade.
406
+ writeFileSync(targetPath, sourceContent, 'utf-8');
407
+ manifest.entries[manifestKey] = sourceHash;
408
+ stats.updated++;
409
+ } else {
410
+ writeFileSync(targetPath, sourceContent, 'utf-8');
411
+ manifest.entries[manifestKey] = sourceHash;
412
+ stats.installed++;
120
413
  }
121
414
  }
122
415
 
@@ -131,12 +424,14 @@ export interface InstallCommandsResult {
131
424
  installed: number;
132
425
  updated: number;
133
426
  skipped: number;
427
+ kept: number;
134
428
  commandsDir: string;
135
429
  }
136
430
 
137
431
  export function installCommands(projectRoot: string): InstallCommandsResult {
138
432
  const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
139
- const targetDir = resolve(projectRoot, claudeDirName, 'commands');
433
+ const claudeDir = resolve(projectRoot, claudeDirName);
434
+ const targetDir = resolve(claudeDir, 'commands');
140
435
 
141
436
  if (!existsSync(targetDir)) {
142
437
  mkdirSync(targetDir, { recursive: true });
@@ -146,10 +441,13 @@ export function installCommands(projectRoot: string): InstallCommandsResult {
146
441
  if (!sourceDir) {
147
442
  console.error(' ERROR: Could not find massu commands directory.');
148
443
  console.error(' Try reinstalling: npm install @massu/core');
149
- return { installed: 0, updated: 0, skipped: 0, commandsDir: targetDir };
444
+ return { installed: 0, updated: 0, skipped: 0, kept: 0, commandsDir: targetDir };
150
445
  }
151
446
 
152
- const stats = syncDirectory(sourceDir, targetDir);
447
+ const framework = getConfig().framework;
448
+ const stats = runWithManifest(claudeDir, (manifest) =>
449
+ syncDirectory(sourceDir, targetDir, framework, manifest, 'commands', true),
450
+ );
153
451
  return { ...stats, commandsDir: targetDir };
154
452
  }
155
453
 
@@ -162,6 +460,7 @@ export interface InstallAllResult {
162
460
  totalInstalled: number;
163
461
  totalUpdated: number;
164
462
  totalSkipped: number;
463
+ totalKept: number;
165
464
  claudeDir: string;
166
465
  }
167
466
 
@@ -173,23 +472,41 @@ export function installAll(projectRoot: string): InstallAllResult {
173
472
  let totalInstalled = 0;
174
473
  let totalUpdated = 0;
175
474
  let totalSkipped = 0;
176
-
177
- for (const assetType of ASSET_TYPES) {
178
- const sourceDir = resolveAssetDir(assetType.name);
179
- if (!sourceDir) {
180
- continue;
475
+ let totalKept = 0;
476
+
477
+ const framework = getConfig().framework;
478
+
479
+ runWithManifest(claudeDir, (manifest) => {
480
+ for (const assetType of ASSET_TYPES) {
481
+ const sourceDir = resolveAssetDir(assetType.name);
482
+ if (!sourceDir) continue;
483
+
484
+ const targetDir = resolve(claudeDir, assetType.targetSubdir);
485
+ const stats = syncDirectory(
486
+ sourceDir,
487
+ targetDir,
488
+ framework,
489
+ manifest,
490
+ assetType.targetSubdir,
491
+ true,
492
+ );
493
+
494
+ assets[assetType.name] = stats;
495
+ totalInstalled += stats.installed;
496
+ totalUpdated += stats.updated;
497
+ totalSkipped += stats.skipped;
498
+ totalKept += stats.kept;
181
499
  }
182
-
183
- const targetDir = resolve(claudeDir, assetType.targetSubdir);
184
- const stats = syncDirectory(sourceDir, targetDir);
185
-
186
- assets[assetType.name] = stats;
187
- totalInstalled += stats.installed;
188
- totalUpdated += stats.updated;
189
- totalSkipped += stats.skipped;
190
- }
191
-
192
- return { assets, totalInstalled, totalUpdated, totalSkipped, claudeDir };
500
+ });
501
+
502
+ return {
503
+ assets,
504
+ totalInstalled,
505
+ totalUpdated,
506
+ totalSkipped,
507
+ totalKept,
508
+ claudeDir,
509
+ };
193
510
  }
194
511
 
195
512
  // ============================================================
@@ -212,21 +529,28 @@ export async function runInstallCommands(): Promise<void> {
212
529
  if (!stats) {
213
530
  continue;
214
531
  }
215
- const total = stats.installed + stats.updated + stats.skipped;
532
+ const total = stats.installed + stats.updated + stats.skipped + stats.kept;
216
533
  if (total === 0) continue;
217
534
 
218
535
  const parts: string[] = [];
219
536
  if (stats.installed > 0) parts.push(`${stats.installed} new`);
220
537
  if (stats.updated > 0) parts.push(`${stats.updated} updated`);
221
538
  if (stats.skipped > 0) parts.push(`${stats.skipped} current`);
539
+ if (stats.kept > 0) parts.push(`${stats.kept} kept (local edits)`);
222
540
 
223
541
  const description = assetType.description;
224
542
  console.log(` ${description}: ${parts.join(', ')} (${total} total)`);
225
543
  }
226
544
 
227
- const grandTotal = result.totalInstalled + result.totalUpdated + result.totalSkipped;
545
+ const grandTotal =
546
+ result.totalInstalled + result.totalUpdated + result.totalSkipped + result.totalKept;
228
547
  console.log('');
229
548
  console.log(` ${grandTotal} total files synced to ${result.claudeDir}`);
549
+ if (result.totalKept > 0) {
550
+ console.log(
551
+ ` ${result.totalKept} file(s) had local edits and were preserved (see stderr above).`,
552
+ );
553
+ }
230
554
  console.log('');
231
555
  console.log(' Restart your Claude Code session to use them.');
232
556
  console.log('');
@@ -0,0 +1,65 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * `massu show-template <name>` — print the resolved variant of a template.
6
+ *
7
+ * Used by the local-edit-protection messaging to support a workflow like:
8
+ *
9
+ * diff .claude/commands/massu-scaffold-router.md \
10
+ * <(npx massu show-template massu-scaffold-router)
11
+ *
12
+ * The resolved variant honors `pickVariant` against the consumer's current
13
+ * `massu.config.yaml`. Exits 0 on success, 1 on unknown template.
14
+ */
15
+
16
+ import { existsSync, readFileSync } from 'fs';
17
+ import { resolve } from 'path';
18
+ import { getConfig } from '../config.ts';
19
+ import { pickVariant, resolveAssetDir } from './install-commands.ts';
20
+
21
+ /** Strip an optional trailing `.md` extension. */
22
+ function normalizeBaseName(input: string): string {
23
+ return input.endsWith('.md') ? input.slice(0, -'.md'.length) : input;
24
+ }
25
+
26
+ export async function runShowTemplate(args: string[]): Promise<void> {
27
+ const rawName = args[0];
28
+ if (!rawName) {
29
+ process.stderr.write('massu: show-template requires a template name\n');
30
+ process.stderr.write(' usage: massu show-template <name>\n');
31
+ process.exit(1);
32
+ return;
33
+ }
34
+
35
+ const baseName = normalizeBaseName(rawName);
36
+ const sourceDir = resolveAssetDir('commands');
37
+ if (!sourceDir) {
38
+ process.stderr.write('massu: could not locate the bundled commands directory\n');
39
+ process.exit(1);
40
+ return;
41
+ }
42
+
43
+ const framework = getConfig().framework;
44
+ const choice = pickVariant(baseName, sourceDir, framework);
45
+
46
+ if (choice.kind === 'miss') {
47
+ process.stderr.write(`massu: no template named "${baseName}" found\n`);
48
+ process.exit(1);
49
+ return;
50
+ }
51
+
52
+ const suffix = choice.kind === 'hit' ? choice.suffix : '';
53
+ const file = suffix === ''
54
+ ? resolve(sourceDir, `${baseName}.md`)
55
+ : resolve(sourceDir, `${baseName}${suffix}.md`);
56
+
57
+ if (!existsSync(file)) {
58
+ // Defensive: pickVariant said hit but the file isn't there.
59
+ process.stderr.write(`massu: resolved template "${file}" no longer exists\n`);
60
+ process.exit(1);
61
+ return;
62
+ }
63
+
64
+ process.stdout.write(readFileSync(file, 'utf-8'));
65
+ }
package/src/config.ts CHANGED
@@ -534,13 +534,16 @@ export function getConfig(): Config {
534
534
  name: parsed.project.name,
535
535
  root: projectRoot,
536
536
  },
537
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
538
+ // `framework.python`) survive into the consumer-visible Config. Then override
539
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
540
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
541
+ // top-level passthrough language blocks.
537
542
  framework: {
538
- type: fw.type,
543
+ ...fw,
539
544
  router,
540
545
  orm,
541
546
  ui,
542
- primary: fw.primary,
543
- languages: fw.languages,
544
547
  },
545
548
  paths: parsed.paths,
546
549
  toolPrefix: parsed.toolPrefix,