@massu/core 1.2.0 → 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.
- package/README.md +40 -0
- package/commands/README.md +122 -0
- package/commands/massu-deploy.python.md +200 -0
- package/commands/massu-scaffold-page.md +172 -59
- package/commands/massu-scaffold-page.swift.md +121 -0
- package/commands/massu-scaffold-router.python.md +143 -0
- package/dist/cli.js +562 -231
- package/dist/hooks/auto-learning-pipeline.js +8 -4
- package/dist/hooks/classify-failure.js +8 -4
- package/dist/hooks/cost-tracker.js +8 -4
- package/dist/hooks/fix-detector.js +8 -4
- package/dist/hooks/incident-pipeline.js +8 -4
- package/dist/hooks/post-edit-context.js +8 -4
- package/dist/hooks/post-tool-use.js +8 -4
- package/dist/hooks/pre-compact.js +8 -4
- package/dist/hooks/pre-delete-check.js +8 -4
- package/dist/hooks/quality-event.js +8 -4
- package/dist/hooks/rule-enforcement-pipeline.js +8 -4
- package/dist/hooks/session-end.js +8 -4
- package/dist/hooks/session-start.js +20 -7
- package/dist/hooks/user-prompt.js +8 -4
- package/package.json +1 -1
- package/src/cli.ts +6 -0
- package/src/commands/init.ts +89 -4
- package/src/commands/install-commands.ts +366 -42
- package/src/commands/show-template.ts +65 -0
- package/src/config.ts +11 -3
- package/src/detect/index.ts +10 -1
- package/src/detect/migrate.ts +52 -1
- package/src/detect/source-dir-detector.ts +28 -2
|
@@ -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 {
|
|
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
|
-
*
|
|
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(
|
|
86
|
-
|
|
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
|
|
101
|
-
const
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 =
|
|
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
|
@@ -254,9 +254,14 @@ const PythonConfigSchema = z.object({
|
|
|
254
254
|
export type PythonConfig = z.infer<typeof PythonConfigSchema>;
|
|
255
255
|
|
|
256
256
|
// --- Paths Config ---
|
|
257
|
+
// `monorepo_roots` (P1-005): optional, additive. Emitted by `init --ci` when
|
|
258
|
+
// `monorepo.type !== 'single'` and at least one workspace package exists.
|
|
259
|
+
// Downstream tools may consume it for monorepo-aware scanning. Existing v1
|
|
260
|
+
// configs omit it — `.optional()` preserves full back-compat.
|
|
257
261
|
const PathsConfigSchema = z.object({
|
|
258
262
|
source: z.string().default('src'),
|
|
259
263
|
aliases: z.record(z.string(), z.string()).default({ '@': 'src' }),
|
|
264
|
+
monorepo_roots: z.array(z.string()).optional(),
|
|
260
265
|
routers: z.string().optional(),
|
|
261
266
|
routerRoot: z.string().optional(),
|
|
262
267
|
pages: z.string().optional(),
|
|
@@ -529,13 +534,16 @@ export function getConfig(): Config {
|
|
|
529
534
|
name: parsed.project.name,
|
|
530
535
|
root: projectRoot,
|
|
531
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.
|
|
532
542
|
framework: {
|
|
533
|
-
|
|
543
|
+
...fw,
|
|
534
544
|
router,
|
|
535
545
|
orm,
|
|
536
546
|
ui,
|
|
537
|
-
primary: fw.primary,
|
|
538
|
-
languages: fw.languages,
|
|
539
547
|
},
|
|
540
548
|
paths: parsed.paths,
|
|
541
549
|
toolPrefix: parsed.toolPrefix,
|
package/src/detect/index.ts
CHANGED
|
@@ -135,9 +135,18 @@ export async function runDetection(
|
|
|
135
135
|
new Set(pkg.manifests.map((m) => m.language))
|
|
136
136
|
) as SupportedLanguage[];
|
|
137
137
|
|
|
138
|
+
// P1-002: when the repo has a `javascript` manifest but NO `typescript`
|
|
139
|
+
// manifest, still glob `.ts`/`.tsx` for the javascript slot. This fixes
|
|
140
|
+
// plain-JS monorepos (e.g. turbo + next in a package.json with no
|
|
141
|
+
// `typescript` dep and no `tsconfig.json`) that contain `.tsx` files
|
|
142
|
+
// under `apps/*/`. Without this, `init --ci` falls back to the nonexistent
|
|
143
|
+
// `src/` and rolls back with a validation error.
|
|
144
|
+
const fallbackTsForJs =
|
|
145
|
+
languages.includes('javascript') && !languages.includes('typescript');
|
|
146
|
+
|
|
138
147
|
// 3b. run source-dir + monorepo detection in parallel (both pure fs).
|
|
139
148
|
const [sourceDirs, monorepo] = await Promise.all([
|
|
140
|
-
Promise.resolve(detectSourceDirs(projectRoot, languages)),
|
|
149
|
+
Promise.resolve(detectSourceDirs(projectRoot, languages, { fallbackTsForJs })),
|
|
141
150
|
Promise.resolve(detectMonorepo(projectRoot)),
|
|
142
151
|
]);
|
|
143
152
|
|