@massu/core 1.10.7 → 1.12.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/CHANGELOG.md +1303 -0
- package/dist/adapter.js +6 -4
- package/dist/cli.js +559 -400
- package/dist/detect/adapters/.bundle-shasums.json +4 -7
- package/dist/detect/adapters/rails.js +6 -4
- package/dist/detect/adapters/spring.js +6 -4
- package/dist/detect/adapters/tree-sitter-loader.js +6 -4
- package/dist/hooks/auto-learning-pipeline.js +24 -1
- package/dist/hooks/classify-failure.js +32 -3
- package/dist/hooks/cost-tracker.js +24 -0
- package/dist/hooks/fix-detector.js +97 -1
- package/dist/hooks/incident-pipeline.js +30 -1
- package/dist/hooks/intent-suggester.js +6 -3
- package/dist/hooks/post-edit-context.js +24 -1
- package/dist/hooks/post-tool-use.js +66 -18
- package/dist/hooks/pre-compact.js +27 -2
- package/dist/hooks/pre-delete-check.js +48 -22
- package/dist/hooks/pre-tool-use-gate.js +962 -0
- package/dist/hooks/quality-event.js +24 -0
- package/dist/hooks/rule-enforcement-pipeline.js +24 -1
- package/dist/hooks/security-gate.js +29 -16
- package/dist/hooks/session-end.js +24 -0
- package/dist/hooks/session-start.js +8710 -8460
- package/dist/hooks/user-prompt.js +24 -0
- package/package.json +15 -18
- package/commands/README.md +0 -134
- package/src/adapter.ts +0 -121
- package/src/adr-generator.ts +0 -292
- package/src/analytics.ts +0 -373
- package/src/audit-trail.ts +0 -450
- package/src/backfill-sessions.ts +0 -182
- package/src/changelog-generator.ts +0 -178
- package/src/cli.ts +0 -246
- package/src/cloud-sync.ts +0 -234
- package/src/commands/adapters.ts +0 -824
- package/src/commands/changelog.ts +0 -165
- package/src/commands/config-check-drift.ts +0 -133
- package/src/commands/config-refresh.ts +0 -396
- package/src/commands/config-upgrade.ts +0 -127
- package/src/commands/doctor.ts +0 -485
- package/src/commands/hook-runner.ts +0 -145
- package/src/commands/init.ts +0 -1823
- package/src/commands/install-commands.ts +0 -692
- package/src/commands/install-hooks.ts +0 -27
- package/src/commands/permissions.ts +0 -150
- package/src/commands/refresh-log.ts +0 -37
- package/src/commands/show-template.ts +0 -65
- package/src/commands/template-engine.ts +0 -301
- package/src/commands/watch.ts +0 -430
- package/src/config.ts +0 -765
- package/src/cost-tracker.ts +0 -355
- package/src/db.ts +0 -370
- package/src/dependency-scorer.ts +0 -337
- package/src/detect/adapters/aspnet.ts +0 -4
- package/src/detect/adapters/discover.ts +0 -469
- package/src/detect/adapters/file-sampler.ts +0 -225
- package/src/detect/adapters/go-chi.ts +0 -4
- package/src/detect/adapters/index.ts +0 -51
- package/src/detect/adapters/nextjs-trpc.ts +0 -166
- package/src/detect/adapters/parse-guard.ts +0 -133
- package/src/detect/adapters/phoenix.ts +0 -4
- package/src/detect/adapters/python-django.ts +0 -208
- package/src/detect/adapters/python-fastapi.ts +0 -223
- package/src/detect/adapters/python-flask.ts +0 -235
- package/src/detect/adapters/query-helpers.ts +0 -170
- package/src/detect/adapters/rails.ts +0 -4
- package/src/detect/adapters/runner.ts +0 -284
- package/src/detect/adapters/spring.ts +0 -4
- package/src/detect/adapters/swift-swiftui.ts +0 -171
- package/src/detect/adapters/tree-sitter-loader.ts +0 -517
- package/src/detect/adapters/types.ts +0 -191
- package/src/detect/codebase-introspector.ts +0 -210
- package/src/detect/domain-inferrer.ts +0 -197
- package/src/detect/drift.ts +0 -199
- package/src/detect/framework-detector.ts +0 -307
- package/src/detect/index.ts +0 -209
- package/src/detect/manifest-registry.ts +0 -261
- package/src/detect/migrate.ts +0 -362
- package/src/detect/monorepo-detector.ts +0 -348
- package/src/detect/package-detector.ts +0 -828
- package/src/detect/passthrough.ts +0 -108
- package/src/detect/regex-fallback.ts +0 -449
- package/src/detect/source-dir-detector.ts +0 -297
- package/src/detect/vr-command-map.ts +0 -167
- package/src/docs-map.json +0 -100
- package/src/docs-tools.ts +0 -519
- package/src/domains.ts +0 -182
- package/src/hooks/auto-learning-pipeline.ts +0 -228
- package/src/hooks/classify-failure.ts +0 -259
- package/src/hooks/cost-tracker.ts +0 -66
- package/src/hooks/fix-detector.ts +0 -186
- package/src/hooks/incident-pipeline.ts +0 -190
- package/src/hooks/intent-suggester.ts +0 -131
- package/src/hooks/post-edit-context.ts +0 -91
- package/src/hooks/post-tool-use.ts +0 -413
- package/src/hooks/pre-compact.ts +0 -168
- package/src/hooks/pre-delete-check.ts +0 -243
- package/src/hooks/quality-event.ts +0 -127
- package/src/hooks/rule-enforcement-pipeline.ts +0 -159
- package/src/hooks/security-gate.ts +0 -153
- package/src/hooks/session-end.ts +0 -467
- package/src/hooks/session-start.ts +0 -436
- package/src/hooks/user-prompt.ts +0 -157
- package/src/import-resolver.ts +0 -226
- package/src/knowledge-db.ts +0 -176
- package/src/knowledge-indexer.ts +0 -708
- package/src/knowledge-tools.ts +0 -1414
- package/src/lib/fileLock.ts +0 -203
- package/src/lib/gitToplevel.ts +0 -22
- package/src/lib/hook-registry.ts +0 -43
- package/src/lib/installLock.ts +0 -66
- package/src/lib/memory-path.ts +0 -49
- package/src/lib/pidLiveness.ts +0 -67
- package/src/lib/settings-local.ts +0 -110
- package/src/lib/sql-table-names.ts +0 -71
- package/src/license.ts +0 -547
- package/src/lsp/auto-detect.ts +0 -98
- package/src/lsp/client.ts +0 -776
- package/src/lsp/enrich.ts +0 -127
- package/src/lsp/types.ts +0 -221
- package/src/memory-db.ts +0 -1614
- package/src/memory-file-ingest.ts +0 -128
- package/src/memory-tools.ts +0 -424
- package/src/middleware-tree.ts +0 -71
- package/src/observability-tools.ts +0 -343
- package/src/observation-extractor.ts +0 -418
- package/src/page-deps.ts +0 -285
- package/src/permissions.ts +0 -422
- package/src/prompt-analyzer.ts +0 -332
- package/src/python/coupling-detector.ts +0 -125
- package/src/python/domain-enforcer.ts +0 -84
- package/src/python/impact-analyzer.ts +0 -96
- package/src/python/import-parser.ts +0 -244
- package/src/python/import-resolver.ts +0 -136
- package/src/python/migration-indexer.ts +0 -116
- package/src/python/migration-parser.ts +0 -332
- package/src/python/model-indexer.ts +0 -71
- package/src/python/model-parser.ts +0 -279
- package/src/python/route-indexer.ts +0 -59
- package/src/python/route-parser.ts +0 -317
- package/src/python-tools.ts +0 -630
- package/src/regression-detector.ts +0 -319
- package/src/rules.ts +0 -57
- package/src/schema-mapper.ts +0 -232
- package/src/security/adapter-origin.ts +0 -130
- package/src/security/adapter-verifier.ts +0 -319
- package/src/security/atomic-write.ts +0 -164
- package/src/security/fetcher.ts +0 -200
- package/src/security/install-tracking.ts +0 -319
- package/src/security/license-pubkey.generated.ts +0 -30
- package/src/security/license-response-verifier.ts +0 -149
- package/src/security/local-fingerprint.ts +0 -225
- package/src/security/manifest-cache.ts +0 -333
- package/src/security/manifest-schema.ts +0 -129
- package/src/security/registry-pubkey.generated.ts +0 -35
- package/src/security/telemetry.ts +0 -320
- package/src/security-scorer.ts +0 -405
- package/src/security-utils.ts +0 -133
- package/src/sentinel-db.ts +0 -580
- package/src/sentinel-scanner.ts +0 -406
- package/src/sentinel-tools.ts +0 -513
- package/src/sentinel-types.ts +0 -140
- package/src/server-dispatch.ts +0 -225
- package/src/server.ts +0 -107
- package/src/session-archiver.ts +0 -111
- package/src/session-state-generator.ts +0 -174
- package/src/team-knowledge.ts +0 -407
- package/src/tool-db-needs.ts +0 -232
- package/src/tools.ts +0 -1205
- package/src/transcript-parser.ts +0 -458
- package/src/trpc-index.ts +0 -215
- package/src/validate-features-runner.ts +0 -107
- package/src/validation-engine.ts +0 -358
- package/src/watch/daemon.ts +0 -385
- package/src/watch/lockfile-detector.ts +0 -65
- package/src/watch/paths.ts +0 -279
- package/src/watch/state.ts +0 -178
package/src/commands/init.ts
DELETED
|
@@ -1,1823 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
-
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* `massu init` — One-command, detection-driven project setup.
|
|
6
|
-
*
|
|
7
|
-
* Phase 3 rewrite (2026-04-19): replaces the JS/TS template copier (old
|
|
8
|
-
* detectFramework/generateConfig path, root cause of multi-runtime stale-config drift)
|
|
9
|
-
* with a flow that runs the Phase 1 detection engine (`runDetection`) and
|
|
10
|
-
* generates a v2 schema_version=2 `massu.config.yaml` that reflects the
|
|
11
|
-
* actual repo layout (languages, source_dirs, verification commands, domains).
|
|
12
|
-
*
|
|
13
|
-
* Subcommands / flags:
|
|
14
|
-
* massu init Interactive — prompts on overwrite, stack confirm
|
|
15
|
-
* massu init --ci Non-interactive; errors on conflict
|
|
16
|
-
* massu init --force Overwrite existing config without prompting
|
|
17
|
-
* massu init --template X Greenfield template (skips detection entirely)
|
|
18
|
-
*
|
|
19
|
-
* Post-write guarantees:
|
|
20
|
-
* - Atomic (tmp-file + rename; partial writes never persist)
|
|
21
|
-
* - Zod-validated (load via getConfig — bad config is rolled back + deleted)
|
|
22
|
-
* - declared source_dirs must exist on disk
|
|
23
|
-
*
|
|
24
|
-
* Legacy exports preserved for cli.test.ts and install-hooks.ts:
|
|
25
|
-
* detectFramework, detectPython, generateConfig, registerMcpServer,
|
|
26
|
-
* installHooks, buildHooksConfig, resolveHooksDir, initMemoryDir, runInit.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, writeFileSync, writeSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, chmodSync } from 'fs';
|
|
30
|
-
import { resolve, basename, dirname } from 'path';
|
|
31
|
-
import { fileURLToPath } from 'url';
|
|
32
|
-
import { homedir } from 'os';
|
|
33
|
-
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
|
|
34
|
-
import { backfillMemoryFiles } from '../memory-file-ingest.ts';
|
|
35
|
-
import { getConfig, resetConfig } from '../config.ts';
|
|
36
|
-
import { installAll } from './install-commands.ts';
|
|
37
|
-
import { readSettingsLocal, writeSettingsLocalAtomic } from '../lib/settings-local.ts';
|
|
38
|
-
import { encodeMemoryDirName } from '../lib/memory-path.ts';
|
|
39
|
-
import {
|
|
40
|
-
runDetection,
|
|
41
|
-
type DetectionResult,
|
|
42
|
-
type SupportedLanguage,
|
|
43
|
-
type VRCommandSet,
|
|
44
|
-
} from '../detect/index.ts';
|
|
45
|
-
import { computeFingerprint } from '../detect/drift.ts';
|
|
46
|
-
|
|
47
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
48
|
-
const __dirname = dirname(__filename);
|
|
49
|
-
|
|
50
|
-
// ============================================================
|
|
51
|
-
// Types
|
|
52
|
-
// ============================================================
|
|
53
|
-
|
|
54
|
-
interface FrameworkDetection {
|
|
55
|
-
type: string;
|
|
56
|
-
router: string;
|
|
57
|
-
orm: string;
|
|
58
|
-
ui: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface InitResult {
|
|
62
|
-
configCreated: boolean;
|
|
63
|
-
configSkipped: boolean;
|
|
64
|
-
mcpRegistered: boolean;
|
|
65
|
-
mcpSkipped: boolean;
|
|
66
|
-
hooksInstalled: boolean;
|
|
67
|
-
hooksCount: number;
|
|
68
|
-
framework: FrameworkDetection;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface InitOptions {
|
|
72
|
-
/** Skip all prompts; fail on conflict. Also set when stdin is not a TTY. */
|
|
73
|
-
ci?: boolean;
|
|
74
|
-
/** Overwrite existing config without prompting (ignored in --ci mode). */
|
|
75
|
-
force?: boolean;
|
|
76
|
-
/** Template name for greenfield projects (skips detection). */
|
|
77
|
-
template?: string;
|
|
78
|
-
/** Skip hook/command/memory install side-effects. Used in tests. */
|
|
79
|
-
skipSideEffects?: boolean;
|
|
80
|
-
/**
|
|
81
|
-
* Plan #2 P4-002: when true, skip the asset-install (commands / agents /
|
|
82
|
-
* patterns / etc). MCP register, hooks, and memory init still run.
|
|
83
|
-
*/
|
|
84
|
-
skipCommands?: boolean;
|
|
85
|
-
/** Override cwd (tests). */
|
|
86
|
-
cwd?: string;
|
|
87
|
-
/** Suppress console output. */
|
|
88
|
-
silent?: boolean;
|
|
89
|
-
/**
|
|
90
|
-
* Plan 1.5.4: skip AST adapter introspection that surfaces under
|
|
91
|
-
* `detected.<adapter-id>:` blocks. Default false (introspect runs);
|
|
92
|
-
* set true via `--no-introspect` for fast sync-only init or when
|
|
93
|
-
* the AST tier's grammar download isn't desirable.
|
|
94
|
-
*/
|
|
95
|
-
skipIntrospect?: boolean;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface GenerateConfigV2Options {
|
|
99
|
-
/** Project root to generate against. Detection is run on this directory. */
|
|
100
|
-
projectRoot: string;
|
|
101
|
-
/** Pre-computed detection result (reused if already available). */
|
|
102
|
-
detection?: DetectionResult;
|
|
103
|
-
/** Project name override (default = basename of projectRoot). */
|
|
104
|
-
projectName?: string;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ============================================================
|
|
108
|
-
// Legacy Framework Auto-Detection (preserved for cli.test.ts)
|
|
109
|
-
// ============================================================
|
|
110
|
-
|
|
111
|
-
export function detectFramework(projectRoot: string): FrameworkDetection {
|
|
112
|
-
const result: FrameworkDetection = {
|
|
113
|
-
type: 'javascript',
|
|
114
|
-
router: 'none',
|
|
115
|
-
orm: 'none',
|
|
116
|
-
ui: 'none',
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const pkgPath = resolve(projectRoot, 'package.json');
|
|
120
|
-
if (!existsSync(pkgPath)) return result;
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
124
|
-
const allDeps = {
|
|
125
|
-
...pkg.dependencies,
|
|
126
|
-
...pkg.devDependencies,
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
// Language detection
|
|
130
|
-
if (allDeps['typescript']) result.type = 'typescript';
|
|
131
|
-
|
|
132
|
-
// UI framework detection
|
|
133
|
-
if (allDeps['next']) result.ui = 'nextjs';
|
|
134
|
-
else if (allDeps['@sveltejs/kit']) result.ui = 'sveltekit';
|
|
135
|
-
else if (allDeps['nuxt']) result.ui = 'nuxt';
|
|
136
|
-
else if (allDeps['@angular/core']) result.ui = 'angular';
|
|
137
|
-
else if (allDeps['vue']) result.ui = 'vue';
|
|
138
|
-
else if (allDeps['react']) result.ui = 'react';
|
|
139
|
-
|
|
140
|
-
// Router detection
|
|
141
|
-
if (allDeps['@trpc/server']) result.router = 'trpc';
|
|
142
|
-
else if (allDeps['graphql'] || allDeps['@apollo/server']) result.router = 'graphql';
|
|
143
|
-
else if (allDeps['express'] || allDeps['fastify'] || allDeps['hono']) result.router = 'rest';
|
|
144
|
-
|
|
145
|
-
// ORM detection
|
|
146
|
-
if (allDeps['@prisma/client'] || allDeps['prisma']) result.orm = 'prisma';
|
|
147
|
-
else if (allDeps['drizzle-orm']) result.orm = 'drizzle';
|
|
148
|
-
else if (allDeps['typeorm']) result.orm = 'typeorm';
|
|
149
|
-
else if (allDeps['sequelize']) result.orm = 'sequelize';
|
|
150
|
-
else if (allDeps['mongoose']) result.orm = 'mongoose';
|
|
151
|
-
} catch {
|
|
152
|
-
// Best effort
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return result;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ============================================================
|
|
159
|
-
// Legacy Python Project Detection (preserved for cli.test.ts / back compat)
|
|
160
|
-
// ============================================================
|
|
161
|
-
|
|
162
|
-
interface PythonDetection {
|
|
163
|
-
detected: boolean;
|
|
164
|
-
root: string;
|
|
165
|
-
hasFastapi: boolean;
|
|
166
|
-
hasSqlalchemy: boolean;
|
|
167
|
-
hasAlembic: boolean;
|
|
168
|
-
alembicDir: string | null;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function detectPython(projectRoot: string): PythonDetection {
|
|
172
|
-
const result: PythonDetection = {
|
|
173
|
-
detected: false,
|
|
174
|
-
root: '',
|
|
175
|
-
hasFastapi: false,
|
|
176
|
-
hasSqlalchemy: false,
|
|
177
|
-
hasAlembic: false,
|
|
178
|
-
alembicDir: null,
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
const markers = ['pyproject.toml', 'setup.py', 'requirements.txt', 'Pipfile'];
|
|
182
|
-
const hasMarker = markers.some(m => existsSync(resolve(projectRoot, m)));
|
|
183
|
-
if (!hasMarker) return result;
|
|
184
|
-
|
|
185
|
-
result.detected = true;
|
|
186
|
-
|
|
187
|
-
const depFiles = [
|
|
188
|
-
{ file: 'pyproject.toml' },
|
|
189
|
-
{ file: 'requirements.txt' },
|
|
190
|
-
{ file: 'setup.py' },
|
|
191
|
-
{ file: 'Pipfile' },
|
|
192
|
-
];
|
|
193
|
-
|
|
194
|
-
for (const { file } of depFiles) {
|
|
195
|
-
const filePath = resolve(projectRoot, file);
|
|
196
|
-
if (existsSync(filePath)) {
|
|
197
|
-
try {
|
|
198
|
-
const content = readFileSync(filePath, 'utf-8').toLowerCase();
|
|
199
|
-
if (content.includes('fastapi')) result.hasFastapi = true;
|
|
200
|
-
if (content.includes('sqlalchemy')) result.hasSqlalchemy = true;
|
|
201
|
-
} catch {
|
|
202
|
-
// Best effort
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (existsSync(resolve(projectRoot, 'alembic.ini'))) {
|
|
208
|
-
result.hasAlembic = true;
|
|
209
|
-
if (existsSync(resolve(projectRoot, 'alembic'))) {
|
|
210
|
-
result.alembicDir = 'alembic';
|
|
211
|
-
}
|
|
212
|
-
} else if (existsSync(resolve(projectRoot, 'alembic'))) {
|
|
213
|
-
result.hasAlembic = true;
|
|
214
|
-
result.alembicDir = 'alembic';
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const candidateRoots = ['app', 'src', 'backend', 'api'];
|
|
218
|
-
for (const candidate of candidateRoots) {
|
|
219
|
-
const candidatePath = resolve(projectRoot, candidate);
|
|
220
|
-
if (existsSync(candidatePath) && existsSync(resolve(candidatePath, '__init__.py'))) {
|
|
221
|
-
result.root = candidate;
|
|
222
|
-
break;
|
|
223
|
-
}
|
|
224
|
-
if (existsSync(candidatePath)) {
|
|
225
|
-
try {
|
|
226
|
-
const files = readdirSync(candidatePath);
|
|
227
|
-
if (files.some(f => f.endsWith('.py'))) {
|
|
228
|
-
result.root = candidate;
|
|
229
|
-
break;
|
|
230
|
-
}
|
|
231
|
-
} catch {
|
|
232
|
-
// Best effort
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (!result.root) {
|
|
238
|
-
result.root = '.';
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return result;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// ============================================================
|
|
245
|
-
// Legacy Config File Generation (preserved for cli.test.ts)
|
|
246
|
-
// ============================================================
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* @deprecated Since @massu/core@1.2.1. Use {@link buildConfigFromDetection}
|
|
250
|
-
* with {@link runDetection} for monorepo-aware path resolution and
|
|
251
|
-
* schema_version=2 output. This path hardcodes `paths.source = 'src'` and
|
|
252
|
-
* cannot emit `paths.monorepo_roots`, so it would roll back on every
|
|
253
|
-
* non-`src/` layout. Kept only for the legacy `cli.test.ts` smoke tests;
|
|
254
|
-
* new callers must use the v2 builder.
|
|
255
|
-
*/
|
|
256
|
-
export function generateConfig(projectRoot: string, framework: FrameworkDetection): boolean {
|
|
257
|
-
console.warn(
|
|
258
|
-
'[@massu/core] generateConfig() is deprecated since 1.2.1 — use buildConfigFromDetection instead. It cannot produce valid configs for monorepos.'
|
|
259
|
-
);
|
|
260
|
-
const configPath = resolve(projectRoot, 'massu.config.yaml');
|
|
261
|
-
|
|
262
|
-
if (existsSync(configPath)) {
|
|
263
|
-
return false; // Config already exists
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const projectName = basename(projectRoot);
|
|
267
|
-
|
|
268
|
-
const config: Record<string, unknown> = {
|
|
269
|
-
project: {
|
|
270
|
-
name: projectName,
|
|
271
|
-
root: 'auto',
|
|
272
|
-
},
|
|
273
|
-
framework: {
|
|
274
|
-
type: framework.type,
|
|
275
|
-
router: framework.router,
|
|
276
|
-
orm: framework.orm,
|
|
277
|
-
ui: framework.ui,
|
|
278
|
-
},
|
|
279
|
-
paths: {
|
|
280
|
-
source: 'src',
|
|
281
|
-
aliases: { '@': 'src' },
|
|
282
|
-
},
|
|
283
|
-
toolPrefix: 'massu',
|
|
284
|
-
domains: [],
|
|
285
|
-
rules: [
|
|
286
|
-
{
|
|
287
|
-
pattern: 'src/**/*.ts',
|
|
288
|
-
rules: ['Use ESM imports, not CommonJS'],
|
|
289
|
-
},
|
|
290
|
-
],
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
// Detect and add Python configuration
|
|
294
|
-
const python = detectPython(projectRoot);
|
|
295
|
-
if (python.detected) {
|
|
296
|
-
const pythonConfig: Record<string, unknown> = {
|
|
297
|
-
root: python.root,
|
|
298
|
-
exclude_dirs: ['__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache'],
|
|
299
|
-
};
|
|
300
|
-
if (python.hasFastapi) pythonConfig.framework = 'fastapi';
|
|
301
|
-
if (python.hasSqlalchemy) pythonConfig.orm = 'sqlalchemy';
|
|
302
|
-
if (python.hasAlembic && python.alembicDir) {
|
|
303
|
-
pythonConfig.alembic_dir = python.alembicDir;
|
|
304
|
-
}
|
|
305
|
-
config.python = pythonConfig;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const yamlContent = `# Massu AI Configuration
|
|
309
|
-
# Generated by: npx massu init
|
|
310
|
-
# Documentation: https://massu.ai/docs/getting-started/configuration
|
|
311
|
-
|
|
312
|
-
${yamlStringify(config)}`;
|
|
313
|
-
|
|
314
|
-
writeFileSync(configPath, yamlContent, 'utf-8');
|
|
315
|
-
return true;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// ============================================================
|
|
319
|
-
// V2 Config Builder (detection-driven)
|
|
320
|
-
// ============================================================
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Return the common top-level parent directory across every workspace
|
|
324
|
-
* package. Returns `'.'` when packages span multiple parents (e.g. a repo
|
|
325
|
-
* with both `apps/*` and `packages/*`) — the project root is always a valid
|
|
326
|
-
* paths.source value (see validateWrittenConfig at init.ts:572).
|
|
327
|
-
*/
|
|
328
|
-
function monorepoCommonRoot(
|
|
329
|
-
packages: ReadonlyArray<{ path: string }>
|
|
330
|
-
): string {
|
|
331
|
-
const roots = monorepoDistinctRoots(packages);
|
|
332
|
-
return roots.length === 1 ? roots[0] : '.';
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Return the distinct top-level parent directories of every workspace
|
|
337
|
-
* package (e.g. `['apps', 'packages']` when both are present). Sorted for
|
|
338
|
-
* determinism. Excludes root-level ('.') workspaces.
|
|
339
|
-
*/
|
|
340
|
-
function monorepoDistinctRoots(
|
|
341
|
-
packages: ReadonlyArray<{ path: string }>
|
|
342
|
-
): string[] {
|
|
343
|
-
const set = new Set<string>();
|
|
344
|
-
for (const p of packages) {
|
|
345
|
-
const parts = p.path.split('/');
|
|
346
|
-
if (parts.length > 1 && parts[0] !== '' && parts[0] !== '.') {
|
|
347
|
-
set.add(parts[0]);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
return [...set].sort();
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Build a schema_version=2 config object from a DetectionResult.
|
|
355
|
-
*
|
|
356
|
-
* Contract:
|
|
357
|
-
* - `framework.type` is `'multi'` when 2+ languages present, else the sole language.
|
|
358
|
-
* - `framework.primary` is the language with the most manifests (ties: alpha).
|
|
359
|
-
* - `framework.languages` is populated for every detected language with a
|
|
360
|
-
* non-null framework or test framework.
|
|
361
|
-
* - Legacy top-level `framework.router/.orm/.ui` are mirrored from the primary
|
|
362
|
-
* language entry so existing consumers (tools.ts lines 89/192/246) keep
|
|
363
|
-
* working without any change (per Phase 0 P0-003 + Phase 2 P2-002 contract).
|
|
364
|
-
* - `paths.source` is the dominant directory for the primary language (or '.'
|
|
365
|
-
* for single-repo flat layouts).
|
|
366
|
-
* - `verification.<language>` is pulled from VRCommandMap output.
|
|
367
|
-
* - `domains[]` is the DomainInferrer output (may be empty).
|
|
368
|
-
*/
|
|
369
|
-
export function buildConfigFromDetection(
|
|
370
|
-
opts: GenerateConfigV2Options
|
|
371
|
-
): Record<string, unknown> {
|
|
372
|
-
const { projectRoot, detection } = opts;
|
|
373
|
-
if (!detection) {
|
|
374
|
-
throw new Error('buildConfigFromDetection requires a detection result');
|
|
375
|
-
}
|
|
376
|
-
const projectName = opts.projectName ?? basename(projectRoot);
|
|
377
|
-
|
|
378
|
-
const languages = Array.from(
|
|
379
|
-
new Set(detection.manifests.map((m) => m.language))
|
|
380
|
-
) as SupportedLanguage[];
|
|
381
|
-
|
|
382
|
-
// Pick primary: language with most manifests; ties broken by alphabetical.
|
|
383
|
-
const languageCounts = new Map<SupportedLanguage, number>();
|
|
384
|
-
for (const m of detection.manifests) {
|
|
385
|
-
languageCounts.set(m.language, (languageCounts.get(m.language) ?? 0) + 1);
|
|
386
|
-
}
|
|
387
|
-
const sortedLangs = [...languageCounts.entries()].sort((a, b) => {
|
|
388
|
-
if (b[1] !== a[1]) return b[1] - a[1];
|
|
389
|
-
return a[0].localeCompare(b[0]);
|
|
390
|
-
});
|
|
391
|
-
const primary: SupportedLanguage | null = sortedLangs.length > 0 ? sortedLangs[0][0] : null;
|
|
392
|
-
|
|
393
|
-
const frameworkType = languages.length > 1 ? 'multi' : (languages[0] ?? 'typescript');
|
|
394
|
-
|
|
395
|
-
// Build per-language entries from FrameworkMap.
|
|
396
|
-
const languageEntries: Record<string, Record<string, unknown>> = {};
|
|
397
|
-
for (const lang of languages) {
|
|
398
|
-
const fw = detection.frameworks[lang];
|
|
399
|
-
const dirInfo = detection.sourceDirs[lang];
|
|
400
|
-
const sourceDirs = dirInfo?.source_dirs ?? [];
|
|
401
|
-
const entry: Record<string, unknown> = {};
|
|
402
|
-
if (fw?.framework) entry.framework = fw.framework;
|
|
403
|
-
if (fw?.test_framework) entry.test_framework = fw.test_framework;
|
|
404
|
-
if (fw?.orm) entry.orm = fw.orm;
|
|
405
|
-
if (fw?.router) entry.router = fw.router;
|
|
406
|
-
if (fw?.ui_library) entry.ui = fw.ui_library;
|
|
407
|
-
if (sourceDirs.length > 0) entry.source_dirs = sourceDirs;
|
|
408
|
-
// Only include entries that have at least one field populated.
|
|
409
|
-
if (Object.keys(entry).length > 0) {
|
|
410
|
-
languageEntries[lang] = entry;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Legacy top-level framework fields (mirror from primary language).
|
|
415
|
-
// Preserves tools.ts:89,192,246 reads under v2.
|
|
416
|
-
const primaryEntry = primary ? languageEntries[primary] : undefined;
|
|
417
|
-
const legacyRouter =
|
|
418
|
-
(primaryEntry?.router as string | undefined) ?? 'none';
|
|
419
|
-
const legacyOrm = (primaryEntry?.orm as string | undefined) ?? 'none';
|
|
420
|
-
const legacyUi = (primaryEntry?.ui as string | undefined) ?? 'none';
|
|
421
|
-
|
|
422
|
-
// Determine paths.source from primary language's dominant source dir.
|
|
423
|
-
// P1-003: when the primary language has no detectable source dir AND the
|
|
424
|
-
// repo is a monorepo, fall back to the common parent of workspace packages
|
|
425
|
-
// (e.g. 'apps' for turbo + apps/*, 'packages' for pnpm + packages/*). This
|
|
426
|
-
// prevents the validator from rejecting a nonexistent top-level 'src/' on
|
|
427
|
-
// monorepo shapes where code actually lives under apps/ or packages/.
|
|
428
|
-
let pathsSource = 'src';
|
|
429
|
-
if (primary) {
|
|
430
|
-
const primaryDirs = detection.sourceDirs[primary]?.source_dirs ?? [];
|
|
431
|
-
if (primaryDirs.length > 0) {
|
|
432
|
-
pathsSource = primaryDirs[0];
|
|
433
|
-
} else if (
|
|
434
|
-
detection.monorepo.type !== 'single' &&
|
|
435
|
-
detection.monorepo.packages.length > 0
|
|
436
|
-
) {
|
|
437
|
-
pathsSource = monorepoCommonRoot(detection.monorepo.packages);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// P-H004 (plan-stage-c-high-batch): App Router / Pages Router fallback.
|
|
442
|
-
// When pathsSource would default to 'src' but src/ doesn't exist, check
|
|
443
|
-
// recognized framework conventions before failing validation. Fixes
|
|
444
|
-
// `massu init` outright failure on fresh Next.js 14+ App Router repos
|
|
445
|
-
// (have `app/` + `package.json`, no `src/`) and Pages Router (`pages/`).
|
|
446
|
-
// Final fallback to '.' makes flat-layout projects work too.
|
|
447
|
-
if (pathsSource === 'src' && !existsSync(resolve(projectRoot, 'src'))) {
|
|
448
|
-
const fallbacks = ['app', 'pages', '.'];
|
|
449
|
-
for (const fallback of fallbacks) {
|
|
450
|
-
if (fallback === '.' || existsSync(resolve(projectRoot, fallback))) {
|
|
451
|
-
pathsSource = fallback;
|
|
452
|
-
break;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// P1-005: emit `paths.monorepo_roots` as the distinct parent directories of
|
|
458
|
-
// every workspace package when this is a monorepo. Optional + additive;
|
|
459
|
-
// v1 consumers ignore it. When detection identified a monorepo type
|
|
460
|
-
// (turbo/nx/pnpm/etc) but no manifested workspace packages were found
|
|
461
|
-
// (e.g. fresh-install fixtures with apps/*/main.py that haven't declared
|
|
462
|
-
// sub-manifests yet), fall back to deriving roots from the resolved
|
|
463
|
-
// paths.source so the field is still accurate for monorepo-aware tools.
|
|
464
|
-
let monorepoRoots: string[] | undefined;
|
|
465
|
-
if (detection.monorepo.type !== 'single') {
|
|
466
|
-
if (detection.monorepo.packages.length > 0) {
|
|
467
|
-
monorepoRoots = monorepoDistinctRoots(detection.monorepo.packages);
|
|
468
|
-
} else if (pathsSource !== 'src' && pathsSource !== '.') {
|
|
469
|
-
// Derive from paths.source when no workspace manifests exist.
|
|
470
|
-
monorepoRoots = [pathsSource];
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Verification commands per language.
|
|
475
|
-
const verification: Record<string, Record<string, string>> = {};
|
|
476
|
-
for (const lang of languages) {
|
|
477
|
-
const cmds: VRCommandSet | undefined = detection.verificationCommands[lang];
|
|
478
|
-
if (!cmds) continue;
|
|
479
|
-
const entry: Record<string, string> = {};
|
|
480
|
-
if (cmds.test) entry.test = cmds.test;
|
|
481
|
-
if (cmds.type) entry.type = cmds.type;
|
|
482
|
-
if (cmds.build) entry.build = cmds.build;
|
|
483
|
-
if (cmds.syntax) entry.syntax = cmds.syntax;
|
|
484
|
-
if (cmds.lint) entry.lint = cmds.lint;
|
|
485
|
-
if (Object.keys(entry).length > 0) {
|
|
486
|
-
verification[lang] = entry;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// Domains: emit from inferred + strip defaulting so YAML stays lean.
|
|
491
|
-
const domains = detection.domains.map((d) => {
|
|
492
|
-
const out: Record<string, unknown> = { name: d.name };
|
|
493
|
-
if (d.routers.length > 0) out.routers = d.routers;
|
|
494
|
-
if (d.pages.length > 0) out.pages = d.pages;
|
|
495
|
-
if (d.tables.length > 0) out.tables = d.tables;
|
|
496
|
-
if (d.allowedImportsFrom.length > 0) out.allowedImportsFrom = d.allowedImportsFrom;
|
|
497
|
-
return out;
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
const frameworkBlock: Record<string, unknown> = {
|
|
501
|
-
type: frameworkType,
|
|
502
|
-
router: legacyRouter,
|
|
503
|
-
orm: legacyOrm,
|
|
504
|
-
ui: legacyUi,
|
|
505
|
-
};
|
|
506
|
-
if (languages.length > 1 && primary) {
|
|
507
|
-
frameworkBlock.primary = primary;
|
|
508
|
-
}
|
|
509
|
-
if (Object.keys(languageEntries).length > 0) {
|
|
510
|
-
frameworkBlock.languages = languageEntries;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
const pathsBlock: Record<string, unknown> = {
|
|
514
|
-
source: pathsSource,
|
|
515
|
-
aliases: { '@': pathsSource },
|
|
516
|
-
};
|
|
517
|
-
if (monorepoRoots && monorepoRoots.length > 0) {
|
|
518
|
-
pathsBlock.monorepo_roots = monorepoRoots;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
const config: Record<string, unknown> = {
|
|
522
|
-
schema_version: 2,
|
|
523
|
-
project: {
|
|
524
|
-
name: projectName,
|
|
525
|
-
root: 'auto',
|
|
526
|
-
},
|
|
527
|
-
framework: frameworkBlock,
|
|
528
|
-
paths: pathsBlock,
|
|
529
|
-
toolPrefix: 'massu',
|
|
530
|
-
domains,
|
|
531
|
-
rules: [],
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
if (Object.keys(verification).length > 0) {
|
|
535
|
-
config.verification = verification;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// P5-002: stamp a stack fingerprint so session-start can detect drift later.
|
|
539
|
-
config.detection = { fingerprint: computeFingerprint(detection) };
|
|
540
|
-
|
|
541
|
-
// Plan #2 P3-003: emit detector-owned `detected:` block (per-language
|
|
542
|
-
// conventions sampled from the codebase). Only present when the introspector
|
|
543
|
-
// ran (i.e., not skipped by the session-start hook). Detector-owned →
|
|
544
|
-
// refreshed on every `init`/`config refresh`, NOT in PRESERVED_FIELDS.
|
|
545
|
-
if (detection.detected && Object.keys(detection.detected).length > 0) {
|
|
546
|
-
config.detected = detection.detected;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Preserve legacy `python` block for v1 consumers (domain-enforcer, etc.).
|
|
550
|
-
// Per Phase 0 P1-009 (b): python legacy config coexists with languages.python.
|
|
551
|
-
if (languages.includes('python')) {
|
|
552
|
-
const pySourceDirs = detection.sourceDirs.python?.source_dirs ?? [];
|
|
553
|
-
const pyRoot = pySourceDirs.length > 0 ? pySourceDirs[0] : '.';
|
|
554
|
-
const pyFw = detection.frameworks.python;
|
|
555
|
-
const pythonBlock: Record<string, unknown> = {
|
|
556
|
-
root: pyRoot,
|
|
557
|
-
exclude_dirs: ['__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache'],
|
|
558
|
-
};
|
|
559
|
-
if (pyFw?.framework) pythonBlock.framework = pyFw.framework;
|
|
560
|
-
if (pyFw?.orm) pythonBlock.orm = pyFw.orm;
|
|
561
|
-
// Alembic detection — best-effort via filesystem (detection layer is DB-free).
|
|
562
|
-
if (existsSync(resolve(projectRoot, 'alembic.ini')) || existsSync(resolve(projectRoot, 'alembic'))) {
|
|
563
|
-
pythonBlock.alembic_dir = 'alembic';
|
|
564
|
-
}
|
|
565
|
-
config.python = pythonBlock;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
return config;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* Plan 1.5.1 §3 — variant template merge.
|
|
573
|
-
*
|
|
574
|
-
* Map from detected `framework.languages.<lang>.framework` value → variant
|
|
575
|
-
* template directory under `packages/core/templates/`. Most detected
|
|
576
|
-
* frameworks map 1:1 to a template dir of the same name, but a few have
|
|
577
|
-
* naming divergence (e.g., detection emits `spring-boot` but the template
|
|
578
|
-
* dir is `spring`; detection emits `chi` but the template dir is `go-chi`).
|
|
579
|
-
*
|
|
580
|
-
* The mapping is intentionally tight — only frameworks with an actual
|
|
581
|
-
* variant template under `templates/` are listed. Adding a new framework
|
|
582
|
-
* = one entry here + one templates/<id>/massu.config.yaml file. The
|
|
583
|
-
* `manifest-registry-drift.test.ts` already gates the manifest side; the
|
|
584
|
-
* `init-end-to-end.test.ts` gates this map.
|
|
585
|
-
*/
|
|
586
|
-
const FRAMEWORK_TO_TEMPLATE_ID: Record<string, string> = {
|
|
587
|
-
rails: 'rails',
|
|
588
|
-
phoenix: 'phoenix',
|
|
589
|
-
'aspnet-core': 'aspnet',
|
|
590
|
-
'spring-boot': 'spring',
|
|
591
|
-
chi: 'go-chi',
|
|
592
|
-
flask: 'python-flask',
|
|
593
|
-
};
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* After `buildConfigFromDetection` produces a baseline config, look up the
|
|
597
|
-
* variant template for the detected framework (if any) and selectively
|
|
598
|
-
* merge fields. The variant wins on a small allowlist:
|
|
599
|
-
* - `framework.router`
|
|
600
|
-
* - `framework.orm`
|
|
601
|
-
* - `framework.ui`
|
|
602
|
-
* - `paths.source`
|
|
603
|
-
* - `verification.<lang>.{lint,syntax,test,type,build}` — variant lint
|
|
604
|
-
* is the canonical project-style command (rubocop, credo, etc.) and
|
|
605
|
-
* should not be overridden by the generic detection default.
|
|
606
|
-
*
|
|
607
|
-
* The variant template's `framework.type`, `framework.languages`,
|
|
608
|
-
* `project.name`, `domains`, and `rules` are NOT merged — those come from
|
|
609
|
-
* detection and reflect the actual repo state. Allowlist keeps the merge
|
|
610
|
-
* precise; future fields require an explicit decision.
|
|
611
|
-
*/
|
|
612
|
-
export function applyVariantTemplate(
|
|
613
|
-
config: Record<string, unknown>,
|
|
614
|
-
templatesDir: string | null,
|
|
615
|
-
): Record<string, unknown> {
|
|
616
|
-
if (!templatesDir) return config;
|
|
617
|
-
const fw = config.framework as Record<string, unknown> | undefined;
|
|
618
|
-
if (!fw) return config;
|
|
619
|
-
const langs = fw.languages as Record<string, unknown> | undefined;
|
|
620
|
-
if (!langs || typeof langs !== 'object') return config;
|
|
621
|
-
|
|
622
|
-
// Find the first language that has a `framework` value with a known
|
|
623
|
-
// variant template. Most projects have ONE primary language; in
|
|
624
|
-
// monorepos the detection-driven primary is what we honor.
|
|
625
|
-
let templateId: string | null = null;
|
|
626
|
-
for (const langEntry of Object.values(langs)) {
|
|
627
|
-
if (langEntry && typeof langEntry === 'object') {
|
|
628
|
-
const fwName = (langEntry as Record<string, unknown>).framework;
|
|
629
|
-
if (typeof fwName === 'string' && FRAMEWORK_TO_TEMPLATE_ID[fwName]) {
|
|
630
|
-
templateId = FRAMEWORK_TO_TEMPLATE_ID[fwName];
|
|
631
|
-
break;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
if (templateId === null) return config;
|
|
636
|
-
|
|
637
|
-
const templatePath = resolve(templatesDir, templateId, 'massu.config.yaml');
|
|
638
|
-
if (!existsSync(templatePath)) return config;
|
|
639
|
-
|
|
640
|
-
let template: Record<string, unknown>;
|
|
641
|
-
try {
|
|
642
|
-
// pattern-scanner-allow: yaml-parse — reason: this loads a per-framework
|
|
643
|
-
// variant config-template shipped inside @massu/core. getConfig() reads
|
|
644
|
-
// the project's massu.config.yaml from cwd; this is a SEPARATE file
|
|
645
|
-
// (the template) that doesn't pass through that cache and isn't a Zod-
|
|
646
|
-
// validated config — it's a partial override map.
|
|
647
|
-
template = yamlParse(readFileSync(templatePath, 'utf-8')) as Record<string, unknown>;
|
|
648
|
-
} catch {
|
|
649
|
-
return config;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
const out = { ...config };
|
|
653
|
-
const tplFw = template.framework as Record<string, unknown> | undefined;
|
|
654
|
-
const outFw = (out.framework as Record<string, unknown>) ?? {};
|
|
655
|
-
if (tplFw) {
|
|
656
|
-
if (typeof tplFw.router === 'string' && (!outFw.router || outFw.router === 'none')) {
|
|
657
|
-
outFw.router = tplFw.router;
|
|
658
|
-
}
|
|
659
|
-
if (typeof tplFw.orm === 'string' && (!outFw.orm || outFw.orm === 'none')) {
|
|
660
|
-
outFw.orm = tplFw.orm;
|
|
661
|
-
}
|
|
662
|
-
if (typeof tplFw.ui === 'string' && (!outFw.ui || outFw.ui === 'none')) {
|
|
663
|
-
outFw.ui = tplFw.ui;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
out.framework = outFw;
|
|
667
|
-
|
|
668
|
-
const tplPaths = template.paths as Record<string, unknown> | undefined;
|
|
669
|
-
const outPaths = (out.paths as Record<string, unknown>) ?? {};
|
|
670
|
-
if (tplPaths && typeof tplPaths.source === 'string' && tplPaths.source) {
|
|
671
|
-
outPaths.source = tplPaths.source;
|
|
672
|
-
}
|
|
673
|
-
out.paths = outPaths;
|
|
674
|
-
|
|
675
|
-
const tplVerify = template.verification as Record<string, Record<string, unknown>> | undefined;
|
|
676
|
-
const outVerify = (out.verification as Record<string, Record<string, unknown>>) ?? {};
|
|
677
|
-
if (tplVerify) {
|
|
678
|
-
for (const [lang, tplLangVerify] of Object.entries(tplVerify)) {
|
|
679
|
-
if (!tplLangVerify || typeof tplLangVerify !== 'object') continue;
|
|
680
|
-
const outLangVerify = outVerify[lang] ?? {};
|
|
681
|
-
// Variant wins on lint + syntax (canonical project commands like
|
|
682
|
-
// rubocop, credo, golangci-lint that the generic detection layer
|
|
683
|
-
// doesn't know to suggest). For test/type/build, prefer detection-
|
|
684
|
-
// derived values when present (e.g., monorepo `cd packages/foo`
|
|
685
|
-
// prefixing) and fall back to variant template otherwise.
|
|
686
|
-
for (const key of ['lint', 'syntax']) {
|
|
687
|
-
if (typeof tplLangVerify[key] === 'string' && tplLangVerify[key]) {
|
|
688
|
-
outLangVerify[key] = tplLangVerify[key];
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
for (const key of ['test', 'type', 'build']) {
|
|
692
|
-
if (
|
|
693
|
-
typeof tplLangVerify[key] === 'string' &&
|
|
694
|
-
tplLangVerify[key] &&
|
|
695
|
-
!outLangVerify[key]
|
|
696
|
-
) {
|
|
697
|
-
outLangVerify[key] = tplLangVerify[key];
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
outVerify[lang] = outLangVerify;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
if (Object.keys(outVerify).length > 0) {
|
|
704
|
-
out.verification = outVerify;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
return out;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* Serialize a built config object into YAML with a header comment.
|
|
712
|
-
* Safe for `writeConfigAtomic` and for `fs.writeFileSync` directly.
|
|
713
|
-
*/
|
|
714
|
-
export function renderConfigYaml(config: Record<string, unknown>): string {
|
|
715
|
-
return `# Massu AI Configuration
|
|
716
|
-
# Generated by: npx massu init (schema_version=2, detection-driven)
|
|
717
|
-
# Documentation: https://massu.ai/docs/getting-started/configuration
|
|
718
|
-
|
|
719
|
-
${yamlStringify(config)}`;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// ============================================================
|
|
723
|
-
// Atomic Write + Post-Write Validation (P3-004, P3-005)
|
|
724
|
-
// ============================================================
|
|
725
|
-
|
|
726
|
-
/**
|
|
727
|
-
* Atomically write YAML to `configPath`.
|
|
728
|
-
* 1. Writes to `<configPath>.tmp`.
|
|
729
|
-
* 2. Validates the written file by parsing it as YAML and through the Zod
|
|
730
|
-
* RawConfigSchema via a short-lived `getConfig` reload on a sandboxed cwd.
|
|
731
|
-
* 3. Renames the tmp file to the target.
|
|
732
|
-
* 4. On ANY error, removes the tmp file. No partial config ever persists.
|
|
733
|
-
*
|
|
734
|
-
* Preserves existing file permissions when overwriting.
|
|
735
|
-
*
|
|
736
|
-
* P3-006: never writes outside `configPath`'s directory; caller is responsible
|
|
737
|
-
* for passing an in-project path (enforced at the call-site in runInit).
|
|
738
|
-
*/
|
|
739
|
-
export function writeConfigAtomic(
|
|
740
|
-
configPath: string,
|
|
741
|
-
content: string
|
|
742
|
-
): { validated: boolean; error?: string } {
|
|
743
|
-
const tmpPath = `${configPath}.tmp`;
|
|
744
|
-
|
|
745
|
-
// Preserve existing permissions when overwriting.
|
|
746
|
-
let existingMode: number | undefined;
|
|
747
|
-
if (existsSync(configPath)) {
|
|
748
|
-
try {
|
|
749
|
-
existingMode = statSync(configPath).mode;
|
|
750
|
-
} catch {
|
|
751
|
-
existingMode = undefined;
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
try {
|
|
756
|
-
// Iter-8 fix: ensure the parent directory exists. POSIX `rename(2)`
|
|
757
|
-
// requires the target's parent to exist; otherwise the rename fails
|
|
758
|
-
// with ENOENT and we leak the tmp. The watcher's auto-refresh path
|
|
759
|
-
// never hits this (the configPath is always inside an existing repo
|
|
760
|
-
// with massu.config.yaml already there), but `runInit` on a fresh
|
|
761
|
-
// path under a non-existent parent would fall over before this line.
|
|
762
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
763
|
-
|
|
764
|
-
// Iter-7 fix: write tmp via openSync + writeSync + fsyncSync + closeSync
|
|
765
|
-
// so the data hits the platter BEFORE renameSync. This matches
|
|
766
|
-
// `writeStateAtomic` (watch/state.ts) and the spec doc claim that the
|
|
767
|
-
// 3a watcher's atomic-rename guarantees universally cover all writes
|
|
768
|
-
// touched during a refresh cycle. Without fsync, on certain filesystems
|
|
769
|
-
// (xfs, ext4 `data=writeback`) the rename can land before data, leaving
|
|
770
|
-
// a zero-byte config on power-loss / SIGKILL between writeFileSync and
|
|
771
|
-
// renameSync — a gap the watcher daemon makes more reachable since
|
|
772
|
-
// refresh writes happen unattended every quiescence window.
|
|
773
|
-
const fd = openSync(tmpPath, 'w', 0o644);
|
|
774
|
-
try {
|
|
775
|
-
const buf = Buffer.from(content, 'utf-8');
|
|
776
|
-
writeSync(fd, buf, 0, buf.length, 0);
|
|
777
|
-
fsyncSync(fd);
|
|
778
|
-
} finally {
|
|
779
|
-
closeSync(fd);
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// Validate YAML parses.
|
|
783
|
-
// pattern-scanner-allow: yaml-parse — reason: atomic-write post-validator. We just generated `content` and wrote it to a tmp path; before atomic rename to the final config path we re-parse to verify the bytes we serialized are valid YAML. Calling getConfig() here would re-read from cwd and miss the tmp file.
|
|
784
|
-
const parsed = yamlParse(content);
|
|
785
|
-
if (parsed === null || typeof parsed !== 'object') {
|
|
786
|
-
throw new Error('Generated config is not a valid YAML object');
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// Atomic rename.
|
|
790
|
-
renameSync(tmpPath, configPath);
|
|
791
|
-
|
|
792
|
-
// Restore mode if we had one.
|
|
793
|
-
if (existingMode !== undefined) {
|
|
794
|
-
try {
|
|
795
|
-
chmodSync(configPath, existingMode);
|
|
796
|
-
} catch {
|
|
797
|
-
// Best effort; unreadable mode doesn't block init.
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
return { validated: true };
|
|
802
|
-
} catch (err) {
|
|
803
|
-
// Clean up the temp file on failure.
|
|
804
|
-
if (existsSync(tmpPath)) {
|
|
805
|
-
try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
|
|
806
|
-
}
|
|
807
|
-
return { validated: false, error: err instanceof Error ? err.message : String(err) };
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
/**
|
|
812
|
-
* Validate a written config against the live Zod schema AND filesystem.
|
|
813
|
-
* Returns null on success, an error message on failure.
|
|
814
|
-
*
|
|
815
|
-
* When `checkPaths` is false (template mode, greenfield scaffolds), filesystem
|
|
816
|
-
* existence checks on `paths.source` and per-language source_dirs are skipped.
|
|
817
|
-
*/
|
|
818
|
-
export function validateWrittenConfig(
|
|
819
|
-
configPath: string,
|
|
820
|
-
projectRoot: string,
|
|
821
|
-
checkPaths: boolean = true
|
|
822
|
-
): string | null {
|
|
823
|
-
try {
|
|
824
|
-
if (!existsSync(configPath)) return 'Config file does not exist after write';
|
|
825
|
-
// Parse YAML directly — we deliberately bypass getConfig() here because
|
|
826
|
-
// getConfig caches against process.cwd() and we may be validating a config
|
|
827
|
-
// outside the current working tree (tests, etc.).
|
|
828
|
-
const content = readFileSync(configPath, 'utf-8');
|
|
829
|
-
// pattern-scanner-allow: yaml-parse — reason: see preceding comment block; getConfig() caches against process.cwd() and would either return a stale entry or fail to read a config at an arbitrary projectRoot.
|
|
830
|
-
const parsed = yamlParse(content);
|
|
831
|
-
if (parsed === null || typeof parsed !== 'object') {
|
|
832
|
-
return 'Config is not a valid YAML object';
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// Validate via getConfig by temporarily chdir'ing to projectRoot, since
|
|
836
|
-
// getConfig reads the config from process.cwd(). The Zod safeParse inside
|
|
837
|
-
// getConfig already surfaces actionable errors on malformed configs.
|
|
838
|
-
const prevCwd = process.cwd();
|
|
839
|
-
let changed = false;
|
|
840
|
-
if (prevCwd !== projectRoot) {
|
|
841
|
-
try { process.chdir(projectRoot); changed = true; } catch { /* ignore */ }
|
|
842
|
-
}
|
|
843
|
-
try {
|
|
844
|
-
resetConfig();
|
|
845
|
-
const cfg = getConfig();
|
|
846
|
-
if (checkPaths) {
|
|
847
|
-
// Verify paths.source actually exists on disk (unless '.', which is always valid).
|
|
848
|
-
const src = cfg.paths.source;
|
|
849
|
-
if (src && src !== '.') {
|
|
850
|
-
const srcAbs = resolve(projectRoot, src);
|
|
851
|
-
if (!existsSync(srcAbs)) {
|
|
852
|
-
return `paths.source '${src}' does not exist on disk`;
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
// Verify every declared language source_dir exists.
|
|
856
|
-
const languages = cfg.framework.languages ?? {};
|
|
857
|
-
for (const [lang, entry] of Object.entries(languages)) {
|
|
858
|
-
const rawDirs = (entry as Record<string, unknown>).source_dirs;
|
|
859
|
-
if (!Array.isArray(rawDirs)) continue;
|
|
860
|
-
for (const d of rawDirs) {
|
|
861
|
-
if (typeof d !== 'string' || d === '.') continue;
|
|
862
|
-
const abs = resolve(projectRoot, d);
|
|
863
|
-
if (!existsSync(abs)) {
|
|
864
|
-
return `framework.languages.${lang}.source_dirs '${d}' does not exist on disk`;
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
// P2-001: verify paths.monorepo_roots entries exist on disk (parity
|
|
869
|
-
// with paths.source existence check at line 624-631 above).
|
|
870
|
-
const mRoots = (cfg.paths as Record<string, unknown>).monorepo_roots;
|
|
871
|
-
if (Array.isArray(mRoots)) {
|
|
872
|
-
for (const r of mRoots) {
|
|
873
|
-
if (typeof r !== 'string' || r === '.') continue;
|
|
874
|
-
if (!existsSync(resolve(projectRoot, r))) {
|
|
875
|
-
return `paths.monorepo_roots '${r}' does not exist on disk`;
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
} catch (err) {
|
|
881
|
-
return err instanceof Error ? err.message : String(err);
|
|
882
|
-
} finally {
|
|
883
|
-
if (changed) {
|
|
884
|
-
try { process.chdir(prevCwd); } catch { /* ignore */ }
|
|
885
|
-
}
|
|
886
|
-
resetConfig();
|
|
887
|
-
}
|
|
888
|
-
return null;
|
|
889
|
-
} catch (err) {
|
|
890
|
-
return err instanceof Error ? err.message : String(err);
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
// ============================================================
|
|
895
|
-
// Template Mode (P3-003)
|
|
896
|
-
// ============================================================
|
|
897
|
-
|
|
898
|
-
const TEMPLATE_NAMES = [
|
|
899
|
-
'python-fastapi',
|
|
900
|
-
'python-django',
|
|
901
|
-
'ts-nextjs',
|
|
902
|
-
'ts-nestjs',
|
|
903
|
-
'rust-actix',
|
|
904
|
-
'swift-ios',
|
|
905
|
-
'multi-runtime',
|
|
906
|
-
] as const;
|
|
907
|
-
|
|
908
|
-
export type TemplateName = (typeof TEMPLATE_NAMES)[number];
|
|
909
|
-
|
|
910
|
-
export function isTemplateName(name: string): name is TemplateName {
|
|
911
|
-
return (TEMPLATE_NAMES as readonly string[]).includes(name);
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
export function listTemplates(): readonly string[] {
|
|
915
|
-
return TEMPLATE_NAMES;
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/**
|
|
919
|
-
* Resolve the templates directory.
|
|
920
|
-
* Order:
|
|
921
|
-
* 1. `node_modules/@massu/core/templates` (installed)
|
|
922
|
-
* 2. Relative to compiled dist (dist/../templates)
|
|
923
|
-
* 3. Relative to source (src/../templates)
|
|
924
|
-
*/
|
|
925
|
-
export function resolveTemplatesDir(): string | null {
|
|
926
|
-
const cwd = process.cwd();
|
|
927
|
-
const candidates = [
|
|
928
|
-
// Project-local install: `<project>/node_modules/@massu/core/templates`.
|
|
929
|
-
resolve(cwd, 'node_modules/@massu/core/templates'),
|
|
930
|
-
// Bundled cli.js layout: cli.js sits at `<package>/dist/cli.js`, so
|
|
931
|
-
// templates live one level up at `<package>/templates`. (Plan 1.5.1
|
|
932
|
-
// bug discovery: pre-existing layout assumed `dist/commands/init.js`
|
|
933
|
-
// depth which never matched the bundled cli, so resolveTemplatesDir
|
|
934
|
-
// returned null in production for both `--template` mode AND the
|
|
935
|
-
// applyVariantTemplate path.)
|
|
936
|
-
resolve(__dirname, '../templates'),
|
|
937
|
-
// Legacy nested layouts retained as fallbacks (in case a future
|
|
938
|
-
// build moves cli.js back into a subdirectory).
|
|
939
|
-
resolve(__dirname, '../../templates'),
|
|
940
|
-
resolve(__dirname, '../../../templates'),
|
|
941
|
-
];
|
|
942
|
-
for (const c of candidates) {
|
|
943
|
-
if (existsSync(c)) return c;
|
|
944
|
-
}
|
|
945
|
-
return null;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
export function copyTemplateConfig(
|
|
949
|
-
templateName: TemplateName,
|
|
950
|
-
targetPath: string,
|
|
951
|
-
projectName: string
|
|
952
|
-
): { success: boolean; error?: string } {
|
|
953
|
-
const templatesDir = resolveTemplatesDir();
|
|
954
|
-
if (!templatesDir) {
|
|
955
|
-
return { success: false, error: `Templates directory not found (looked in node_modules and dist/src)` };
|
|
956
|
-
}
|
|
957
|
-
const srcPath = resolve(templatesDir, templateName, 'massu.config.yaml');
|
|
958
|
-
if (!existsSync(srcPath)) {
|
|
959
|
-
return { success: false, error: `Template '${templateName}' not found at ${srcPath}` };
|
|
960
|
-
}
|
|
961
|
-
try {
|
|
962
|
-
let content = readFileSync(srcPath, 'utf-8');
|
|
963
|
-
// Replace {{PROJECT_NAME}} placeholder if present.
|
|
964
|
-
content = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
|
|
965
|
-
writeFileSync(targetPath, content, 'utf-8');
|
|
966
|
-
return { success: true };
|
|
967
|
-
} catch (err) {
|
|
968
|
-
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// ============================================================
|
|
973
|
-
// MCP Server Registration (preserved)
|
|
974
|
-
// ============================================================
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* Read the installer's OWN package.json version. The installer runs from
|
|
978
|
-
* its compiled `dist/cli.js` (under npx cache or a global install); the
|
|
979
|
-
* package.json sits one directory up from that. Used to pin downstream
|
|
980
|
-
* MCP server invocations and hook commands so customers don't drift onto
|
|
981
|
-
* unpinned `@massu/core` (which would resolve to the latest dist-tag on
|
|
982
|
-
* every spawn and silently change behavior across versions).
|
|
983
|
-
*
|
|
984
|
-
* Hard error if the package.json can't be read or has no version field —
|
|
985
|
-
* an unpinned write is a structural drift bug (P-002) and silently
|
|
986
|
-
* falling back to an unversioned `@massu/core` is what we're closing.
|
|
987
|
-
*/
|
|
988
|
-
export function getInstallerVersion(): string {
|
|
989
|
-
// Walk up from this module's compiled location to find package.json.
|
|
990
|
-
// Compiled layout: <root>/dist/cli.js → ../package.json
|
|
991
|
-
// TS source layout: <root>/src/commands/init.ts → ../../package.json
|
|
992
|
-
const candidates = [
|
|
993
|
-
resolve(__dirname, '../package.json'),
|
|
994
|
-
resolve(__dirname, '../../package.json'),
|
|
995
|
-
];
|
|
996
|
-
for (const candidate of candidates) {
|
|
997
|
-
if (existsSync(candidate)) {
|
|
998
|
-
try {
|
|
999
|
-
const pkg = JSON.parse(readFileSync(candidate, 'utf-8'));
|
|
1000
|
-
if (typeof pkg.version === 'string' && pkg.version.length > 0 && pkg.name === '@massu/core') {
|
|
1001
|
-
return pkg.version;
|
|
1002
|
-
}
|
|
1003
|
-
} catch { /* try next */ }
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
throw new Error(
|
|
1007
|
-
'getInstallerVersion: could not resolve @massu/core package.json. ' +
|
|
1008
|
-
'This indicates a corrupt install. Re-install via `npx -y @massu/core init`.',
|
|
1009
|
-
);
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
export function registerMcpServer(projectRoot: string): boolean {
|
|
1013
|
-
const mcpPath = resolve(projectRoot, '.mcp.json');
|
|
1014
|
-
|
|
1015
|
-
let existing: Record<string, unknown> = {};
|
|
1016
|
-
if (existsSync(mcpPath)) {
|
|
1017
|
-
try {
|
|
1018
|
-
existing = JSON.parse(readFileSync(mcpPath, 'utf-8'));
|
|
1019
|
-
} catch {
|
|
1020
|
-
existing = {};
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
const servers = (existing.mcpServers ?? {}) as Record<string, unknown>;
|
|
1025
|
-
if (servers.massu) {
|
|
1026
|
-
return false;
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
// P-002: pin the version so customers don't drift onto unpinned `@massu/core`
|
|
1030
|
-
// (which resolves to the latest dist-tag on every spawn). Closes the structural
|
|
1031
|
-
// class flagged by feedback_mcp_pin_version_in_mcp_json (precedent: 0b60916).
|
|
1032
|
-
const version = getInstallerVersion();
|
|
1033
|
-
servers.massu = {
|
|
1034
|
-
type: 'stdio',
|
|
1035
|
-
command: 'npx',
|
|
1036
|
-
args: ['-y', `@massu/core@${version}`],
|
|
1037
|
-
};
|
|
1038
|
-
|
|
1039
|
-
existing.mcpServers = servers;
|
|
1040
|
-
|
|
1041
|
-
writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
|
|
1042
|
-
return true;
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
// ============================================================
|
|
1046
|
-
// Hook Installation (preserved)
|
|
1047
|
-
// ============================================================
|
|
1048
|
-
|
|
1049
|
-
interface HookEntry {
|
|
1050
|
-
type: 'command';
|
|
1051
|
-
command: string;
|
|
1052
|
-
timeout: number;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
interface HookGroup {
|
|
1056
|
-
matcher?: string;
|
|
1057
|
-
hooks: HookEntry[];
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
type HooksConfig = Record<string, HookGroup[]>;
|
|
1061
|
-
|
|
1062
|
-
/**
|
|
1063
|
-
* @deprecated P-003 (1.9.4+): the path returned here is unsafe to bake into
|
|
1064
|
-
* settings.json — under npx, `__dirname` resolves to whatever cache directory
|
|
1065
|
-
* npx happens to use, which is invalidated on cache clear / upgrade / move.
|
|
1066
|
-
*
|
|
1067
|
-
* Retained only for backward compatibility with `buildHooksConfig(hooksDir)`
|
|
1068
|
-
* callers in existing tests. New code should NEVER consume the returned path
|
|
1069
|
-
* verbatim in a command line; use `hook-runner` invocations instead (see
|
|
1070
|
-
* `buildHooksConfig` which now ignores the argument).
|
|
1071
|
-
*/
|
|
1072
|
-
export function resolveHooksDir(): string {
|
|
1073
|
-
const cwd = process.cwd();
|
|
1074
|
-
const nodeModulesPath = resolve(cwd, 'node_modules/@massu/core/dist/hooks');
|
|
1075
|
-
if (existsSync(nodeModulesPath)) {
|
|
1076
|
-
return 'node_modules/@massu/core/dist/hooks';
|
|
1077
|
-
}
|
|
1078
|
-
const localPath = resolve(__dirname, '../dist/hooks');
|
|
1079
|
-
if (existsSync(localPath)) {
|
|
1080
|
-
return localPath;
|
|
1081
|
-
}
|
|
1082
|
-
return 'node_modules/@massu/core/dist/hooks';
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
/**
|
|
1086
|
-
* Build a single hook-command line. P-003: emits `npx -y @massu/core@<version>
|
|
1087
|
-
* hook-runner <name>` so the hook file is resolved dynamically at fire-time
|
|
1088
|
-
* rather than baked as an absolute path. Pins the version (CR-49-class fix)
|
|
1089
|
-
* so customers don't drift onto unpinned `@massu/core` between hook fires.
|
|
1090
|
-
*/
|
|
1091
|
-
function hookCmd(version: string, hookName: string): string {
|
|
1092
|
-
return `npx -y @massu/core@${version} hook-runner ${hookName}`;
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
/**
|
|
1096
|
-
* Build the canonical Claude Code hooks configuration. The legacy
|
|
1097
|
-
* `hooksDir` parameter is now ignored; we emit `hook-runner` invocations
|
|
1098
|
-
* instead of `node <abs-path>` (see P-003). The parameter is retained
|
|
1099
|
-
* for backward-compatible call sites (existing tests pass a dir).
|
|
1100
|
-
*/
|
|
1101
|
-
export function buildHooksConfig(_hooksDir?: string): HooksConfig {
|
|
1102
|
-
const version = getInstallerVersion();
|
|
1103
|
-
return {
|
|
1104
|
-
SessionStart: [
|
|
1105
|
-
{
|
|
1106
|
-
hooks: [
|
|
1107
|
-
{ type: 'command', command: hookCmd(version, 'session-start'), timeout: 10 },
|
|
1108
|
-
],
|
|
1109
|
-
},
|
|
1110
|
-
],
|
|
1111
|
-
PreToolUse: [
|
|
1112
|
-
{
|
|
1113
|
-
matcher: 'Bash',
|
|
1114
|
-
hooks: [
|
|
1115
|
-
{ type: 'command', command: hookCmd(version, 'security-gate'), timeout: 5 },
|
|
1116
|
-
],
|
|
1117
|
-
},
|
|
1118
|
-
{
|
|
1119
|
-
matcher: 'Bash|Write',
|
|
1120
|
-
hooks: [
|
|
1121
|
-
{ type: 'command', command: hookCmd(version, 'pre-delete-check'), timeout: 5 },
|
|
1122
|
-
],
|
|
1123
|
-
},
|
|
1124
|
-
],
|
|
1125
|
-
PostToolUse: [
|
|
1126
|
-
{
|
|
1127
|
-
hooks: [
|
|
1128
|
-
{ type: 'command', command: hookCmd(version, 'post-tool-use'), timeout: 10 },
|
|
1129
|
-
{ type: 'command', command: hookCmd(version, 'quality-event'), timeout: 5 },
|
|
1130
|
-
{ type: 'command', command: hookCmd(version, 'cost-tracker'), timeout: 5 },
|
|
1131
|
-
],
|
|
1132
|
-
},
|
|
1133
|
-
{
|
|
1134
|
-
matcher: 'Edit|Write',
|
|
1135
|
-
hooks: [
|
|
1136
|
-
{ type: 'command', command: hookCmd(version, 'post-edit-context'), timeout: 5 },
|
|
1137
|
-
// Auto-learning pipeline — classifies failures and detects fixes on
|
|
1138
|
-
// file changes. See Phase 5-6 of the autodetect plan.
|
|
1139
|
-
{ type: 'command', command: hookCmd(version, 'fix-detector'), timeout: 5 },
|
|
1140
|
-
{ type: 'command', command: hookCmd(version, 'classify-failure'), timeout: 5 },
|
|
1141
|
-
],
|
|
1142
|
-
},
|
|
1143
|
-
{
|
|
1144
|
-
matcher: 'Write',
|
|
1145
|
-
hooks: [
|
|
1146
|
-
// Incident + rule enforcement pipelines fire on Write-only (incidents
|
|
1147
|
-
// are authored as .md files; rules are enforced after new-file drops).
|
|
1148
|
-
{ type: 'command', command: hookCmd(version, 'incident-pipeline'), timeout: 5 },
|
|
1149
|
-
{ type: 'command', command: hookCmd(version, 'rule-enforcement-pipeline'), timeout: 5 },
|
|
1150
|
-
],
|
|
1151
|
-
},
|
|
1152
|
-
],
|
|
1153
|
-
Stop: [
|
|
1154
|
-
{
|
|
1155
|
-
hooks: [
|
|
1156
|
-
{ type: 'command', command: hookCmd(version, 'session-end'), timeout: 15 },
|
|
1157
|
-
// Session-end auto-learning aggregation (failure-class roll-up).
|
|
1158
|
-
{ type: 'command', command: hookCmd(version, 'auto-learning-pipeline'), timeout: 10 },
|
|
1159
|
-
],
|
|
1160
|
-
},
|
|
1161
|
-
],
|
|
1162
|
-
PreCompact: [
|
|
1163
|
-
{
|
|
1164
|
-
hooks: [
|
|
1165
|
-
{ type: 'command', command: hookCmd(version, 'pre-compact'), timeout: 10 },
|
|
1166
|
-
],
|
|
1167
|
-
},
|
|
1168
|
-
],
|
|
1169
|
-
UserPromptSubmit: [
|
|
1170
|
-
{
|
|
1171
|
-
hooks: [
|
|
1172
|
-
{ type: 'command', command: hookCmd(version, 'user-prompt'), timeout: 5 },
|
|
1173
|
-
{ type: 'command', command: hookCmd(version, 'intent-suggester'), timeout: 5 },
|
|
1174
|
-
],
|
|
1175
|
-
},
|
|
1176
|
-
],
|
|
1177
|
-
};
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
/**
|
|
1181
|
-
* Deep-merge two hooks configurations. P-012 (1.9.4+) — mirrors the 1.8.0
|
|
1182
|
-
* permissions merge pattern; closes the structural class where wholesale
|
|
1183
|
-
* `settings.hooks = newConfig` silently destroyed customer-defined hooks
|
|
1184
|
-
* on every reinstall.
|
|
1185
|
-
*
|
|
1186
|
-
* Merge semantics:
|
|
1187
|
-
* - Top-level keys (event names: SessionStart, PreToolUse, ...) are unioned.
|
|
1188
|
-
* - For each event, hook-groups are merged by `matcher` (or "" if no matcher).
|
|
1189
|
-
* This is the same identity key Claude Code uses for dispatch — two groups
|
|
1190
|
-
* with the same matcher MUST be coalesced or the dispatcher will pick one
|
|
1191
|
-
* and silently drop the other.
|
|
1192
|
-
* - Within a merged group, hook entries are deduplicated by `command` string
|
|
1193
|
-
* (exact match). Massu's own canonical entries are emitted first, then any
|
|
1194
|
-
* customer entries that don't collide. This preserves customer hooks while
|
|
1195
|
-
* keeping Massu's pipeline behavior deterministic.
|
|
1196
|
-
*
|
|
1197
|
-
* Massu canonical entries are identified by the `npx -y @massu/core@<version>
|
|
1198
|
-
* hook-runner ` prefix. ANY entry not matching that prefix is treated as
|
|
1199
|
-
* customer-defined and preserved verbatim across reinstalls — including
|
|
1200
|
-
* legacy entries from older `@massu/core` versions, which the customer can
|
|
1201
|
-
* clean up at their leisure.
|
|
1202
|
-
*/
|
|
1203
|
-
export function mergeHooksConfig(
|
|
1204
|
-
existing: HooksConfig,
|
|
1205
|
-
additions: HooksConfig,
|
|
1206
|
-
): HooksConfig {
|
|
1207
|
-
const eventNames = new Set<string>([
|
|
1208
|
-
...Object.keys(existing ?? {}),
|
|
1209
|
-
...Object.keys(additions ?? {}),
|
|
1210
|
-
]);
|
|
1211
|
-
|
|
1212
|
-
const merged: HooksConfig = {};
|
|
1213
|
-
for (const event of eventNames) {
|
|
1214
|
-
const existingGroups = (existing?.[event] ?? []) as HookGroup[];
|
|
1215
|
-
const additionGroups = (additions?.[event] ?? []) as HookGroup[];
|
|
1216
|
-
|
|
1217
|
-
// Index by matcher key (use "" sentinel for groups with no matcher).
|
|
1218
|
-
const byMatcher = new Map<string, HookGroup>();
|
|
1219
|
-
|
|
1220
|
-
// Pass 1: seed with EXISTING groups (preserves customer order + structure).
|
|
1221
|
-
for (const group of existingGroups) {
|
|
1222
|
-
const key = group.matcher ?? '';
|
|
1223
|
-
const existingGroup = byMatcher.get(key);
|
|
1224
|
-
if (existingGroup) {
|
|
1225
|
-
// Two existing groups with same matcher — unusual but coalesce defensively.
|
|
1226
|
-
existingGroup.hooks = mergeHookEntries(existingGroup.hooks, group.hooks);
|
|
1227
|
-
} else {
|
|
1228
|
-
byMatcher.set(key, { ...group, hooks: [...(group.hooks ?? [])] });
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
// Pass 2: merge ADDITIONS into the indexed groups.
|
|
1233
|
-
for (const group of additionGroups) {
|
|
1234
|
-
const key = group.matcher ?? '';
|
|
1235
|
-
const existingGroup = byMatcher.get(key);
|
|
1236
|
-
if (existingGroup) {
|
|
1237
|
-
existingGroup.hooks = mergeHookEntries(existingGroup.hooks, group.hooks);
|
|
1238
|
-
} else {
|
|
1239
|
-
byMatcher.set(key, { ...group, hooks: [...(group.hooks ?? [])] });
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
merged[event] = Array.from(byMatcher.values());
|
|
1244
|
-
}
|
|
1245
|
-
return merged;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
/**
|
|
1249
|
-
* Merge two arrays of hook entries, deduplicating by `command`. Customer
|
|
1250
|
-
* entries (any command NOT matching the Massu canonical prefix) are
|
|
1251
|
-
* always preserved.
|
|
1252
|
-
*/
|
|
1253
|
-
function mergeHookEntries(
|
|
1254
|
-
existing: HookEntry[],
|
|
1255
|
-
additions: HookEntry[],
|
|
1256
|
-
): HookEntry[] {
|
|
1257
|
-
const seen = new Set<string>();
|
|
1258
|
-
const result: HookEntry[] = [];
|
|
1259
|
-
// Additions go first so Massu's canonical pipeline order is deterministic.
|
|
1260
|
-
for (const entry of additions ?? []) {
|
|
1261
|
-
if (!entry || typeof entry.command !== 'string') continue;
|
|
1262
|
-
if (seen.has(entry.command)) continue;
|
|
1263
|
-
seen.add(entry.command);
|
|
1264
|
-
result.push(entry);
|
|
1265
|
-
}
|
|
1266
|
-
for (const entry of existing ?? []) {
|
|
1267
|
-
if (!entry || typeof entry.command !== 'string') continue;
|
|
1268
|
-
if (seen.has(entry.command)) continue;
|
|
1269
|
-
seen.add(entry.command);
|
|
1270
|
-
result.push(entry);
|
|
1271
|
-
}
|
|
1272
|
-
return result;
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
export function installHooks(projectRoot: string): { installed: boolean; count: number } {
|
|
1276
|
-
// Read claudeDirName defensively — tests may call installHooks without
|
|
1277
|
-
// ever creating massu.config.yaml, in which case getConfig() throws (since
|
|
1278
|
-
// it reads against process.cwd() and our cwd may not have one).
|
|
1279
|
-
let claudeDirName = '.claude';
|
|
1280
|
-
try {
|
|
1281
|
-
claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
|
|
1282
|
-
} catch {
|
|
1283
|
-
claudeDirName = '.claude';
|
|
1284
|
-
}
|
|
1285
|
-
const claudeDir = resolve(projectRoot, claudeDirName);
|
|
1286
|
-
|
|
1287
|
-
if (!existsSync(claudeDir)) {
|
|
1288
|
-
mkdirSync(claudeDir, { recursive: true });
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
const settings = readSettingsLocal(claudeDir);
|
|
1292
|
-
|
|
1293
|
-
// P-003: hooksDir argument is now unused (kept for legacy callers).
|
|
1294
|
-
// buildHooksConfig emits `npx -y @massu/core@<version> hook-runner` lines.
|
|
1295
|
-
const hooksConfig = buildHooksConfig(resolveHooksDir());
|
|
1296
|
-
|
|
1297
|
-
// P-012: deep-merge with existing customer hooks instead of wholesale
|
|
1298
|
-
// replacement. Mirrors the 1.8.0 permissions-merge pattern (CR-39 trap class).
|
|
1299
|
-
const existingHooks = (settings.hooks as HooksConfig | undefined) ?? {};
|
|
1300
|
-
const mergedHooks = mergeHooksConfig(existingHooks, hooksConfig);
|
|
1301
|
-
|
|
1302
|
-
let hookCount = 0;
|
|
1303
|
-
for (const groups of Object.values(mergedHooks)) {
|
|
1304
|
-
for (const group of groups) {
|
|
1305
|
-
hookCount += group.hooks.length;
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
settings.hooks = mergedHooks;
|
|
1310
|
-
|
|
1311
|
-
writeSettingsLocalAtomic(claudeDir, settings);
|
|
1312
|
-
|
|
1313
|
-
return { installed: true, count: hookCount };
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
// ============================================================
|
|
1317
|
-
// Memory Directory Initialization (preserved)
|
|
1318
|
-
// ============================================================
|
|
1319
|
-
|
|
1320
|
-
export function initMemoryDir(projectRoot: string): { created: boolean; memoryMdCreated: boolean; migratedFromLegacy: boolean } {
|
|
1321
|
-
// P-004 / CR-39: encoding MUST match the reader at `config.ts:getResolvedPaths()`.
|
|
1322
|
-
// The legacy writer prepended an extra `-` (producing `--Users-foo-...`) which
|
|
1323
|
-
// orphaned MEMORY.md from the reader's canonical single-dash path. Shared helper
|
|
1324
|
-
// is the SoT — never re-derive inline.
|
|
1325
|
-
const encodedRoot = encodeMemoryDirName(projectRoot);
|
|
1326
|
-
const memoryDir = resolve(homedir(), `.claude/projects/${encodedRoot}/memory`);
|
|
1327
|
-
|
|
1328
|
-
// Legacy-double-dash migration: if the customer was previously installed by
|
|
1329
|
-
// a buggy version (<1.9.4) that wrote to `--<root>`, detect that orphaned
|
|
1330
|
-
// sibling directory and move its contents into the canonical `-<root>` form.
|
|
1331
|
-
// Idempotent: skips if the legacy dir doesn't exist OR if it's already migrated.
|
|
1332
|
-
let migratedFromLegacy = false;
|
|
1333
|
-
const legacyDir = resolve(homedir(), `.claude/projects/-${encodedRoot}/memory`);
|
|
1334
|
-
if (existsSync(legacyDir) && !existsSync(memoryDir)) {
|
|
1335
|
-
try {
|
|
1336
|
-
mkdirSync(resolve(memoryDir, '..'), { recursive: true });
|
|
1337
|
-
renameSync(legacyDir, memoryDir);
|
|
1338
|
-
// Best-effort cleanup of the now-empty parent (only if empty).
|
|
1339
|
-
try {
|
|
1340
|
-
const legacyParent = resolve(legacyDir, '..');
|
|
1341
|
-
if (existsSync(legacyParent) && readdirSync(legacyParent).length === 0) {
|
|
1342
|
-
rmSync(legacyParent, { recursive: false });
|
|
1343
|
-
}
|
|
1344
|
-
} catch { /* best effort */ }
|
|
1345
|
-
migratedFromLegacy = true;
|
|
1346
|
-
} catch { /* best effort — if migration fails, the new canonical dir is still created below */ }
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
let created = false;
|
|
1350
|
-
if (!existsSync(memoryDir)) {
|
|
1351
|
-
mkdirSync(memoryDir, { recursive: true });
|
|
1352
|
-
created = true;
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
const memoryMdPath = resolve(memoryDir, 'MEMORY.md');
|
|
1356
|
-
let memoryMdCreated = false;
|
|
1357
|
-
if (!existsSync(memoryMdPath)) {
|
|
1358
|
-
const projectName = basename(projectRoot);
|
|
1359
|
-
const memoryContent = `# ${projectName} - Massu Memory
|
|
1360
|
-
|
|
1361
|
-
## Key Learnings
|
|
1362
|
-
<!-- Important patterns and conventions discovered during development -->
|
|
1363
|
-
|
|
1364
|
-
## Common Gotchas
|
|
1365
|
-
<!-- Non-obvious issues and how to avoid them -->
|
|
1366
|
-
|
|
1367
|
-
## Corrections
|
|
1368
|
-
<!-- Wrong behaviors that were corrected and how to prevent them -->
|
|
1369
|
-
|
|
1370
|
-
## File Index
|
|
1371
|
-
<!-- Significant files and directories -->
|
|
1372
|
-
`;
|
|
1373
|
-
writeFileSync(memoryMdPath, memoryContent, 'utf-8');
|
|
1374
|
-
memoryMdCreated = true;
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
return { created, memoryMdCreated, migratedFromLegacy };
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
// ============================================================
|
|
1381
|
-
// Flag Parsing
|
|
1382
|
-
// ============================================================
|
|
1383
|
-
|
|
1384
|
-
export interface ParseInitArgsResult extends InitOptions {
|
|
1385
|
-
/** True when --help / -h was requested. runInit should print help and exit. */
|
|
1386
|
-
help?: boolean;
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
export function parseInitArgs(argv: string[]): ParseInitArgsResult {
|
|
1390
|
-
const opts: ParseInitArgsResult = {};
|
|
1391
|
-
for (let i = 0; i < argv.length; i++) {
|
|
1392
|
-
const a = argv[i];
|
|
1393
|
-
if (a === '--ci') opts.ci = true;
|
|
1394
|
-
else if (a === '--force') opts.force = true;
|
|
1395
|
-
else if (a === '--skip-commands') opts.skipCommands = true;
|
|
1396
|
-
else if (a === '--no-introspect') opts.skipIntrospect = true;
|
|
1397
|
-
else if (a === '--help' || a === '-h') opts.help = true;
|
|
1398
|
-
else if (a === '--template') {
|
|
1399
|
-
const next = argv[i + 1];
|
|
1400
|
-
if (next && !next.startsWith('--')) {
|
|
1401
|
-
opts.template = next;
|
|
1402
|
-
i++;
|
|
1403
|
-
}
|
|
1404
|
-
} else if (a.startsWith('--template=')) {
|
|
1405
|
-
opts.template = a.slice('--template='.length);
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
return opts;
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
export function printInitHelp(): void {
|
|
1412
|
-
console.log(`
|
|
1413
|
-
massu init — detect project stack and generate massu.config.yaml
|
|
1414
|
-
|
|
1415
|
-
Usage:
|
|
1416
|
-
massu init [options]
|
|
1417
|
-
|
|
1418
|
-
Options:
|
|
1419
|
-
--ci Non-interactive mode. Errors on existing config
|
|
1420
|
-
(unless --force). Auto-enabled when stdin is not a TTY.
|
|
1421
|
-
--force Overwrite existing massu.config.yaml without prompting.
|
|
1422
|
-
--template <name> Skip detection and scaffold from a greenfield template.
|
|
1423
|
-
Templates: ${TEMPLATE_NAMES.join(', ')}
|
|
1424
|
-
--skip-commands Skip the asset install (.claude/commands etc).
|
|
1425
|
-
MCP register, hooks, and memory init still run.
|
|
1426
|
-
--help, -h Show this help message
|
|
1427
|
-
|
|
1428
|
-
Examples:
|
|
1429
|
-
massu init # Interactive (prompts before overwriting)
|
|
1430
|
-
massu init --ci # Safe for CI; fails if config already exists
|
|
1431
|
-
massu init --force # Overwrite an existing config
|
|
1432
|
-
massu init --template ts-nextjs # Scaffold from the Next.js template
|
|
1433
|
-
|
|
1434
|
-
Documentation: https://massu.ai/docs/getting-started/configuration
|
|
1435
|
-
`);
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
// ============================================================
|
|
1439
|
-
// Stack summary (for user confirmation)
|
|
1440
|
-
// ============================================================
|
|
1441
|
-
|
|
1442
|
-
function summarizeDetection(detection: DetectionResult): string {
|
|
1443
|
-
const parts: string[] = [];
|
|
1444
|
-
const languages = Array.from(
|
|
1445
|
-
new Set(detection.manifests.map((m) => m.language))
|
|
1446
|
-
) as SupportedLanguage[];
|
|
1447
|
-
for (const lang of languages) {
|
|
1448
|
-
const fw = detection.frameworks[lang];
|
|
1449
|
-
const dirs = detection.sourceDirs[lang]?.source_dirs ?? [];
|
|
1450
|
-
const dirSuffix = dirs.length > 0 ? ` in ${dirs.join(',')}` : '';
|
|
1451
|
-
const fwName = fw?.framework ?? 'no-framework';
|
|
1452
|
-
parts.push(`${capitalize(lang)}/${fwName}${dirSuffix}`);
|
|
1453
|
-
}
|
|
1454
|
-
const mono = detection.monorepo.type;
|
|
1455
|
-
const monoSuffix = mono && mono !== 'single' ? ` [${mono} monorepo]` : '';
|
|
1456
|
-
return parts.join('; ') + monoSuffix;
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
// ============================================================
|
|
1460
|
-
// Main Init Flow (Phase 3 rewrite)
|
|
1461
|
-
// ============================================================
|
|
1462
|
-
|
|
1463
|
-
export async function runInit(argv?: string[], overrides?: InitOptions): Promise<void> {
|
|
1464
|
-
const argsToParse = argv ?? process.argv.slice(3); // argv[0]=node, [1]=cli.js, [2]='init'
|
|
1465
|
-
const parsed = parseInitArgs(argsToParse);
|
|
1466
|
-
if (parsed.help && !overrides?.silent) {
|
|
1467
|
-
printInitHelp();
|
|
1468
|
-
return;
|
|
1469
|
-
}
|
|
1470
|
-
// Strip `help` from parsed before merging (not part of InitOptions).
|
|
1471
|
-
const { help: _help, ...parsedOpts } = parsed;
|
|
1472
|
-
void _help;
|
|
1473
|
-
const opts: InitOptions = { ...parsedOpts, ...(overrides ?? {}) };
|
|
1474
|
-
|
|
1475
|
-
// Auto-CI when stdin is not a TTY (e.g., CI pipes, scripts).
|
|
1476
|
-
if (!opts.ci && !process.stdin.isTTY) {
|
|
1477
|
-
opts.ci = true;
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
const projectRoot = opts.cwd ?? process.cwd();
|
|
1481
|
-
const log = opts.silent ? () => {} : (s: string) => console.log(s);
|
|
1482
|
-
const errLog = opts.silent ? () => {} : (s: string) => console.error(s);
|
|
1483
|
-
|
|
1484
|
-
log('');
|
|
1485
|
-
log('Massu AI - Project Setup');
|
|
1486
|
-
log('========================');
|
|
1487
|
-
log('');
|
|
1488
|
-
|
|
1489
|
-
const configPath = resolve(projectRoot, 'massu.config.yaml');
|
|
1490
|
-
|
|
1491
|
-
// P3-006: safety rails for existing config.
|
|
1492
|
-
if (existsSync(configPath)) {
|
|
1493
|
-
if (opts.ci && !opts.force) {
|
|
1494
|
-
errLog(`error: massu.config.yaml already exists at ${configPath}`);
|
|
1495
|
-
errLog(' rerun with --force to overwrite, or remove the file first');
|
|
1496
|
-
throw new Error('massu init: config exists in --ci mode (no overwrite)');
|
|
1497
|
-
}
|
|
1498
|
-
if (!opts.ci && !opts.force) {
|
|
1499
|
-
// Interactive: prompt to confirm overwrite.
|
|
1500
|
-
const confirmed = await promptOverwrite(configPath);
|
|
1501
|
-
if (!confirmed) {
|
|
1502
|
-
log(' massu.config.yaml preserved — init aborted');
|
|
1503
|
-
return;
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
// else: --force set, proceed with overwrite
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
// Branch 1: template mode (P3-003)
|
|
1510
|
-
if (opts.template) {
|
|
1511
|
-
if (!isTemplateName(opts.template)) {
|
|
1512
|
-
errLog(`error: unknown template '${opts.template}'. Available: ${TEMPLATE_NAMES.join(', ')}`);
|
|
1513
|
-
throw new Error(`Unknown template: ${opts.template}`);
|
|
1514
|
-
}
|
|
1515
|
-
const projectName = basename(projectRoot);
|
|
1516
|
-
const res = copyTemplateConfig(opts.template, configPath, projectName);
|
|
1517
|
-
if (!res.success) {
|
|
1518
|
-
errLog(`error: template copy failed: ${res.error}`);
|
|
1519
|
-
throw new Error(res.error ?? 'template copy failed');
|
|
1520
|
-
}
|
|
1521
|
-
// Validate the template-derived config (skip filesystem existence checks:
|
|
1522
|
-
// templates are explicitly for greenfield projects where the declared dirs
|
|
1523
|
-
// don't exist yet).
|
|
1524
|
-
const validation = validateWrittenConfig(configPath, projectRoot, false);
|
|
1525
|
-
if (validation !== null) {
|
|
1526
|
-
try { rmSync(configPath, { force: true }); } catch { /* ignore */ }
|
|
1527
|
-
errLog(`error: template config failed validation: ${validation}`);
|
|
1528
|
-
throw new Error(`Template config invalid: ${validation}`);
|
|
1529
|
-
}
|
|
1530
|
-
log(` Installed template '${opts.template}' → massu.config.yaml`);
|
|
1531
|
-
if (!opts.skipSideEffects) {
|
|
1532
|
-
installSideEffects(projectRoot, log, opts.skipCommands);
|
|
1533
|
-
}
|
|
1534
|
-
return;
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
// Branch 2: detection-driven path (P3-001, P3-002)
|
|
1538
|
-
const detection = await runDetection(projectRoot);
|
|
1539
|
-
const languageCount = new Set(detection.manifests.map((m) => m.language)).size;
|
|
1540
|
-
const emptyStack = detection.manifests.length === 0 && languageCount === 0;
|
|
1541
|
-
if (emptyStack) {
|
|
1542
|
-
if (opts.ci && !opts.force) {
|
|
1543
|
-
// Plan #2 §"Answer to install-before-stack": interactive `massu init` in
|
|
1544
|
-
// an empty repo is supported. CI mode keeps the strict guard (no
|
|
1545
|
-
// accidental empty-stack configs in pipelines) — pass --force in CI to
|
|
1546
|
-
// explicitly opt into empty-stack init.
|
|
1547
|
-
errLog('error: no languages detected in this directory');
|
|
1548
|
-
errLog(' (no package.json, pyproject.toml, Cargo.toml, etc.)');
|
|
1549
|
-
errLog(' pass --template <name>, --force, or run interactively for empty-stack init');
|
|
1550
|
-
throw new Error('No languages detected');
|
|
1551
|
-
}
|
|
1552
|
-
log(' No languages detected — proceeding with empty-stack init.');
|
|
1553
|
-
log(' After adding a manifest (package.json, pyproject.toml, ...) run: npx massu config refresh');
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
// Emit warnings to stderr for ambiguous / malformed detection.
|
|
1557
|
-
for (const w of detection.warnings) {
|
|
1558
|
-
errLog(`warning: ${w.path}: ${w.reason}`);
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
// Ambiguity warning: multiple languages with similar file density.
|
|
1562
|
-
const dirCounts: { lang: SupportedLanguage; count: number }[] = [];
|
|
1563
|
-
for (const [lang, info] of Object.entries(detection.sourceDirs)) {
|
|
1564
|
-
if (info && typeof info.file_count === 'number') {
|
|
1565
|
-
dirCounts.push({ lang: lang as SupportedLanguage, count: info.file_count });
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
if (dirCounts.length >= 2) {
|
|
1569
|
-
dirCounts.sort((a, b) => b.count - a.count);
|
|
1570
|
-
if (dirCounts[0].count > 0 && dirCounts[1].count / Math.max(dirCounts[0].count, 1) >= 0.5) {
|
|
1571
|
-
errLog(`warning: multiple languages with similar file counts: ${dirCounts.map(d => `${d.lang}=${d.count}`).join(', ')}`);
|
|
1572
|
-
errLog(' primary language chosen by manifest count; review framework.primary in the generated config');
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
log(` Detected: ${summarizeDetection(detection)}`);
|
|
1577
|
-
|
|
1578
|
-
// Interactive confirmation for detected stack.
|
|
1579
|
-
if (!opts.ci && !opts.force) {
|
|
1580
|
-
const confirmed = await promptStackConfirm();
|
|
1581
|
-
if (!confirmed) {
|
|
1582
|
-
log(' init aborted — no changes made');
|
|
1583
|
-
return;
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
// Build config + apply variant template + write atomically. Plan 1.5.1
|
|
1588
|
-
// §3: the framework-specific variant template under
|
|
1589
|
-
// packages/core/templates/<id>/massu.config.yaml supplies router,
|
|
1590
|
-
// paths.source, and verification.<lang>.lint that the generic
|
|
1591
|
-
// detection-derived baseline doesn't know to set. Pre-1.5.1 init
|
|
1592
|
-
// emitted configs with `router: none` even for clear-Rails / clear-
|
|
1593
|
-
// Phoenix / clear-Spring projects (CR-39 violation per the Plan 1.5.1
|
|
1594
|
-
// 5-fixture verification).
|
|
1595
|
-
const baseConfig = buildConfigFromDetection({ projectRoot, detection });
|
|
1596
|
-
const withVariant = applyVariantTemplate(baseConfig, resolveTemplatesDir());
|
|
1597
|
-
|
|
1598
|
-
// Plan 1.5.4 §3: pipe AST adapter introspect output into the emitted
|
|
1599
|
-
// config under `detected.<adapter-id>:` blocks. introspectAsync runs
|
|
1600
|
-
// the AST adapter pipeline (using the real file sampler from
|
|
1601
|
-
// codebase-introspector.ts post-1.5.4); each adapter that returns
|
|
1602
|
-
// non-'none' confidence surfaces its conventions + provenance.
|
|
1603
|
-
// --no-introspect bypasses for users who want sync init.
|
|
1604
|
-
let config = withVariant;
|
|
1605
|
-
if (!opts.skipIntrospect) {
|
|
1606
|
-
try {
|
|
1607
|
-
const { introspectAsync } = await import('../detect/codebase-introspector.ts');
|
|
1608
|
-
const introspected = await introspectAsync(detection, projectRoot);
|
|
1609
|
-
const detectedBlocks: Record<string, unknown> = {};
|
|
1610
|
-
for (const [key, block] of Object.entries(introspected)) {
|
|
1611
|
-
// `introspected` includes language-keyed regex-fallback blocks
|
|
1612
|
-
// (`python`, `swift`, `typescript`) AND adapter-id-keyed AST
|
|
1613
|
-
// blocks. The AST blocks are the ones we want under
|
|
1614
|
-
// `detected.<adapter-id>:` — distinguishable by the presence of
|
|
1615
|
-
// a `_confidence` field on the block (the regex blocks don't
|
|
1616
|
-
// carry it). Filter for AST adapter outputs only.
|
|
1617
|
-
if (block && typeof block === 'object' && '_confidence' in (block as Record<string, unknown>)) {
|
|
1618
|
-
detectedBlocks[key] = block;
|
|
1619
|
-
}
|
|
1620
|
-
}
|
|
1621
|
-
if (Object.keys(detectedBlocks).length > 0) {
|
|
1622
|
-
config = { ...config, detected: detectedBlocks };
|
|
1623
|
-
}
|
|
1624
|
-
} catch (err) {
|
|
1625
|
-
// Non-fatal: AST introspect is enrichment, not core detection.
|
|
1626
|
-
// Log to stderr so operators see the warning if it matters.
|
|
1627
|
-
errLog(`warning: AST adapter introspection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
const content = renderConfigYaml(config);
|
|
1632
|
-
const writeRes = writeConfigAtomic(configPath, content);
|
|
1633
|
-
if (!writeRes.validated) {
|
|
1634
|
-
errLog(`error: failed to write config: ${writeRes.error}`);
|
|
1635
|
-
throw new Error(writeRes.error ?? 'atomic write failed');
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
// Post-write validation; rollback on failure. Skip filesystem-existence
|
|
1639
|
-
// checks for empty-stack init (no manifests = `paths.source` defaults to
|
|
1640
|
-
// 'src' which legitimately doesn't exist in an empty dir).
|
|
1641
|
-
const validation = validateWrittenConfig(configPath, projectRoot, !emptyStack);
|
|
1642
|
-
if (validation !== null) {
|
|
1643
|
-
try { rmSync(configPath, { force: true }); } catch { /* ignore */ }
|
|
1644
|
-
errLog(`error: generated config failed validation: ${validation}`);
|
|
1645
|
-
errLog(' config file rolled back; no changes persisted');
|
|
1646
|
-
throw new Error(`Generated config invalid: ${validation}`);
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
log(' Created massu.config.yaml (schema_version: 2)');
|
|
1650
|
-
|
|
1651
|
-
if (!opts.skipSideEffects) {
|
|
1652
|
-
installSideEffects(projectRoot, log, opts.skipCommands, emptyStack);
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
/** Shared side-effect steps (MCP register + hooks + commands + memory + backfill). */
|
|
1657
|
-
function installSideEffects(
|
|
1658
|
-
projectRoot: string,
|
|
1659
|
-
log: (s: string) => void,
|
|
1660
|
-
skipCommands: boolean = false,
|
|
1661
|
-
emptyStack: boolean = false,
|
|
1662
|
-
): void {
|
|
1663
|
-
// MCP register
|
|
1664
|
-
const mcpRegistered = registerMcpServer(projectRoot);
|
|
1665
|
-
if (mcpRegistered) {
|
|
1666
|
-
log(' Registered MCP server in .mcp.json');
|
|
1667
|
-
} else {
|
|
1668
|
-
log(' MCP server already registered in .mcp.json');
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
// Hooks
|
|
1672
|
-
const { count: hooksCount } = installHooks(projectRoot);
|
|
1673
|
-
log(` Installed ${hooksCount} hooks in .claude/settings.local.json`);
|
|
1674
|
-
|
|
1675
|
-
// Plan #2 P4-002: install all asset types (commands, agents, patterns,
|
|
1676
|
-
// protocols, reference) via installAll — replaces the legacy
|
|
1677
|
-
// installCommands() that only handled commands. Skipped when --skip-commands.
|
|
1678
|
-
// Plan #2 P4-003: when no stack-specific commands resolved (empty-stack init),
|
|
1679
|
-
// write a single `_massu-needs-stack.md` placeholder so consumers know to
|
|
1680
|
-
// run `config refresh` after adding their first manifest.
|
|
1681
|
-
if (!skipCommands) {
|
|
1682
|
-
try {
|
|
1683
|
-
const cmdResult = installAll(projectRoot);
|
|
1684
|
-
const cmdTotal =
|
|
1685
|
-
cmdResult.totalInstalled +
|
|
1686
|
-
cmdResult.totalUpdated +
|
|
1687
|
-
cmdResult.totalSkipped +
|
|
1688
|
-
cmdResult.totalKept;
|
|
1689
|
-
if (cmdResult.totalInstalled > 0 || cmdResult.totalUpdated > 0) {
|
|
1690
|
-
log(` Installed ${cmdTotal} project assets (${cmdResult.totalInstalled} new, ${cmdResult.totalUpdated} updated)`);
|
|
1691
|
-
} else if (cmdTotal > 0) {
|
|
1692
|
-
log(` ${cmdTotal} project assets already up to date`);
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
// Empty-stack init detection: when caller signals an empty stack OR
|
|
1696
|
-
// when NO commands resolved at all, drop the placeholder so the user
|
|
1697
|
-
// understands the next step. The explicit `emptyStack` signal handles
|
|
1698
|
-
// the t=0 case (zero manifests detected) where generic-default commands
|
|
1699
|
-
// still install but no stack-specific scaffolds match the consumer.
|
|
1700
|
-
const commandStats = cmdResult.assets.commands;
|
|
1701
|
-
const stackResolved = !emptyStack && commandStats &&
|
|
1702
|
-
(commandStats.installed > 0 || commandStats.updated > 0 || commandStats.kept > 0);
|
|
1703
|
-
if (!stackResolved) {
|
|
1704
|
-
const placeholderPath = resolve(cmdResult.claudeDir, 'commands', '_massu-needs-stack.md');
|
|
1705
|
-
if (!existsSync(placeholderPath)) {
|
|
1706
|
-
const placeholderBody = [
|
|
1707
|
-
'# Massu — stack not yet detected',
|
|
1708
|
-
'',
|
|
1709
|
-
'Your stack hasn\'t been detected yet. Most slash commands ship as language-specific',
|
|
1710
|
-
'variants (e.g., `massu-scaffold-router.python-fastapi.md` for FastAPI projects).',
|
|
1711
|
-
'When detection finds a manifest, the right variants get installed automatically.',
|
|
1712
|
-
'',
|
|
1713
|
-
'After you add your first manifest (`package.json`, `pyproject.toml`, `Cargo.toml`,',
|
|
1714
|
-
'etc.) run:',
|
|
1715
|
-
'',
|
|
1716
|
-
'```bash',
|
|
1717
|
-
'npx massu config refresh',
|
|
1718
|
-
'```',
|
|
1719
|
-
'',
|
|
1720
|
-
'This file will be auto-removed on the first refresh that resolves at least one',
|
|
1721
|
-
'stack-specific command.',
|
|
1722
|
-
'',
|
|
1723
|
-
'— Massu',
|
|
1724
|
-
].join('\n');
|
|
1725
|
-
try {
|
|
1726
|
-
mkdirSync(resolve(cmdResult.claudeDir, 'commands'), { recursive: true });
|
|
1727
|
-
writeFileSync(placeholderPath, placeholderBody, 'utf-8');
|
|
1728
|
-
log(' Wrote _massu-needs-stack.md placeholder (no stack detected yet)');
|
|
1729
|
-
} catch {
|
|
1730
|
-
// Best-effort.
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
} catch {
|
|
1735
|
-
// Best-effort — don't fail init if assets can't be resolved.
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
// Memory dir
|
|
1740
|
-
const { created: memDirCreated, memoryMdCreated, migratedFromLegacy } = initMemoryDir(projectRoot);
|
|
1741
|
-
if (memDirCreated) {
|
|
1742
|
-
log(' Created memory directory');
|
|
1743
|
-
}
|
|
1744
|
-
if (memoryMdCreated) {
|
|
1745
|
-
log(' Created initial MEMORY.md');
|
|
1746
|
-
}
|
|
1747
|
-
if (migratedFromLegacy) {
|
|
1748
|
-
log(' Migrated memory directory from legacy double-dash path (pre-1.9.4)');
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
// Backfill (best-effort, silent failure)
|
|
1752
|
-
(async () => {
|
|
1753
|
-
try {
|
|
1754
|
-
// Shared encode helper — must match `initMemoryDir` and `config.ts:getResolvedPaths()`.
|
|
1755
|
-
const encodedRoot = encodeMemoryDirName(projectRoot);
|
|
1756
|
-
const memoryDir = resolve(homedir(), '.claude', 'projects', encodedRoot, 'memory');
|
|
1757
|
-
const memFiles = existsSync(memoryDir)
|
|
1758
|
-
? readdirSync(memoryDir).filter(f => f.endsWith('.md') && f !== 'MEMORY.md')
|
|
1759
|
-
: [];
|
|
1760
|
-
if (memFiles.length > 0) {
|
|
1761
|
-
const { getMemoryDb } = await import('../memory-db.ts');
|
|
1762
|
-
const db = getMemoryDb();
|
|
1763
|
-
try {
|
|
1764
|
-
const stats = backfillMemoryFiles(db, memoryDir, `init-${Date.now()}`);
|
|
1765
|
-
if (stats.inserted > 0 || stats.updated > 0) {
|
|
1766
|
-
log(` Backfilled ${stats.inserted + stats.updated} memory files (${stats.inserted} new, ${stats.updated} updated)`);
|
|
1767
|
-
}
|
|
1768
|
-
} finally {
|
|
1769
|
-
db.close();
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
} catch { /* best effort */ }
|
|
1773
|
-
})();
|
|
1774
|
-
|
|
1775
|
-
log(' Databases will auto-create on first session');
|
|
1776
|
-
log('');
|
|
1777
|
-
log('Massu AI is ready. Start a Claude Code session to begin.');
|
|
1778
|
-
log('');
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
// ============================================================
|
|
1782
|
-
// Prompts (interactive path)
|
|
1783
|
-
// ============================================================
|
|
1784
|
-
|
|
1785
|
-
async function promptOverwrite(configPath: string): Promise<boolean> {
|
|
1786
|
-
try {
|
|
1787
|
-
const { confirm, isCancel } = await import('@clack/prompts');
|
|
1788
|
-
const res = await confirm({
|
|
1789
|
-
message: `massu.config.yaml already exists at ${configPath}. Overwrite?`,
|
|
1790
|
-
initialValue: false,
|
|
1791
|
-
});
|
|
1792
|
-
if (isCancel(res)) return false;
|
|
1793
|
-
return res === true;
|
|
1794
|
-
} catch {
|
|
1795
|
-
// Clack not available (should never happen — it's a dep); fail safe to NO.
|
|
1796
|
-
return false;
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
async function promptStackConfirm(): Promise<boolean> {
|
|
1801
|
-
try {
|
|
1802
|
-
const { confirm, isCancel } = await import('@clack/prompts');
|
|
1803
|
-
const res = await confirm({
|
|
1804
|
-
message: 'Generate massu.config.yaml from detected stack?',
|
|
1805
|
-
initialValue: true,
|
|
1806
|
-
});
|
|
1807
|
-
if (isCancel(res)) return false;
|
|
1808
|
-
return res === true;
|
|
1809
|
-
} catch {
|
|
1810
|
-
return true; // Default yes when clack is unavailable.
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
// ============================================================
|
|
1815
|
-
// Helpers
|
|
1816
|
-
// ============================================================
|
|
1817
|
-
|
|
1818
|
-
function capitalize(str: string): string {
|
|
1819
|
-
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
// `InitResult` is a compile-time type only; it's kept for external type-reuse.
|
|
1823
|
-
export type { InitResult };
|