@massu/core 1.0.0 → 1.2.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/dist/cli.js +913 -26
- package/dist/hooks/session-start.js +8706 -771
- package/package.json +1 -1
- package/src/cli.ts +79 -1
- package/src/commands/config-check-drift.ts +132 -0
- package/src/commands/config-refresh.ts +327 -0
- package/src/commands/config-upgrade.ts +126 -0
- package/src/commands/init.ts +4 -0
- package/src/detect/migrate.ts +37 -4
- package/src/detect/passthrough.ts +108 -0
- package/src/hooks/session-start.ts +42 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@massu/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
|
|
6
6
|
"main": "src/server.ts",
|
package/src/cli.ts
CHANGED
|
@@ -52,6 +52,10 @@ async function main(): Promise<void> {
|
|
|
52
52
|
await runValidateConfig();
|
|
53
53
|
break;
|
|
54
54
|
}
|
|
55
|
+
case 'config': {
|
|
56
|
+
await handleConfigSubcommand(args.slice(1));
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
55
59
|
case '--help':
|
|
56
60
|
case '-h': {
|
|
57
61
|
printHelp();
|
|
@@ -70,6 +74,56 @@ async function main(): Promise<void> {
|
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
async function handleConfigSubcommand(configArgs: string[]): Promise<void> {
|
|
78
|
+
const sub = configArgs[0];
|
|
79
|
+
const flags = new Set(configArgs.slice(1));
|
|
80
|
+
switch (sub) {
|
|
81
|
+
case 'refresh': {
|
|
82
|
+
const { runConfigRefresh } = await import('./commands/config-refresh.ts');
|
|
83
|
+
const result = await runConfigRefresh({ dryRun: flags.has('--dry-run') });
|
|
84
|
+
process.exit(result.exitCode);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case 'validate': {
|
|
88
|
+
const { runValidateConfig } = await import('./commands/doctor.ts');
|
|
89
|
+
await runValidateConfig();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
case 'upgrade': {
|
|
93
|
+
const { runConfigUpgrade } = await import('./commands/config-upgrade.ts');
|
|
94
|
+
const result = await runConfigUpgrade({
|
|
95
|
+
rollback: flags.has('--rollback'),
|
|
96
|
+
ci: flags.has('--ci') || flags.has('--yes'),
|
|
97
|
+
});
|
|
98
|
+
process.exit(result.exitCode);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
case 'doctor': {
|
|
102
|
+
const { runDoctor } = await import('./commands/doctor.ts');
|
|
103
|
+
await runDoctor();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
case 'check-drift': {
|
|
107
|
+
const { runConfigCheckDrift } = await import('./commands/config-check-drift.ts');
|
|
108
|
+
const result = await runConfigCheckDrift({ verbose: flags.has('--verbose') });
|
|
109
|
+
process.exit(result.exitCode);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
case '--help':
|
|
113
|
+
case '-h':
|
|
114
|
+
case undefined: {
|
|
115
|
+
printConfigHelp();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
default: {
|
|
119
|
+
process.stderr.write(`massu: unknown config subcommand: ${sub}\n`);
|
|
120
|
+
printConfigHelp();
|
|
121
|
+
process.exit(1);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
73
127
|
function printHelp(): void {
|
|
74
128
|
console.log(`
|
|
75
129
|
Massu AI - Engineering Governance Platform
|
|
@@ -82,7 +136,8 @@ Commands:
|
|
|
82
136
|
doctor Check installation health
|
|
83
137
|
install-hooks Install/update Claude Code hooks
|
|
84
138
|
install-commands Install/update slash commands
|
|
85
|
-
validate-config Validate massu.config.yaml
|
|
139
|
+
validate-config Validate massu.config.yaml (alias: config validate)
|
|
140
|
+
config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
|
|
86
141
|
|
|
87
142
|
Options:
|
|
88
143
|
--help, -h Show this help message
|
|
@@ -91,11 +146,34 @@ Options:
|
|
|
91
146
|
Getting started:
|
|
92
147
|
npx massu init # Full setup in one command
|
|
93
148
|
npx massu init --help # Show all init options (--ci, --force, --template)
|
|
149
|
+
npx massu config --help # Show config subcommands
|
|
94
150
|
|
|
95
151
|
Documentation: https://massu.ai/docs
|
|
96
152
|
`);
|
|
97
153
|
}
|
|
98
154
|
|
|
155
|
+
function printConfigHelp(): void {
|
|
156
|
+
console.log(`
|
|
157
|
+
massu config <subcommand>
|
|
158
|
+
|
|
159
|
+
Subcommands:
|
|
160
|
+
refresh Re-run detection and apply changes to massu.config.yaml.
|
|
161
|
+
--dry-run Print diff and exit without writing.
|
|
162
|
+
validate Validate massu.config.yaml (alias of \`massu validate-config\`).
|
|
163
|
+
upgrade Migrate a v1 config to schema_version=2.
|
|
164
|
+
--rollback Restore from .bak file.
|
|
165
|
+
--ci, --yes Non-interactive mode (no prompts).
|
|
166
|
+
doctor Run the full health check (alias of \`massu doctor\`).
|
|
167
|
+
check-drift CI-safe drift gate; exits 1 on drift.
|
|
168
|
+
--verbose Print detailed changes to stdout.
|
|
169
|
+
|
|
170
|
+
Examples:
|
|
171
|
+
npx massu config refresh --dry-run
|
|
172
|
+
npx massu config upgrade --ci
|
|
173
|
+
npx massu config check-drift --verbose
|
|
174
|
+
`);
|
|
175
|
+
}
|
|
176
|
+
|
|
99
177
|
function printVersion(): void {
|
|
100
178
|
try {
|
|
101
179
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `massu config check-drift` — CI-safe drift gate.
|
|
6
|
+
*
|
|
7
|
+
* Reads massu.config.yaml, runs detection, and compares the result with both
|
|
8
|
+
* the stored fingerprint (if present) and the structural detectDrift() check.
|
|
9
|
+
*
|
|
10
|
+
* Flags:
|
|
11
|
+
* --verbose Emit the full changes[] to stdout (field: before -> after).
|
|
12
|
+
*
|
|
13
|
+
* Exit codes:
|
|
14
|
+
* 0 no drift
|
|
15
|
+
* 1 drift detected
|
|
16
|
+
* 2 config missing or unparseable
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from 'fs';
|
|
20
|
+
import { resolve } from 'path';
|
|
21
|
+
import { parse as parseYaml } from 'yaml';
|
|
22
|
+
import { runDetection } from '../detect/index.ts';
|
|
23
|
+
import { computeFingerprint, detectDrift, type DriftChange } from '../detect/drift.ts';
|
|
24
|
+
import type { AnyConfig } from '../detect/migrate.ts';
|
|
25
|
+
|
|
26
|
+
export interface ConfigCheckDriftOptions {
|
|
27
|
+
verbose?: boolean;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
silent?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConfigCheckDriftResult {
|
|
33
|
+
exitCode: 0 | 1 | 2;
|
|
34
|
+
drifted: boolean;
|
|
35
|
+
changes: DriftChange[];
|
|
36
|
+
storedFingerprint: string | null;
|
|
37
|
+
currentFingerprint: string | null;
|
|
38
|
+
message?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderChanges(changes: DriftChange[]): string {
|
|
42
|
+
if (changes.length === 0) return '(none)\n';
|
|
43
|
+
return changes
|
|
44
|
+
.map((c) => ` ${c.field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`)
|
|
45
|
+
.join('\n') + '\n';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runConfigCheckDrift(
|
|
49
|
+
opts: ConfigCheckDriftOptions = {}
|
|
50
|
+
): Promise<ConfigCheckDriftResult> {
|
|
51
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
52
|
+
const configPath = resolve(cwd, 'massu.config.yaml');
|
|
53
|
+
const log = opts.silent ? () => {} : (s: string) => process.stdout.write(s);
|
|
54
|
+
const err = opts.silent ? () => {} : (s: string) => process.stderr.write(s);
|
|
55
|
+
|
|
56
|
+
if (!existsSync(configPath)) {
|
|
57
|
+
const message = 'massu.config.yaml not found. Run: npx massu init';
|
|
58
|
+
err(message + '\n');
|
|
59
|
+
return {
|
|
60
|
+
exitCode: 2,
|
|
61
|
+
drifted: false,
|
|
62
|
+
changes: [],
|
|
63
|
+
storedFingerprint: null,
|
|
64
|
+
currentFingerprint: null,
|
|
65
|
+
message,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let config: AnyConfig;
|
|
70
|
+
try {
|
|
71
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
72
|
+
const parsed = parseYaml(content);
|
|
73
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
74
|
+
throw new Error('config is not a YAML object');
|
|
75
|
+
}
|
|
76
|
+
config = parsed as AnyConfig;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
const message = `Failed to parse massu.config.yaml: ${e instanceof Error ? e.message : String(e)}`;
|
|
79
|
+
err(message + '\n');
|
|
80
|
+
return {
|
|
81
|
+
exitCode: 2,
|
|
82
|
+
drifted: false,
|
|
83
|
+
changes: [],
|
|
84
|
+
storedFingerprint: null,
|
|
85
|
+
currentFingerprint: null,
|
|
86
|
+
message,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const detection = await runDetection(cwd);
|
|
91
|
+
const currentFp = computeFingerprint(detection);
|
|
92
|
+
const storedFp =
|
|
93
|
+
typeof (config.detection as Record<string, unknown> | undefined)?.fingerprint === 'string'
|
|
94
|
+
? ((config.detection as Record<string, unknown>).fingerprint as string)
|
|
95
|
+
: null;
|
|
96
|
+
|
|
97
|
+
const report = detectDrift(config, detection);
|
|
98
|
+
const fingerprintDrift = storedFp !== null && storedFp !== currentFp;
|
|
99
|
+
const drifted = report.drifted || fingerprintDrift;
|
|
100
|
+
|
|
101
|
+
if (!drifted) {
|
|
102
|
+
log('No drift detected.\n');
|
|
103
|
+
if (opts.verbose) {
|
|
104
|
+
log(`Fingerprint: ${currentFp}\n`);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
exitCode: 0,
|
|
108
|
+
drifted: false,
|
|
109
|
+
changes: report.changes,
|
|
110
|
+
storedFingerprint: storedFp,
|
|
111
|
+
currentFingerprint: currentFp,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
err('Config drift detected; run `npx massu config refresh` to update.\n');
|
|
116
|
+
if (opts.verbose) {
|
|
117
|
+
if (storedFp !== null) {
|
|
118
|
+
log(`Fingerprint: ${storedFp.slice(0, 16)} -> ${currentFp.slice(0, 16)}\n`);
|
|
119
|
+
} else {
|
|
120
|
+
log(`Fingerprint (new): ${currentFp.slice(0, 16)}\n`);
|
|
121
|
+
}
|
|
122
|
+
log('Changes:\n');
|
|
123
|
+
log(renderChanges(report.changes));
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
exitCode: 1,
|
|
127
|
+
drifted: true,
|
|
128
|
+
changes: report.changes,
|
|
129
|
+
storedFingerprint: storedFp,
|
|
130
|
+
currentFingerprint: currentFp,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `massu config refresh` — re-run detection, diff against current config, apply
|
|
6
|
+
* or print-only (--dry-run).
|
|
7
|
+
*
|
|
8
|
+
* Merge semantics:
|
|
9
|
+
* - Detector-owned keys (framework, paths.source, verification, detection) are REFRESHED.
|
|
10
|
+
* - User-authored keys (rules, domains, canonical_paths, accessScopes,
|
|
11
|
+
* knownMismatches, dbAccessPattern, analytics, governance, security, team,
|
|
12
|
+
* regression, cloud, conventions, autoLearning, verification_types,
|
|
13
|
+
* python) are PRESERVED verbatim from the existing config.
|
|
14
|
+
*
|
|
15
|
+
* Flags:
|
|
16
|
+
* --dry-run Emit the diff to stdout, exit 0, never write.
|
|
17
|
+
* (none) Interactive: show diff, prompt for confirmation via @clack/prompts.
|
|
18
|
+
* When stdin is not a TTY, behaves as --dry-run with a note.
|
|
19
|
+
*
|
|
20
|
+
* Exit codes:
|
|
21
|
+
* 0 success (applied, or dry-run completed)
|
|
22
|
+
* 1 missing massu.config.yaml (run `massu init`)
|
|
23
|
+
* 2 unparseable massu.config.yaml
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from 'fs';
|
|
27
|
+
import { resolve } from 'path';
|
|
28
|
+
import { parse as parseYaml } from 'yaml';
|
|
29
|
+
import { runDetection } from '../detect/index.ts';
|
|
30
|
+
import { computeFingerprint } from '../detect/drift.ts';
|
|
31
|
+
import type { AnyConfig } from '../detect/migrate.ts';
|
|
32
|
+
import { copyUnknownKeys, preserveNestedSubkeys } from '../detect/passthrough.ts';
|
|
33
|
+
import { buildConfigFromDetection, renderConfigYaml, writeConfigAtomic } from './init.ts';
|
|
34
|
+
|
|
35
|
+
const PRESERVED_FIELDS = [
|
|
36
|
+
'rules',
|
|
37
|
+
'domains',
|
|
38
|
+
'canonical_paths',
|
|
39
|
+
'verification_types',
|
|
40
|
+
'accessScopes',
|
|
41
|
+
'knownMismatches',
|
|
42
|
+
'dbAccessPattern',
|
|
43
|
+
'analytics',
|
|
44
|
+
'governance',
|
|
45
|
+
'security',
|
|
46
|
+
'team',
|
|
47
|
+
'regression',
|
|
48
|
+
'cloud',
|
|
49
|
+
'conventions',
|
|
50
|
+
'autoLearning',
|
|
51
|
+
'python',
|
|
52
|
+
'toolPrefix',
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
export interface ConfigRefreshOptions {
|
|
56
|
+
dryRun?: boolean;
|
|
57
|
+
cwd?: string;
|
|
58
|
+
silent?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ConfigRefreshResult {
|
|
62
|
+
exitCode: 0 | 1 | 2;
|
|
63
|
+
applied: boolean;
|
|
64
|
+
dryRun: boolean;
|
|
65
|
+
diff: DiffLine[];
|
|
66
|
+
message?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DiffLine {
|
|
70
|
+
kind: 'add' | 'remove' | 'change' | 'same';
|
|
71
|
+
path: string;
|
|
72
|
+
before?: unknown;
|
|
73
|
+
after?: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function flatten(obj: unknown, prefix = ''): Record<string, unknown> {
|
|
77
|
+
const out: Record<string, unknown> = {};
|
|
78
|
+
if (obj === null || obj === undefined) {
|
|
79
|
+
out[prefix || '<root>'] = obj;
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
|
83
|
+
out[prefix || '<root>'] = obj;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
const rec = obj as Record<string, unknown>;
|
|
87
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
88
|
+
const p = prefix ? `${prefix}.${k}` : k;
|
|
89
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
90
|
+
Object.assign(out, flatten(v, p));
|
|
91
|
+
} else {
|
|
92
|
+
out[p] = v;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function computeDiff(before: AnyConfig, after: AnyConfig): DiffLine[] {
|
|
99
|
+
const b = flatten(before);
|
|
100
|
+
const a = flatten(after);
|
|
101
|
+
const keys = new Set<string>([...Object.keys(b), ...Object.keys(a)]);
|
|
102
|
+
const sorted = [...keys].sort();
|
|
103
|
+
const lines: DiffLine[] = [];
|
|
104
|
+
for (const k of sorted) {
|
|
105
|
+
const bVal = b[k];
|
|
106
|
+
const aVal = a[k];
|
|
107
|
+
const bHas = k in b;
|
|
108
|
+
const aHas = k in a;
|
|
109
|
+
if (bHas && !aHas) {
|
|
110
|
+
lines.push({ kind: 'remove', path: k, before: bVal });
|
|
111
|
+
} else if (!bHas && aHas) {
|
|
112
|
+
lines.push({ kind: 'add', path: k, after: aVal });
|
|
113
|
+
} else if (JSON.stringify(bVal) !== JSON.stringify(aVal)) {
|
|
114
|
+
lines.push({ kind: 'change', path: k, before: bVal, after: aVal });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function mergeRefresh(existing: AnyConfig, refreshed: AnyConfig): AnyConfig {
|
|
121
|
+
// P1-008: Start from detector output (fresh framework, paths.source, verification, detection).
|
|
122
|
+
const out: AnyConfig = { ...refreshed };
|
|
123
|
+
|
|
124
|
+
// Restore user-authored top-level sections verbatim.
|
|
125
|
+
for (const field of PRESERVED_FIELDS) {
|
|
126
|
+
if (existing[field] !== undefined) {
|
|
127
|
+
out[field] = existing[field];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Restore toolPrefix from existing (never let detector-defaulted 'massu' overwrite a custom prefix).
|
|
132
|
+
if (typeof existing.toolPrefix === 'string' && existing.toolPrefix !== '') {
|
|
133
|
+
out.toolPrefix = existing.toolPrefix;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// For detector-owned blocks (framework, paths, project), preserve any user subkey the detector didn't emit.
|
|
137
|
+
for (const block of ['framework', 'paths', 'project'] as const) {
|
|
138
|
+
const existingBlock = existing[block];
|
|
139
|
+
const outBlock = out[block];
|
|
140
|
+
if (
|
|
141
|
+
existingBlock && typeof existingBlock === 'object' && !Array.isArray(existingBlock) &&
|
|
142
|
+
outBlock && typeof outBlock === 'object' && !Array.isArray(outBlock)
|
|
143
|
+
) {
|
|
144
|
+
preserveNestedSubkeys(existingBlock, outBlock as Record<string, unknown>);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Restore user-set project.root (detector at init.ts:418 always writes 'auto'; user value wins).
|
|
149
|
+
// Separated from the block loop above for readability (A-004 architecture-review follow-up).
|
|
150
|
+
const existingProject = existing.project;
|
|
151
|
+
const outProject = out.project;
|
|
152
|
+
if (
|
|
153
|
+
existingProject && typeof existingProject === 'object' && !Array.isArray(existingProject) &&
|
|
154
|
+
outProject && typeof outProject === 'object' && !Array.isArray(outProject)
|
|
155
|
+
) {
|
|
156
|
+
const userRoot = (existingProject as Record<string, unknown>).root;
|
|
157
|
+
if (typeof userRoot === 'string' && userRoot !== '') {
|
|
158
|
+
(outProject as Record<string, unknown>).root = userRoot;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// paths.aliases is a 2-level-nested user block. Detector always writes
|
|
163
|
+
// { '@': <source-dir> }; user-authored alias map must survive. Spread user
|
|
164
|
+
// over detector so user keys win for any overlap AND user-only keys survive.
|
|
165
|
+
// (P5-002 discovery — hedge's paths.aliases['@'] was being overwritten.)
|
|
166
|
+
const existingPaths = existing.paths;
|
|
167
|
+
const outPaths = out.paths;
|
|
168
|
+
if (
|
|
169
|
+
existingPaths && typeof existingPaths === 'object' && !Array.isArray(existingPaths) &&
|
|
170
|
+
outPaths && typeof outPaths === 'object' && !Array.isArray(outPaths)
|
|
171
|
+
) {
|
|
172
|
+
const existingAliases = (existingPaths as Record<string, unknown>).aliases;
|
|
173
|
+
const outAliases = (outPaths as Record<string, unknown>).aliases;
|
|
174
|
+
if (
|
|
175
|
+
existingAliases && typeof existingAliases === 'object' && !Array.isArray(existingAliases) &&
|
|
176
|
+
outAliases && typeof outAliases === 'object' && !Array.isArray(outAliases)
|
|
177
|
+
) {
|
|
178
|
+
(outPaths as Record<string, unknown>).aliases = {
|
|
179
|
+
...(outAliases as Record<string, unknown>),
|
|
180
|
+
...(existingAliases as Record<string, unknown>),
|
|
181
|
+
};
|
|
182
|
+
} else if (existingAliases && typeof existingAliases === 'object' && !Array.isArray(existingAliases)) {
|
|
183
|
+
(outPaths as Record<string, unknown>).aliases = existingAliases;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// verification is the other 2-level-nested detector-owned block. Semantics
|
|
188
|
+
// mirror migrate.ts:132-138 buildVerificationBlock: user's custom language
|
|
189
|
+
// sections (e.g., hedge's `gateway`, `ios`, `runtime`, `web`) survive
|
|
190
|
+
// wholesale; user's command overrides on shared languages (e.g., `python`)
|
|
191
|
+
// win over detector defaults. (P5-002 discovery — hedge was losing 15
|
|
192
|
+
// verification command entries across 4 custom language sections plus
|
|
193
|
+
// having 4 python commands overwritten with detector defaults.)
|
|
194
|
+
const existingVer = existing.verification;
|
|
195
|
+
const outVer = out.verification;
|
|
196
|
+
if (
|
|
197
|
+
existingVer && typeof existingVer === 'object' && !Array.isArray(existingVer) &&
|
|
198
|
+
outVer && typeof outVer === 'object' && !Array.isArray(outVer)
|
|
199
|
+
) {
|
|
200
|
+
const eVer = existingVer as Record<string, unknown>;
|
|
201
|
+
const oVer = outVer as Record<string, unknown>;
|
|
202
|
+
for (const lang of Object.keys(eVer)) {
|
|
203
|
+
const userLang = eVer[lang];
|
|
204
|
+
if (userLang === undefined) continue;
|
|
205
|
+
if (!(lang in oVer)) {
|
|
206
|
+
// User-custom language (no detector counterpart) → preserve wholesale.
|
|
207
|
+
oVer[lang] = userLang;
|
|
208
|
+
} else if (
|
|
209
|
+
userLang && typeof userLang === 'object' && !Array.isArray(userLang) &&
|
|
210
|
+
oVer[lang] && typeof oVer[lang] === 'object' && !Array.isArray(oVer[lang])
|
|
211
|
+
) {
|
|
212
|
+
// Shared language → user commands win over detector defaults (spread).
|
|
213
|
+
oVer[lang] = {
|
|
214
|
+
...(oVer[lang] as Record<string, unknown>),
|
|
215
|
+
...(userLang as Record<string, unknown>),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Preserve top-level user keys not handled above (mirrors P1-001 passthrough for upgrade).
|
|
222
|
+
const handledTopLevel = new Set<string>([
|
|
223
|
+
'schema_version', 'project', 'framework', 'paths', 'toolPrefix', 'verification', 'detection',
|
|
224
|
+
...PRESERVED_FIELDS,
|
|
225
|
+
]);
|
|
226
|
+
copyUnknownKeys(existing, out, handledTopLevel);
|
|
227
|
+
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function renderDiff(diff: DiffLine[]): string {
|
|
232
|
+
if (diff.length === 0) return '(no changes)\n';
|
|
233
|
+
const lines: string[] = [];
|
|
234
|
+
for (const d of diff) {
|
|
235
|
+
if (d.kind === 'add') lines.push(`+ ${d.path}: ${JSON.stringify(d.after)}`);
|
|
236
|
+
else if (d.kind === 'remove') lines.push(`- ${d.path}: ${JSON.stringify(d.before)}`);
|
|
237
|
+
else if (d.kind === 'change') {
|
|
238
|
+
lines.push(`~ ${d.path}: ${JSON.stringify(d.before)} -> ${JSON.stringify(d.after)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return lines.join('\n') + '\n';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function runConfigRefresh(opts: ConfigRefreshOptions = {}): Promise<ConfigRefreshResult> {
|
|
245
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
246
|
+
const configPath = resolve(cwd, 'massu.config.yaml');
|
|
247
|
+
const log = opts.silent ? () => {} : (s: string) => process.stdout.write(s);
|
|
248
|
+
|
|
249
|
+
if (!existsSync(configPath)) {
|
|
250
|
+
const message = 'massu.config.yaml not found. Run: npx massu init';
|
|
251
|
+
if (!opts.silent) process.stderr.write(message + '\n');
|
|
252
|
+
return { exitCode: 1, applied: false, dryRun: !!opts.dryRun, diff: [], message };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let existing: AnyConfig;
|
|
256
|
+
try {
|
|
257
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
258
|
+
const parsed = parseYaml(content);
|
|
259
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
260
|
+
throw new Error('config is not a YAML object');
|
|
261
|
+
}
|
|
262
|
+
existing = parsed as AnyConfig;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
const message = `Failed to parse massu.config.yaml: ${err instanceof Error ? err.message : String(err)}`;
|
|
265
|
+
if (!opts.silent) process.stderr.write(message + '\n');
|
|
266
|
+
return { exitCode: 2, applied: false, dryRun: !!opts.dryRun, diff: [], message };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const detection = await runDetection(cwd);
|
|
270
|
+
const refreshed = buildConfigFromDetection({
|
|
271
|
+
projectRoot: cwd,
|
|
272
|
+
detection,
|
|
273
|
+
projectName: typeof (existing.project as Record<string, unknown> | undefined)?.name === 'string'
|
|
274
|
+
? (existing.project as Record<string, unknown>).name as string
|
|
275
|
+
: undefined,
|
|
276
|
+
});
|
|
277
|
+
// buildConfigFromDetection already stamps detection.fingerprint. Double-check.
|
|
278
|
+
if (!(refreshed.detection as Record<string, unknown> | undefined)?.fingerprint) {
|
|
279
|
+
refreshed.detection = { fingerprint: computeFingerprint(detection) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const merged = mergeRefresh(existing, refreshed);
|
|
283
|
+
const diff = computeDiff(existing, merged);
|
|
284
|
+
|
|
285
|
+
if (opts.dryRun) {
|
|
286
|
+
log('Config diff (dry-run; no changes written):\n');
|
|
287
|
+
log(renderDiff(diff));
|
|
288
|
+
return { exitCode: 0, applied: false, dryRun: true, diff };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (diff.length === 0) {
|
|
292
|
+
log('No changes needed — config is already up to date.\n');
|
|
293
|
+
return { exitCode: 0, applied: false, dryRun: false, diff };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Interactive prompt; fall back to dry-run semantics when not a TTY.
|
|
297
|
+
if (!process.stdin.isTTY) {
|
|
298
|
+
log('Config diff (non-interactive; pass --dry-run to suppress this note or run interactively to apply):\n');
|
|
299
|
+
log(renderDiff(diff));
|
|
300
|
+
return {
|
|
301
|
+
exitCode: 0,
|
|
302
|
+
applied: false,
|
|
303
|
+
dryRun: false,
|
|
304
|
+
diff,
|
|
305
|
+
message: 'non-interactive shell; no changes written',
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
log('Config diff:\n');
|
|
310
|
+
log(renderDiff(diff));
|
|
311
|
+
const { confirm } = await import('@clack/prompts');
|
|
312
|
+
const apply = await confirm({ message: 'Apply these changes to massu.config.yaml?' });
|
|
313
|
+
if (apply !== true) {
|
|
314
|
+
log('Aborted; no changes written.\n');
|
|
315
|
+
return { exitCode: 0, applied: false, dryRun: false, diff, message: 'aborted by user' };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const yamlContent = renderConfigYaml(merged);
|
|
319
|
+
const writeRes = writeConfigAtomic(configPath, yamlContent);
|
|
320
|
+
if (!writeRes.validated) {
|
|
321
|
+
const message = `Failed to write config: ${writeRes.error}`;
|
|
322
|
+
if (!opts.silent) process.stderr.write(message + '\n');
|
|
323
|
+
return { exitCode: 2, applied: false, dryRun: false, diff, message };
|
|
324
|
+
}
|
|
325
|
+
log('Config refreshed.\n');
|
|
326
|
+
return { exitCode: 0, applied: true, dryRun: false, diff };
|
|
327
|
+
}
|