@massu/core 1.2.1 → 1.4.0-soak.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 +137 -0
- package/commands/massu-deploy.python-docker.md +170 -0
- package/commands/massu-deploy.python-fly.md +189 -0
- package/commands/massu-deploy.python-launchd.md +144 -0
- package/commands/massu-deploy.python-systemd.md +163 -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-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/commands/massu-scaffold-router.python.md +143 -0
- package/dist/cli.js +10170 -4138
- package/dist/hooks/auto-learning-pipeline.js +44 -6
- package/dist/hooks/classify-failure.js +44 -6
- package/dist/hooks/cost-tracker.js +44 -6
- package/dist/hooks/fix-detector.js +44 -6
- package/dist/hooks/incident-pipeline.js +44 -6
- package/dist/hooks/post-edit-context.js +44 -6
- package/dist/hooks/post-tool-use.js +44 -6
- package/dist/hooks/pre-compact.js +44 -6
- package/dist/hooks/pre-delete-check.js +44 -6
- package/dist/hooks/quality-event.js +44 -6
- package/dist/hooks/rule-enforcement-pipeline.js +44 -6
- package/dist/hooks/session-end.js +44 -6
- package/dist/hooks/session-start.js +4789 -410
- package/dist/hooks/user-prompt.js +44 -6
- package/package.json +10 -4
- package/src/cli.ts +28 -2
- package/src/commands/config-refresh.ts +88 -20
- package/src/commands/init.ts +130 -23
- package/src/commands/install-commands.ts +482 -42
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/show-template.ts +65 -0
- package/src/commands/template-engine.ts +262 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +69 -3
- package/src/detect/adapters/nextjs-trpc.ts +166 -0
- package/src/detect/adapters/parse-guard.ts +133 -0
- package/src/detect/adapters/python-django.ts +208 -0
- package/src/detect/adapters/python-fastapi.ts +223 -0
- package/src/detect/adapters/query-helpers.ts +170 -0
- package/src/detect/adapters/runner.ts +252 -0
- package/src/detect/adapters/swift-swiftui.ts +171 -0
- package/src/detect/adapters/tree-sitter-loader.ts +348 -0
- package/src/detect/adapters/types.ts +174 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- package/src/detect/regex-fallback.ts +449 -0
- package/src/hooks/session-start.ts +94 -3
- package/src/lib/gitToplevel.ts +22 -0
- package/src/lib/installLock.ts +179 -0
- package/src/lib/pidLiveness.ts +67 -0
- package/src/lsp/auto-detect.ts +89 -0
- package/src/lsp/client.ts +590 -0
- package/src/lsp/enrich.ts +127 -0
- package/src/lsp/types.ts +221 -0
- package/src/watch/daemon.ts +385 -0
- package/src/watch/lockfile-detector.ts +65 -0
- package/src/watch/paths.ts +279 -0
- package/src/watch/state.ts +178 -0
|
@@ -8,12 +8,36 @@
|
|
|
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
|
+
closeSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
fsyncSync,
|
|
26
|
+
openSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
writeSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
statSync,
|
|
33
|
+
renameSync,
|
|
34
|
+
} from 'fs';
|
|
14
35
|
import { resolve, dirname, relative, join } from 'path';
|
|
15
36
|
import { fileURLToPath } from 'url';
|
|
37
|
+
import { createHash } from 'crypto';
|
|
16
38
|
import { getConfig } from '../config.ts';
|
|
39
|
+
import type { Config } from '../config.ts';
|
|
40
|
+
import { renderTemplate, MissingVariableError, TemplateParseError } from './template-engine.ts';
|
|
17
41
|
|
|
18
42
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
43
|
const __dirname = dirname(__filename);
|
|
@@ -68,6 +92,253 @@ export function resolveCommandsDir(): string | null {
|
|
|
68
92
|
return resolveAssetDir('commands');
|
|
69
93
|
}
|
|
70
94
|
|
|
95
|
+
// ============================================================
|
|
96
|
+
// Manifest (local-edit protection)
|
|
97
|
+
// ============================================================
|
|
98
|
+
|
|
99
|
+
const MANIFEST_VERSION = 1;
|
|
100
|
+
const MANIFEST_RELPATH = join('.massu', 'install-manifest.json');
|
|
101
|
+
|
|
102
|
+
/** Manifest file shape — see plan §"Manifest JSON shape". */
|
|
103
|
+
export interface Manifest {
|
|
104
|
+
version: number;
|
|
105
|
+
generatedBy: string;
|
|
106
|
+
generatedAt: string;
|
|
107
|
+
/** key: path relative to the consumer's claudeDir; value: SHA-256 hex digest. */
|
|
108
|
+
entries: Record<string, string>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** SHA-256 hex digest of a string. */
|
|
112
|
+
export function hashContent(content: string): string {
|
|
113
|
+
return createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Load the manifest from `<claudeDir>/.massu/install-manifest.json`, or return an empty manifest. */
|
|
117
|
+
export function loadManifest(claudeDir: string): Manifest {
|
|
118
|
+
const path = resolve(claudeDir, MANIFEST_RELPATH);
|
|
119
|
+
if (!existsSync(path)) {
|
|
120
|
+
return emptyManifest();
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const raw = readFileSync(path, 'utf-8');
|
|
124
|
+
const parsed = JSON.parse(raw) as Manifest;
|
|
125
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.entries) {
|
|
126
|
+
return emptyManifest();
|
|
127
|
+
}
|
|
128
|
+
return parsed;
|
|
129
|
+
} catch {
|
|
130
|
+
return emptyManifest();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Iter-7 fix: atomic file write — tmp + fsync + rename.
|
|
136
|
+
*
|
|
137
|
+
* Plan 3a §3 Risk #4 ("Watcher writes to .claude/ while editor has it open:
|
|
138
|
+
* editors can lose changes. Mitigation: write to .massu-tmp then atomic rename")
|
|
139
|
+
* AND the watcher spec doc §3 Shutdown Semantics claim ("every file op the
|
|
140
|
+
* refresh issues is already atomic-rename-safe... installAll writes <path>.tmp
|
|
141
|
+
* then renameSync") both demand this. Previously installAll's per-file writes
|
|
142
|
+
* (lines 463/467) and saveManifest were direct `writeFileSync` calls — a
|
|
143
|
+
* SIGINT/power-loss between truncate and complete-write left a partial file.
|
|
144
|
+
* Now both go through this helper so the watcher's iter-6 "we don't await
|
|
145
|
+
* fireRefresh because atomic-rename covers everything" decision is sound.
|
|
146
|
+
*
|
|
147
|
+
* Writes via openSync + writeSync + fsyncSync + closeSync + renameSync so the
|
|
148
|
+
* data hits the platter before the rename. On any error, removes the tmp file.
|
|
149
|
+
* Tmp filename includes process.pid to avoid clashes with concurrent installs
|
|
150
|
+
* from sibling processes (e.g. a manual `npx massu config refresh` racing the
|
|
151
|
+
* watcher daemon — the install-lock should prevent this, but the file-level
|
|
152
|
+
* tmp name disambiguates if it ever happens).
|
|
153
|
+
*/
|
|
154
|
+
function atomicWriteFile(targetPath: string, content: string, mode = 0o644): void {
|
|
155
|
+
const tmpPath = `${targetPath}.${process.pid}.tmp`;
|
|
156
|
+
try {
|
|
157
|
+
const fd = openSync(tmpPath, 'w', mode);
|
|
158
|
+
try {
|
|
159
|
+
const buf = Buffer.from(content, 'utf-8');
|
|
160
|
+
writeSync(fd, buf, 0, buf.length, 0);
|
|
161
|
+
fsyncSync(fd);
|
|
162
|
+
} finally {
|
|
163
|
+
closeSync(fd);
|
|
164
|
+
}
|
|
165
|
+
renameSync(tmpPath, targetPath);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (existsSync(tmpPath)) {
|
|
168
|
+
try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
|
|
169
|
+
}
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Write the manifest atomically: tempfile + fsync + renameSync. */
|
|
175
|
+
export function saveManifest(claudeDir: string, manifest: Manifest): void {
|
|
176
|
+
const dir = resolve(claudeDir, '.massu');
|
|
177
|
+
if (!existsSync(dir)) {
|
|
178
|
+
mkdirSync(dir, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
const finalPath = resolve(dir, 'install-manifest.json');
|
|
181
|
+
manifest.generatedAt = new Date().toISOString();
|
|
182
|
+
atomicWriteFile(finalPath, JSON.stringify(manifest, null, 2));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function emptyManifest(): Manifest {
|
|
186
|
+
return {
|
|
187
|
+
version: MANIFEST_VERSION,
|
|
188
|
+
generatedBy: '@massu/core',
|
|
189
|
+
generatedAt: new Date().toISOString(),
|
|
190
|
+
entries: {},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run a function with the manifest loaded; persist atomically afterward.
|
|
196
|
+
* Used by both `installAll` and the legacy `installCommands` so any caller
|
|
197
|
+
* of either entry point gets the manifest written exactly once per run.
|
|
198
|
+
*/
|
|
199
|
+
export function runWithManifest<T>(claudeDir: string, fn: (m: Manifest) => T): T {
|
|
200
|
+
const manifest = loadManifest(claudeDir);
|
|
201
|
+
const result = fn(manifest);
|
|
202
|
+
saveManifest(claudeDir, manifest);
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================
|
|
207
|
+
// Variant Resolution (Phase 1)
|
|
208
|
+
// ============================================================
|
|
209
|
+
|
|
210
|
+
/** Discriminated-union return shape for `pickVariant`. */
|
|
211
|
+
export type PickVariantResult =
|
|
212
|
+
| { kind: 'hit'; suffix: string } // found a variant (suffix may be "")
|
|
213
|
+
| { kind: 'miss' } // no candidate found, caller SKIPS the file
|
|
214
|
+
| { kind: 'fallback'; reason: string }; // misconfig / safe fallback, caller copies UNSUFFIXED default
|
|
215
|
+
|
|
216
|
+
/** Well-known language keys for the passthrough-fallback step in `pickVariant`. */
|
|
217
|
+
const PASSTHROUGH_LANG_KEYS = [
|
|
218
|
+
'typescript',
|
|
219
|
+
'javascript',
|
|
220
|
+
'python',
|
|
221
|
+
'swift',
|
|
222
|
+
'rust',
|
|
223
|
+
'go',
|
|
224
|
+
] as const;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Choose the variant suffix for a base template name.
|
|
228
|
+
*
|
|
229
|
+
* Two-axis priority (Plan #2 P2-001 extends Plan #1's lang-only axis):
|
|
230
|
+
* For each language `L` in priority order (primary, languages.*, passthrough.*):
|
|
231
|
+
* a. If a sub-framework `F` is declared for L, probe `<base>.<L>-<F>.md`.
|
|
232
|
+
* b. Probe `<base>.<L>.md` (lang-only fallback).
|
|
233
|
+
* Then probe the unsuffixed `<base>.md`.
|
|
234
|
+
*
|
|
235
|
+
* Priority order for the language list (Plan #1):
|
|
236
|
+
* 1. `framework.primary` (or `framework.type` if primary undefined). With the
|
|
237
|
+
* sub-framework axis, the candidate framework is the matching
|
|
238
|
+
* `framework.languages[primary].framework` if present, else `framework.router`
|
|
239
|
+
* / `framework.orm` / `framework.ui` heuristics, else just lang-only.
|
|
240
|
+
* 2. Each declared `framework.languages.<lang>` entry with a non-empty `framework`,
|
|
241
|
+
* in YAML declaration order. Sub-framework = `entry.framework`.
|
|
242
|
+
* 3. Passthrough fallback: well-known top-level `framework.<lang>` blocks with a
|
|
243
|
+
* non-empty `framework` field, in fixed order, excluding entries already covered.
|
|
244
|
+
* Sub-framework = top-level block's `framework` field.
|
|
245
|
+
* 4. The unsuffixed default ("").
|
|
246
|
+
*
|
|
247
|
+
* The function NEVER throws. It returns a discriminated union so the caller can
|
|
248
|
+
* distinguish "skip this file" from "copy the default" — see plan §"Error semantics".
|
|
249
|
+
*/
|
|
250
|
+
export function pickVariant(
|
|
251
|
+
baseName: string,
|
|
252
|
+
sourceDir: string,
|
|
253
|
+
framework: Config['framework'],
|
|
254
|
+
): PickVariantResult {
|
|
255
|
+
// Build (lang, subFramework) candidate pairs in priority order. Sub-framework
|
|
256
|
+
// can be undefined — in that case only the lang-only axis is probed for that
|
|
257
|
+
// language.
|
|
258
|
+
type Candidate = { lang: string; subFramework?: string };
|
|
259
|
+
const candidates: Candidate[] = [];
|
|
260
|
+
const seenLangs = new Set<string>();
|
|
261
|
+
|
|
262
|
+
function pushCandidate(lang: string, sub: string | undefined): void {
|
|
263
|
+
if (seenLangs.has(lang)) return;
|
|
264
|
+
seenLangs.add(lang);
|
|
265
|
+
candidates.push({ lang, subFramework: sub && sub.length > 0 ? sub : undefined });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 1. framework.primary (or fall back to framework.type)
|
|
269
|
+
const primary = framework.primary ?? framework.type;
|
|
270
|
+
if (primary && primary !== 'multi') {
|
|
271
|
+
// Best-effort sub-framework detection for the primary lang:
|
|
272
|
+
// - If `framework.languages[primary]` has a `framework`, use that.
|
|
273
|
+
// - Else, fall back to top-level passthrough `framework[primary].framework`.
|
|
274
|
+
let primarySub: string | undefined;
|
|
275
|
+
if (framework.languages && framework.languages[primary]?.framework) {
|
|
276
|
+
primarySub = framework.languages[primary].framework;
|
|
277
|
+
} else {
|
|
278
|
+
const passthrough = framework as unknown as Record<string, unknown>;
|
|
279
|
+
const block = passthrough[primary];
|
|
280
|
+
if (block && typeof block === 'object') {
|
|
281
|
+
const fw = (block as { framework?: unknown }).framework;
|
|
282
|
+
if (typeof fw === 'string' && fw.length > 0) primarySub = fw;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
pushCandidate(primary, primarySub);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 2. framework.languages declaration order
|
|
289
|
+
if (framework.languages) {
|
|
290
|
+
for (const lang of Object.keys(framework.languages)) {
|
|
291
|
+
const entry = framework.languages[lang];
|
|
292
|
+
if (entry && typeof entry.framework === 'string' && entry.framework.length > 0) {
|
|
293
|
+
pushCandidate(lang, entry.framework);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 3. Passthrough fallback — `framework.<lang>` (top-level passthrough block).
|
|
299
|
+
const passthrough = framework as unknown as Record<string, unknown>;
|
|
300
|
+
for (const lang of PASSTHROUGH_LANG_KEYS) {
|
|
301
|
+
if (seenLangs.has(lang)) continue;
|
|
302
|
+
const block = passthrough[lang];
|
|
303
|
+
if (block && typeof block === 'object') {
|
|
304
|
+
const fw = (block as { framework?: unknown }).framework;
|
|
305
|
+
if (typeof fw === 'string' && fw.length > 0) {
|
|
306
|
+
pushCandidate(lang, fw);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 4. Probe disk — for each (lang, sub) pair, try lang-sub first, then lang-only.
|
|
312
|
+
for (const cand of candidates) {
|
|
313
|
+
if (cand.subFramework) {
|
|
314
|
+
const subPath = resolve(sourceDir, `${baseName}.${cand.lang}-${cand.subFramework}.md`);
|
|
315
|
+
if (existsSync(subPath)) {
|
|
316
|
+
return { kind: 'hit', suffix: `.${cand.lang}-${cand.subFramework}` };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const langPath = resolve(sourceDir, `${baseName}.${cand.lang}.md`);
|
|
320
|
+
if (existsSync(langPath)) {
|
|
321
|
+
return { kind: 'hit', suffix: `.${cand.lang}` };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// Unsuffixed default
|
|
325
|
+
const defaultPath = resolve(sourceDir, `${baseName}.md`);
|
|
326
|
+
if (existsSync(defaultPath)) {
|
|
327
|
+
return { kind: 'hit', suffix: '' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// No hit. Risk #7: framework.type=multi without primary → safe fallback.
|
|
331
|
+
if (framework.type === 'multi' && !framework.primary) {
|
|
332
|
+
process.stderr.write(
|
|
333
|
+
'massu: warning - framework.type=multi but framework.primary is undefined; ' +
|
|
334
|
+
'falling back to default templates\n',
|
|
335
|
+
);
|
|
336
|
+
return { kind: 'fallback', reason: 'multi-without-primary' };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { kind: 'miss' };
|
|
340
|
+
}
|
|
341
|
+
|
|
71
342
|
// ============================================================
|
|
72
343
|
// Recursive File Sync
|
|
73
344
|
// ============================================================
|
|
@@ -76,14 +347,37 @@ interface SyncStats {
|
|
|
76
347
|
installed: number;
|
|
77
348
|
updated: number;
|
|
78
349
|
skipped: number;
|
|
350
|
+
kept: number;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Returns true if a top-level entry name has the `<base>.<variant>.md` shape. */
|
|
354
|
+
function isVariantFilename(entry: string): boolean {
|
|
355
|
+
// Match exactly one inner dot before `.md`. `_shared-preamble.md` (no inner dot) survives.
|
|
356
|
+
return /^[^.]+\.[^.]+\.md$/.test(entry);
|
|
79
357
|
}
|
|
80
358
|
|
|
81
359
|
/**
|
|
82
360
|
* Recursively sync all .md files from sourceDir to targetDir.
|
|
83
|
-
*
|
|
361
|
+
*
|
|
362
|
+
* At top level (`topLevel === true`), apply variant resolution:
|
|
363
|
+
* - Skip entries that match `<base>.<variant>.md` (the variant siblings are
|
|
364
|
+
* selected indirectly via `pickVariant` so they never land in the consumer
|
|
365
|
+
* dir directly).
|
|
366
|
+
* - For each base entry `<base>.md`, call `pickVariant` to choose the source.
|
|
367
|
+
*
|
|
368
|
+
* At depth ≥ 1 (subdirectory recursion), copy files as-is — no variant logic,
|
|
369
|
+
* no dot-skip filter (so future authors can use dotted filenames in subdirs).
|
|
84
370
|
*/
|
|
85
|
-
function syncDirectory(
|
|
86
|
-
|
|
371
|
+
export function syncDirectory(
|
|
372
|
+
sourceDir: string,
|
|
373
|
+
targetDir: string,
|
|
374
|
+
framework: Config['framework'],
|
|
375
|
+
manifest: Manifest,
|
|
376
|
+
manifestKeyPrefix: string,
|
|
377
|
+
topLevel: boolean = true,
|
|
378
|
+
templateVars: Record<string, unknown> = {},
|
|
379
|
+
): SyncStats {
|
|
380
|
+
const stats: SyncStats = { installed: 0, updated: 0, skipped: 0, kept: 0 };
|
|
87
381
|
|
|
88
382
|
if (!existsSync(targetDir)) {
|
|
89
383
|
mkdirSync(targetDir, { recursive: true });
|
|
@@ -93,30 +387,128 @@ function syncDirectory(sourceDir: string, targetDir: string): SyncStats {
|
|
|
93
387
|
|
|
94
388
|
for (const entry of entries) {
|
|
95
389
|
const sourcePath = resolve(sourceDir, entry);
|
|
96
|
-
const targetPath = resolve(targetDir, entry);
|
|
97
390
|
const entryStat = statSync(sourcePath);
|
|
98
391
|
|
|
99
392
|
if (entryStat.isDirectory()) {
|
|
100
|
-
// Recurse
|
|
101
|
-
const
|
|
393
|
+
// Recurse — depth > 0 disables variant filtering for nested files.
|
|
394
|
+
const subTargetDir = resolve(targetDir, entry);
|
|
395
|
+
const subPrefix = manifestKeyPrefix === ''
|
|
396
|
+
? entry
|
|
397
|
+
: `${manifestKeyPrefix}/${entry}`;
|
|
398
|
+
const subStats = syncDirectory(
|
|
399
|
+
sourcePath,
|
|
400
|
+
subTargetDir,
|
|
401
|
+
framework,
|
|
402
|
+
manifest,
|
|
403
|
+
subPrefix,
|
|
404
|
+
false,
|
|
405
|
+
templateVars,
|
|
406
|
+
);
|
|
102
407
|
stats.installed += subStats.installed;
|
|
103
408
|
stats.updated += subStats.updated;
|
|
104
409
|
stats.skipped += subStats.skipped;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
410
|
+
stats.kept += subStats.kept;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!entry.endsWith('.md')) continue;
|
|
415
|
+
|
|
416
|
+
let sourceFilename = entry;
|
|
417
|
+
let baseName = entry.slice(0, -'.md'.length);
|
|
418
|
+
|
|
419
|
+
if (topLevel) {
|
|
420
|
+
// Skip variant siblings — they are selected indirectly via the base name.
|
|
421
|
+
if (isVariantFilename(entry)) continue;
|
|
422
|
+
|
|
423
|
+
const choice = pickVariant(baseName, sourceDir, framework);
|
|
424
|
+
if (choice.kind === 'miss') {
|
|
425
|
+
// No file to copy.
|
|
426
|
+
continue;
|
|
119
427
|
}
|
|
428
|
+
// 'hit' or 'fallback' both copy a file:
|
|
429
|
+
// - 'hit' uses the chosen suffix (may be "")
|
|
430
|
+
// - 'fallback' copies the unsuffixed default (same as suffix === "")
|
|
431
|
+
const suffix = choice.kind === 'hit' ? choice.suffix : '';
|
|
432
|
+
sourceFilename = suffix === '' ? `${baseName}.md` : `${baseName}${suffix}.md`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const resolvedSourcePath = resolve(sourceDir, sourceFilename);
|
|
436
|
+
if (!existsSync(resolvedSourcePath)) {
|
|
437
|
+
// Defensive: pickVariant said hit, but file vanished between probe and read.
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Target filename is always the BASE name (variant suffix is internal to the package).
|
|
442
|
+
const targetFilename = topLevel ? `${baseName}.md` : entry;
|
|
443
|
+
const targetPath = resolve(targetDir, targetFilename);
|
|
444
|
+
const rawContent = readFileSync(resolvedSourcePath, 'utf-8');
|
|
445
|
+
|
|
446
|
+
// Plan #2 P1-003: render any `{{var}}` substitutions BEFORE hashing so
|
|
447
|
+
// the manifest entry hash matches the byte-stream that lands on disk.
|
|
448
|
+
// Engine errors (missing var, malformed token) fail this single file but
|
|
449
|
+
// never abort the whole install — see spec §"Error semantics".
|
|
450
|
+
let sourceContent: string;
|
|
451
|
+
try {
|
|
452
|
+
sourceContent = renderTemplate(rawContent, templateVars);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
if (err instanceof MissingVariableError || err instanceof TemplateParseError) {
|
|
455
|
+
process.stderr.write(
|
|
456
|
+
`massu: skipping ${resolvedSourcePath}: ${err.message}\n`,
|
|
457
|
+
);
|
|
458
|
+
stats.skipped++;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
throw err;
|
|
462
|
+
}
|
|
463
|
+
const sourceHash = hashContent(sourceContent);
|
|
464
|
+
|
|
465
|
+
const manifestKey = manifestKeyPrefix === ''
|
|
466
|
+
? targetFilename
|
|
467
|
+
: `${manifestKeyPrefix}/${targetFilename}`;
|
|
468
|
+
const lastInstalledHash = manifest.entries[manifestKey];
|
|
469
|
+
|
|
470
|
+
if (existsSync(targetPath)) {
|
|
471
|
+
const existingContent = readFileSync(targetPath, 'utf-8');
|
|
472
|
+
const existingHash = hashContent(existingContent);
|
|
473
|
+
|
|
474
|
+
if (existingHash === sourceHash) {
|
|
475
|
+
// Already byte-identical to upstream. Ensure manifest reflects that.
|
|
476
|
+
manifest.entries[manifestKey] = sourceHash;
|
|
477
|
+
stats.skipped++;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (lastInstalledHash === undefined) {
|
|
482
|
+
// First-install ambiguity: file exists but no manifest entry.
|
|
483
|
+
// Treat as user-edited: keep, record existing hash, print one-line notice.
|
|
484
|
+
manifest.entries[manifestKey] = existingHash;
|
|
485
|
+
process.stderr.write(
|
|
486
|
+
`First-install heuristic: keeping existing ${targetPath} (differs from upstream).\n` +
|
|
487
|
+
` To accept upstream: rm ${targetPath} && npx massu install-commands\n`,
|
|
488
|
+
);
|
|
489
|
+
stats.kept++;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (existingHash !== lastInstalledHash) {
|
|
494
|
+
// User edited it after the last install. Preserve.
|
|
495
|
+
process.stderr.write(
|
|
496
|
+
`${targetFilename} has local edits - kept your version.\n` +
|
|
497
|
+
` To accept upstream: rm ${targetPath} && npx massu install-commands\n` +
|
|
498
|
+
` To diff: diff ${targetPath} <(npx massu show-template ${baseName})\n`,
|
|
499
|
+
);
|
|
500
|
+
stats.kept++;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// existingHash === lastInstalledHash and sourceHash differs → safe upgrade.
|
|
505
|
+
atomicWriteFile(targetPath, sourceContent);
|
|
506
|
+
manifest.entries[manifestKey] = sourceHash;
|
|
507
|
+
stats.updated++;
|
|
508
|
+
} else {
|
|
509
|
+
atomicWriteFile(targetPath, sourceContent);
|
|
510
|
+
manifest.entries[manifestKey] = sourceHash;
|
|
511
|
+
stats.installed++;
|
|
120
512
|
}
|
|
121
513
|
}
|
|
122
514
|
|
|
@@ -131,12 +523,28 @@ export interface InstallCommandsResult {
|
|
|
131
523
|
installed: number;
|
|
132
524
|
updated: number;
|
|
133
525
|
skipped: number;
|
|
526
|
+
kept: number;
|
|
134
527
|
commandsDir: string;
|
|
135
528
|
}
|
|
136
529
|
|
|
530
|
+
/**
|
|
531
|
+
* Build the variable scope passed to the templating engine.
|
|
532
|
+
* See spec §"Variable scope passed to the engine" for the contract.
|
|
533
|
+
*/
|
|
534
|
+
export function buildTemplateVars(): Record<string, unknown> {
|
|
535
|
+
const config = getConfig();
|
|
536
|
+
return {
|
|
537
|
+
framework: config.framework,
|
|
538
|
+
paths: config.paths,
|
|
539
|
+
detected: config.detected ?? {},
|
|
540
|
+
config,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
137
544
|
export function installCommands(projectRoot: string): InstallCommandsResult {
|
|
138
545
|
const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
|
|
139
|
-
const
|
|
546
|
+
const claudeDir = resolve(projectRoot, claudeDirName);
|
|
547
|
+
const targetDir = resolve(claudeDir, 'commands');
|
|
140
548
|
|
|
141
549
|
if (!existsSync(targetDir)) {
|
|
142
550
|
mkdirSync(targetDir, { recursive: true });
|
|
@@ -146,10 +554,14 @@ export function installCommands(projectRoot: string): InstallCommandsResult {
|
|
|
146
554
|
if (!sourceDir) {
|
|
147
555
|
console.error(' ERROR: Could not find massu commands directory.');
|
|
148
556
|
console.error(' Try reinstalling: npm install @massu/core');
|
|
149
|
-
return { installed: 0, updated: 0, skipped: 0, commandsDir: targetDir };
|
|
557
|
+
return { installed: 0, updated: 0, skipped: 0, kept: 0, commandsDir: targetDir };
|
|
150
558
|
}
|
|
151
559
|
|
|
152
|
-
const
|
|
560
|
+
const framework = getConfig().framework;
|
|
561
|
+
const templateVars = buildTemplateVars();
|
|
562
|
+
const stats = runWithManifest(claudeDir, (manifest) =>
|
|
563
|
+
syncDirectory(sourceDir, targetDir, framework, manifest, 'commands', true, templateVars),
|
|
564
|
+
);
|
|
153
565
|
return { ...stats, commandsDir: targetDir };
|
|
154
566
|
}
|
|
155
567
|
|
|
@@ -162,6 +574,7 @@ export interface InstallAllResult {
|
|
|
162
574
|
totalInstalled: number;
|
|
163
575
|
totalUpdated: number;
|
|
164
576
|
totalSkipped: number;
|
|
577
|
+
totalKept: number;
|
|
165
578
|
claudeDir: string;
|
|
166
579
|
}
|
|
167
580
|
|
|
@@ -173,23 +586,43 @@ export function installAll(projectRoot: string): InstallAllResult {
|
|
|
173
586
|
let totalInstalled = 0;
|
|
174
587
|
let totalUpdated = 0;
|
|
175
588
|
let totalSkipped = 0;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
589
|
+
let totalKept = 0;
|
|
590
|
+
|
|
591
|
+
const framework = getConfig().framework;
|
|
592
|
+
const templateVars = buildTemplateVars();
|
|
593
|
+
|
|
594
|
+
runWithManifest(claudeDir, (manifest) => {
|
|
595
|
+
for (const assetType of ASSET_TYPES) {
|
|
596
|
+
const sourceDir = resolveAssetDir(assetType.name);
|
|
597
|
+
if (!sourceDir) continue;
|
|
598
|
+
|
|
599
|
+
const targetDir = resolve(claudeDir, assetType.targetSubdir);
|
|
600
|
+
const stats = syncDirectory(
|
|
601
|
+
sourceDir,
|
|
602
|
+
targetDir,
|
|
603
|
+
framework,
|
|
604
|
+
manifest,
|
|
605
|
+
assetType.targetSubdir,
|
|
606
|
+
true,
|
|
607
|
+
templateVars,
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
assets[assetType.name] = stats;
|
|
611
|
+
totalInstalled += stats.installed;
|
|
612
|
+
totalUpdated += stats.updated;
|
|
613
|
+
totalSkipped += stats.skipped;
|
|
614
|
+
totalKept += stats.kept;
|
|
181
615
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return { assets, totalInstalled, totalUpdated, totalSkipped, claudeDir };
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
assets,
|
|
620
|
+
totalInstalled,
|
|
621
|
+
totalUpdated,
|
|
622
|
+
totalSkipped,
|
|
623
|
+
totalKept,
|
|
624
|
+
claudeDir,
|
|
625
|
+
};
|
|
193
626
|
}
|
|
194
627
|
|
|
195
628
|
// ============================================================
|
|
@@ -212,21 +645,28 @@ export async function runInstallCommands(): Promise<void> {
|
|
|
212
645
|
if (!stats) {
|
|
213
646
|
continue;
|
|
214
647
|
}
|
|
215
|
-
const total = stats.installed + stats.updated + stats.skipped;
|
|
648
|
+
const total = stats.installed + stats.updated + stats.skipped + stats.kept;
|
|
216
649
|
if (total === 0) continue;
|
|
217
650
|
|
|
218
651
|
const parts: string[] = [];
|
|
219
652
|
if (stats.installed > 0) parts.push(`${stats.installed} new`);
|
|
220
653
|
if (stats.updated > 0) parts.push(`${stats.updated} updated`);
|
|
221
654
|
if (stats.skipped > 0) parts.push(`${stats.skipped} current`);
|
|
655
|
+
if (stats.kept > 0) parts.push(`${stats.kept} kept (local edits)`);
|
|
222
656
|
|
|
223
657
|
const description = assetType.description;
|
|
224
658
|
console.log(` ${description}: ${parts.join(', ')} (${total} total)`);
|
|
225
659
|
}
|
|
226
660
|
|
|
227
|
-
const grandTotal =
|
|
661
|
+
const grandTotal =
|
|
662
|
+
result.totalInstalled + result.totalUpdated + result.totalSkipped + result.totalKept;
|
|
228
663
|
console.log('');
|
|
229
664
|
console.log(` ${grandTotal} total files synced to ${result.claudeDir}`);
|
|
665
|
+
if (result.totalKept > 0) {
|
|
666
|
+
console.log(
|
|
667
|
+
` ${result.totalKept} file(s) had local edits and were preserved (see stderr above).`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
230
670
|
console.log('');
|
|
231
671
|
console.log(' Restart your Claude Code session to use them.');
|
|
232
672
|
console.log('');
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `massu refresh-log [N]` — show the last N watcher auto-refresh events.
|
|
6
|
+
*
|
|
7
|
+
* Library discipline: returns a result object; cli.ts owns process.exit.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { gitToplevel } from '../lib/gitToplevel.ts';
|
|
11
|
+
import { readRefreshLog } from './watch.ts';
|
|
12
|
+
|
|
13
|
+
export interface RefreshLogResult {
|
|
14
|
+
exitCode: 0 | 1;
|
|
15
|
+
message?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runRefreshLog(args: string[]): Promise<RefreshLogResult> {
|
|
19
|
+
const limitArg = args.find((a) => /^\d+$/.test(a));
|
|
20
|
+
const limit = limitArg ? Math.max(1, Math.min(1000, Number(limitArg))) : 10;
|
|
21
|
+
const root = gitToplevel(process.cwd());
|
|
22
|
+
|
|
23
|
+
const events = readRefreshLog(root, limit);
|
|
24
|
+
if (events.length === 0) {
|
|
25
|
+
process.stdout.write('massu refresh-log: no auto-refresh events recorded yet.\n');
|
|
26
|
+
return { exitCode: 0 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const e of events) {
|
|
30
|
+
const from = e.fromFingerprint ? e.fromFingerprint.slice(0, 12) : 'init';
|
|
31
|
+
const to = e.toFingerprint.slice(0, 12);
|
|
32
|
+
process.stdout.write(
|
|
33
|
+
`${e.at} ${from} -> ${to} installed=${e.filesInstalled} updated=${e.filesUpdated} kept=${e.filesKept}\n`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return { exitCode: 0 };
|
|
37
|
+
}
|