@massu/core 1.2.1 → 1.4.0-soak.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/commands/README.md +137 -0
- package/commands/massu-deploy.python-docker.md +170 -0
- package/commands/massu-deploy.python-fly.md +189 -0
- package/commands/massu-deploy.python-launchd.md +144 -0
- package/commands/massu-deploy.python-systemd.md +163 -0
- package/commands/massu-deploy.python.md +200 -0
- package/commands/massu-scaffold-page.md +172 -59
- package/commands/massu-scaffold-page.swift.md +121 -0
- package/commands/massu-scaffold-router.python-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/commands/massu-scaffold-router.python.md +143 -0
- package/dist/cli.js +10170 -4138
- package/dist/hooks/auto-learning-pipeline.js +44 -6
- package/dist/hooks/classify-failure.js +44 -6
- package/dist/hooks/cost-tracker.js +44 -6
- package/dist/hooks/fix-detector.js +44 -6
- package/dist/hooks/incident-pipeline.js +44 -6
- package/dist/hooks/post-edit-context.js +44 -6
- package/dist/hooks/post-tool-use.js +44 -6
- package/dist/hooks/pre-compact.js +44 -6
- package/dist/hooks/pre-delete-check.js +44 -6
- package/dist/hooks/quality-event.js +44 -6
- package/dist/hooks/rule-enforcement-pipeline.js +44 -6
- package/dist/hooks/session-end.js +44 -6
- package/dist/hooks/session-start.js +4789 -410
- package/dist/hooks/user-prompt.js +44 -6
- package/package.json +10 -4
- package/src/cli.ts +28 -2
- package/src/commands/config-refresh.ts +88 -20
- package/src/commands/init.ts +130 -23
- package/src/commands/install-commands.ts +482 -42
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/show-template.ts +65 -0
- package/src/commands/template-engine.ts +262 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +69 -3
- package/src/detect/adapters/nextjs-trpc.ts +166 -0
- package/src/detect/adapters/parse-guard.ts +133 -0
- package/src/detect/adapters/python-django.ts +208 -0
- package/src/detect/adapters/python-fastapi.ts +223 -0
- package/src/detect/adapters/query-helpers.ts +170 -0
- package/src/detect/adapters/runner.ts +252 -0
- package/src/detect/adapters/swift-swiftui.ts +171 -0
- package/src/detect/adapters/tree-sitter-loader.ts +348 -0
- package/src/detect/adapters/types.ts +174 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- package/src/detect/regex-fallback.ts +449 -0
- package/src/hooks/session-start.ts +94 -3
- package/src/lib/gitToplevel.ts +22 -0
- package/src/lib/installLock.ts +179 -0
- package/src/lib/pidLiveness.ts +67 -0
- package/src/lsp/auto-detect.ts +89 -0
- package/src/lsp/client.ts +590 -0
- package/src/lsp/enrich.ts +127 -0
- package/src/lsp/types.ts +221 -0
- package/src/watch/daemon.ts +385 -0
- package/src/watch/lockfile-detector.ts +65 -0
- package/src/watch/paths.ts +279 -0
- package/src/watch/state.ts +178 -0
|
@@ -16,6 +16,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
16
16
|
import type Database from 'better-sqlite3';
|
|
17
17
|
import { runDetection } from '../detect/index.ts';
|
|
18
18
|
import { computeFingerprint } from '../detect/drift.ts';
|
|
19
|
+
import { isPidAlive } from '../lib/pidLiveness.ts';
|
|
19
20
|
|
|
20
21
|
interface HookInput {
|
|
21
22
|
session_id: string;
|
|
@@ -68,9 +69,14 @@ async function main(): Promise<void> {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// P5-001: drift banner (runs after memory context, independent of it).
|
|
72
|
+
// Plan 3a Phase 6: when a live watcher daemon exists, the drift banner
|
|
73
|
+
// is suppressed in favor of a compact watcher banner.
|
|
71
74
|
const driftBanner = await buildDriftBanner();
|
|
72
75
|
if (driftBanner) {
|
|
73
76
|
process.stdout.write(driftBanner);
|
|
77
|
+
} else {
|
|
78
|
+
const watcherBanner = buildWatcherBanner();
|
|
79
|
+
if (watcherBanner) process.stdout.write(watcherBanner);
|
|
74
80
|
}
|
|
75
81
|
} finally {
|
|
76
82
|
db.close();
|
|
@@ -261,6 +267,15 @@ function readStdin(): Promise<string> {
|
|
|
261
267
|
*/
|
|
262
268
|
async function buildDriftBanner(): Promise<string> {
|
|
263
269
|
try {
|
|
270
|
+
// Plan #2 P4-004: explicit opt-out for users in deliberate mid-migration windows.
|
|
271
|
+
// Stays at the top so MASSU_DRIFT_QUIET=1 remains the strongest signal
|
|
272
|
+
// (iter-1 G8: env-var override beats watcher-state suppression).
|
|
273
|
+
if (process.env.MASSU_DRIFT_QUIET === '1') return '';
|
|
274
|
+
|
|
275
|
+
// Plan 3a Phase 6: if a live watcher daemon refreshed within the last 24h,
|
|
276
|
+
// suppress this banner — the watcher already keeps the config current.
|
|
277
|
+
if (watcherIsLiveAndFresh()) return '';
|
|
278
|
+
|
|
264
279
|
const configPath = resolve(process.cwd(), 'massu.config.yaml');
|
|
265
280
|
if (!existsSync(configPath)) return '';
|
|
266
281
|
const content = readFileSync(configPath, 'utf-8');
|
|
@@ -269,7 +284,10 @@ async function buildDriftBanner(): Promise<string> {
|
|
|
269
284
|
const det = parsed.detection as Record<string, unknown> | undefined;
|
|
270
285
|
const storedFp = typeof det?.fingerprint === 'string' ? (det.fingerprint as string) : null;
|
|
271
286
|
if (!storedFp) return '';
|
|
272
|
-
|
|
287
|
+
// Plan #2 P4-006: skip the codebase introspector pass — the drift banner
|
|
288
|
+
// only needs the fingerprint, not the introspected detail. Saves up to 2s
|
|
289
|
+
// wall-clock from the hook's 5-second budget.
|
|
290
|
+
const detection = await runDetection(process.cwd(), undefined, { skipIntrospect: true });
|
|
273
291
|
const currentFp = computeFingerprint(detection);
|
|
274
292
|
if (currentFp === storedFp) return '';
|
|
275
293
|
return (
|
|
@@ -277,6 +295,9 @@ async function buildDriftBanner(): Promise<string> {
|
|
|
277
295
|
'Detected stack has changed since last config refresh.\n' +
|
|
278
296
|
`Fingerprint: ${storedFp.slice(0, 16)} -> ${currentFp.slice(0, 16)}\n` +
|
|
279
297
|
'Run: npx massu config refresh\n' +
|
|
298
|
+
'(this will update massu.config.yaml AND any commands that need\n' +
|
|
299
|
+
' re-templating for your new stack)\n' +
|
|
300
|
+
'Tip: set MASSU_DRIFT_QUIET=1 to suppress this banner during mid-migration.\n' +
|
|
280
301
|
'=== END ===\n'
|
|
281
302
|
);
|
|
282
303
|
} catch (_e) {
|
|
@@ -306,8 +327,11 @@ function loadCorrectionsPreventionRules(): string[] {
|
|
|
306
327
|
const cwd = process.cwd();
|
|
307
328
|
const config = getConfig();
|
|
308
329
|
const claudeDirName = config.conventions?.claudeDirName ?? '.claude';
|
|
309
|
-
// Convert cwd to Claude's directory format: /Users/x/project -> -Users-x-project
|
|
310
|
-
|
|
330
|
+
// Convert cwd to Claude's directory format: /Users/x/project -> -Users-x-project.
|
|
331
|
+
// Match both forward slashes (POSIX) and backslashes (Windows) so the lookup
|
|
332
|
+
// works cross-platform — without this, Windows users silently miss prevention
|
|
333
|
+
// rules because their projectDirName never resolves.
|
|
334
|
+
const projectDirName = cwd.replace(/[/\\]/g, '-').replace(/^-/, '');
|
|
311
335
|
const correctionsPath = join(homeDir, claudeDirName, 'projects', projectDirName, 'memory', 'corrections.md');
|
|
312
336
|
|
|
313
337
|
if (!existsSync(correctionsPath)) return [];
|
|
@@ -341,4 +365,71 @@ function loadCorrectionsPreventionRules(): string[] {
|
|
|
341
365
|
}
|
|
342
366
|
}
|
|
343
367
|
|
|
368
|
+
// ============================================================
|
|
369
|
+
// Plan 3a Phase 6: watcher-aware banner support
|
|
370
|
+
// ============================================================
|
|
371
|
+
|
|
372
|
+
interface WatchStateShape {
|
|
373
|
+
schema_version?: number;
|
|
374
|
+
daemonPid?: number | null;
|
|
375
|
+
lastRefreshAt?: string | null;
|
|
376
|
+
startedAt?: string | null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function readWatchStateRaw(cwd: string): WatchStateShape | null {
|
|
380
|
+
try {
|
|
381
|
+
const path = resolve(cwd, '.massu', 'watch-state.json');
|
|
382
|
+
if (!existsSync(path)) return null;
|
|
383
|
+
const obj = JSON.parse(readFileSync(path, 'utf-8'));
|
|
384
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
385
|
+
return obj as WatchStateShape;
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function watcherIsLiveAndFresh(): boolean {
|
|
392
|
+
// MASSU_DRIFT_QUIET takes precedence (caller already short-circuited).
|
|
393
|
+
// Fresh = last refresh within 24h AND daemonPid is alive.
|
|
394
|
+
const state = readWatchStateRaw(process.cwd());
|
|
395
|
+
if (!state) return false;
|
|
396
|
+
if (typeof state.daemonPid !== 'number' || state.daemonPid <= 0) return false;
|
|
397
|
+
if (!isPidAlive(state.daemonPid)) return false;
|
|
398
|
+
if (typeof state.lastRefreshAt !== 'string') return false;
|
|
399
|
+
const last = Date.parse(state.lastRefreshAt);
|
|
400
|
+
if (!Number.isFinite(last)) return false;
|
|
401
|
+
const ageMs = Date.now() - last;
|
|
402
|
+
return ageMs >= 0 && ageMs < 24 * 60 * 60 * 1000;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function buildWatcherBanner(): string {
|
|
406
|
+
// P4-004 ordering: MASSU_DRIFT_QUIET wins everywhere.
|
|
407
|
+
if (process.env.MASSU_DRIFT_QUIET === '1') return '';
|
|
408
|
+
const state = readWatchStateRaw(process.cwd());
|
|
409
|
+
if (!state) return '';
|
|
410
|
+
if (typeof state.daemonPid !== 'number' || state.daemonPid <= 0) return '';
|
|
411
|
+
if (!isPidAlive(state.daemonPid)) return '';
|
|
412
|
+
if (typeof state.lastRefreshAt !== 'string') return '';
|
|
413
|
+
const last = Date.parse(state.lastRefreshAt);
|
|
414
|
+
if (!Number.isFinite(last)) return '';
|
|
415
|
+
const ageMs = Date.now() - last;
|
|
416
|
+
if (ageMs < 0 || ageMs >= 24 * 60 * 60 * 1000) return '';
|
|
417
|
+
|
|
418
|
+
const ageStr = formatAge(ageMs);
|
|
419
|
+
return (
|
|
420
|
+
'=== Massu Watcher ===\n' +
|
|
421
|
+
`[massu] watcher running, last refresh: ${ageStr} ago (pid ${state.daemonPid})\n` +
|
|
422
|
+
'=== END ===\n'
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function formatAge(ms: number): string {
|
|
427
|
+
const sec = Math.round(ms / 1000);
|
|
428
|
+
if (sec < 60) return `${sec}s`;
|
|
429
|
+
const min = Math.round(sec / 60);
|
|
430
|
+
if (min < 60) return `${min}m`;
|
|
431
|
+
const hr = Math.round(min / 60);
|
|
432
|
+
return `${hr}h`;
|
|
433
|
+
}
|
|
434
|
+
|
|
344
435
|
main();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the git repository root for a given working directory, falling
|
|
6
|
+
* back to the cwd itself when not inside a git repo.
|
|
7
|
+
*
|
|
8
|
+
* Used by `massu watch` and `massu refresh-log` so the watcher root and the
|
|
9
|
+
* refresh-log path always anchor on the same toplevel rather than wherever
|
|
10
|
+
* the user happened to invoke the CLI from.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'child_process';
|
|
14
|
+
|
|
15
|
+
export function gitToplevel(cwd: string): string {
|
|
16
|
+
const res = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
17
|
+
cwd,
|
|
18
|
+
encoding: 'utf-8',
|
|
19
|
+
});
|
|
20
|
+
if (res.status === 0 && res.stdout) return res.stdout.trim();
|
|
21
|
+
return cwd;
|
|
22
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Synchronous file lock around installAll() for cross-process safety.
|
|
6
|
+
*
|
|
7
|
+
* Plan 3a Phase 6: installAll() may now be invoked from BOTH the manual
|
|
8
|
+
* `runConfigRefresh` path AND the watcher auto-trigger. Without
|
|
9
|
+
* serialization, two concurrent callers can race on `.claude/commands/`
|
|
10
|
+
* file writes. proper-lockfile gives us atomic mkdir-based locks that
|
|
11
|
+
* work cross-platform; we wrap it to:
|
|
12
|
+
*
|
|
13
|
+
* 1. mkdirSync the lock dir (fresh repos may not have `.massu/`)
|
|
14
|
+
* 2. surface ELOCKED (POSIX) and EBUSY (Windows) as the same error
|
|
15
|
+
* 3. keep installAll() synchronous (lockSync, not lock)
|
|
16
|
+
*
|
|
17
|
+
* Plan §190 retry behavior: "second caller blocks up to 30s, then bails".
|
|
18
|
+
* proper-lockfile's `lockSync` REJECTS `retries>0` (see node_modules/
|
|
19
|
+
* proper-lockfile/lib/adapter.js: `Cannot use retries with the sync api`).
|
|
20
|
+
* We implement the retry-block manually via a `lockfile.checkSync` /
|
|
21
|
+
* `lockSync` loop with a busy-wait sleep.
|
|
22
|
+
*
|
|
23
|
+
* iter-3 (third pass, G3-iter3-1+2): align error message with plan §243
|
|
24
|
+
* format `"installAll already running (PID=X) — try again in <N>s"` AND
|
|
25
|
+
* add the manual retry-block loop.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
29
|
+
import { dirname, resolve } from 'path';
|
|
30
|
+
import * as lockfile from 'proper-lockfile';
|
|
31
|
+
|
|
32
|
+
export interface InstallLockOpts {
|
|
33
|
+
/** Default 30s — proper-lockfile considers a lock stale after this elapses. */
|
|
34
|
+
staleMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* How long the manual retry loop should block waiting for the holder to
|
|
37
|
+
* release before bailing with `InstallLockBusyError`. Default 30s per
|
|
38
|
+
* plan §190 ("second caller blocks up to 30s, then bails").
|
|
39
|
+
* Pass `0` to bail immediately (used in tests).
|
|
40
|
+
*/
|
|
41
|
+
blockMs?: number;
|
|
42
|
+
/** Sleep granularity inside the retry loop. Default 100ms. */
|
|
43
|
+
pollIntervalMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Backwards-compat: legacy callers pass `retries: 0` to mean "do not
|
|
46
|
+
* block". When set to a positive integer, used by tests that want to
|
|
47
|
+
* exercise a specific retry count instead of the default time-based loop.
|
|
48
|
+
*/
|
|
49
|
+
retries?: number;
|
|
50
|
+
/** Override clock (test seam). */
|
|
51
|
+
now?: () => number;
|
|
52
|
+
/** Override sleep (test seam). Defaults to a busy-wait spinloop. */
|
|
53
|
+
sleep?: (ms: number) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class InstallLockBusyError extends Error {
|
|
57
|
+
constructor(
|
|
58
|
+
public lockPath: string,
|
|
59
|
+
public holderPid: number | null,
|
|
60
|
+
public retryAfterSeconds: number,
|
|
61
|
+
public causeCode?: string,
|
|
62
|
+
) {
|
|
63
|
+
const pidPart = holderPid != null ? `(PID=${holderPid})` : '(PID=unknown)';
|
|
64
|
+
super(`installAll already running ${pidPart} — try again in ${retryAfterSeconds}s`);
|
|
65
|
+
this.name = 'InstallLockBusyError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Best-effort: read the PID of the current lock holder. proper-lockfile
|
|
71
|
+
* stores the lock as a directory at `<lockPath>` containing nothing PID-
|
|
72
|
+
* identifying, so we look at our own sidecar `<lockPath>.pid` file (written
|
|
73
|
+
* by the lock acquirer below). On any read error we return null so the
|
|
74
|
+
* error message degrades gracefully to `(PID=unknown)`.
|
|
75
|
+
*/
|
|
76
|
+
function readHolderPid(lockPath: string): number | null {
|
|
77
|
+
try {
|
|
78
|
+
const raw = readFileSync(`${lockPath}.pid`, 'utf-8').trim();
|
|
79
|
+
const pid = Number.parseInt(raw, 10);
|
|
80
|
+
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
81
|
+
return pid;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function busyWaitSync(ms: number): void {
|
|
88
|
+
const end = Date.now() + ms;
|
|
89
|
+
// Atomics.wait against a SharedArrayBuffer is the cleanest portable sync
|
|
90
|
+
// sleep; fall back to a tight loop if SharedArrayBuffer is unavailable
|
|
91
|
+
// (older runtimes / sandboxed envs).
|
|
92
|
+
if (typeof SharedArrayBuffer !== 'undefined' && typeof Atomics !== 'undefined') {
|
|
93
|
+
const sab = new SharedArrayBuffer(4);
|
|
94
|
+
const view = new Int32Array(sab);
|
|
95
|
+
Atomics.wait(view, 0, 0, ms);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
while (Date.now() < end) {
|
|
99
|
+
// Spin — this should never run on modern Node, kept as belt-and-suspenders.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Acquire the lock, run `fn`, release on every exit path.
|
|
105
|
+
* Synchronous all the way through so installAll() keeps its sync signature.
|
|
106
|
+
*/
|
|
107
|
+
export function withInstallLock<T>(projectRoot: string, fn: () => T, opts: InstallLockOpts = {}): T {
|
|
108
|
+
const lockPath = resolve(projectRoot, '.massu', 'installAll.lock');
|
|
109
|
+
// iter-3 G3-A11: ensure parent dir exists (fresh repo case).
|
|
110
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
111
|
+
|
|
112
|
+
const staleMs = opts.staleMs ?? 30_000;
|
|
113
|
+
// `retries: 0` legacy path = bail immediately, no wait.
|
|
114
|
+
// Otherwise default to plan §190's 30s block.
|
|
115
|
+
const blockMs = opts.retries === 0
|
|
116
|
+
? 0
|
|
117
|
+
: (opts.blockMs ?? 30_000);
|
|
118
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 100;
|
|
119
|
+
const now = opts.now ?? Date.now;
|
|
120
|
+
const sleep = opts.sleep ?? busyWaitSync;
|
|
121
|
+
|
|
122
|
+
let release: (() => void) | null = null;
|
|
123
|
+
const deadline = now() + blockMs;
|
|
124
|
+
let lastErr: NodeJS.ErrnoException | null = null;
|
|
125
|
+
|
|
126
|
+
// Manual retry loop. proper-lockfile.lockSync forbids retries>0, so we
|
|
127
|
+
// wrap it ourselves: try → on ELOCKED/EBUSY, sleep → try again until
|
|
128
|
+
// deadline. This satisfies plan §190 "second caller blocks up to 30s".
|
|
129
|
+
for (;;) {
|
|
130
|
+
try {
|
|
131
|
+
release = lockfile.lockSync(lockPath, {
|
|
132
|
+
stale: staleMs,
|
|
133
|
+
retries: 0,
|
|
134
|
+
realpath: false,
|
|
135
|
+
});
|
|
136
|
+
// Persist our PID alongside the lock so the next contender can include
|
|
137
|
+
// it in the user-friendly error per plan §243 format.
|
|
138
|
+
try {
|
|
139
|
+
writeFileSync(`${lockPath}.pid`, String(process.pid), 'utf-8');
|
|
140
|
+
} catch {
|
|
141
|
+
// best-effort
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
lastErr = err as NodeJS.ErrnoException;
|
|
146
|
+
const code = lastErr.code;
|
|
147
|
+
if (code !== 'ELOCKED' && code !== 'EBUSY') {
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
if (now() >= deadline) {
|
|
151
|
+
const holderPid = readHolderPid(lockPath);
|
|
152
|
+
const remainingMs = Math.max(0, deadline - now());
|
|
153
|
+
// Surface a hint about how long the *next* poll cycle should wait.
|
|
154
|
+
// When `blockMs=0` the user got bail-immediately semantics; report
|
|
155
|
+
// the staleness window so they know the lock auto-releases in N s.
|
|
156
|
+
const retryAfterSeconds = blockMs === 0
|
|
157
|
+
? Math.round(staleMs / 1000)
|
|
158
|
+
: Math.round(remainingMs / 1000);
|
|
159
|
+
throw new InstallLockBusyError(lockPath, holderPid, retryAfterSeconds, code);
|
|
160
|
+
}
|
|
161
|
+
sleep(pollIntervalMs);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
return fn();
|
|
167
|
+
} finally {
|
|
168
|
+
try {
|
|
169
|
+
if (release) release();
|
|
170
|
+
} catch {
|
|
171
|
+
// best-effort
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
rmSync(`${lockPath}.pid`, { force: true });
|
|
175
|
+
} catch {
|
|
176
|
+
// best-effort
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cross-platform PID liveness probe.
|
|
6
|
+
*
|
|
7
|
+
* POSIX (macOS / Linux):
|
|
8
|
+
* process.kill(pid, 0) → no-throw == alive; ESRCH → dead; EPERM → alive
|
|
9
|
+
* (the process exists but we lack permission to signal it).
|
|
10
|
+
*
|
|
11
|
+
* Windows:
|
|
12
|
+
* `tasklist /FI "PID eq <pid>" /NH` and grep for the PID. Best-effort.
|
|
13
|
+
*
|
|
14
|
+
* Returns boolean; never throws. Used by hooks/session-start.ts
|
|
15
|
+
* (banner suppression) and watch/daemon.ts (registry sweep).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from 'child_process';
|
|
19
|
+
|
|
20
|
+
interface ProbeOpts {
|
|
21
|
+
/** When set, override `process.platform` (test seam). */
|
|
22
|
+
platformOverride?: NodeJS.Platform;
|
|
23
|
+
/** When set, override `process.kill` (test seam). */
|
|
24
|
+
killOverride?: (pid: number, signal: number) => boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isPidAlive(pid: number, opts: ProbeOpts = {}): boolean {
|
|
28
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
29
|
+
|
|
30
|
+
const platform = opts.platformOverride ?? process.platform;
|
|
31
|
+
|
|
32
|
+
if (platform === 'win32') {
|
|
33
|
+
return checkWindows(pid);
|
|
34
|
+
}
|
|
35
|
+
return checkPosix(pid, opts.killOverride);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkPosix(pid: number, killOverride?: (pid: number, signal: number) => boolean): boolean {
|
|
39
|
+
try {
|
|
40
|
+
if (killOverride) {
|
|
41
|
+
killOverride(pid, 0);
|
|
42
|
+
} else {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
48
|
+
if (code === 'EPERM') return true;
|
|
49
|
+
// ESRCH or anything else → treat as dead.
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function checkWindows(pid: number): boolean {
|
|
55
|
+
try {
|
|
56
|
+
const res = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], {
|
|
57
|
+
encoding: 'utf-8',
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
});
|
|
60
|
+
if (res.error || res.status !== 0) return false;
|
|
61
|
+
const stdout = res.stdout || '';
|
|
62
|
+
// Each match is a line that starts with the image name and includes the PID.
|
|
63
|
+
return new RegExp(`\\b${pid}\\b`).test(stdout);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plan 3b — Phase 4: LSP server discovery.
|
|
6
|
+
*
|
|
7
|
+
* Per audit-iter-1 fix G4: this module's discovery is **explicit-only by
|
|
8
|
+
* default**. The optional `lsof` port-scan path is GATED behind
|
|
9
|
+
* `lsp.autoDetect.viaPortScan: true` and defaults to `false` (port-scanning
|
|
10
|
+
* local processes is a security-sensitive default — opt-in only at v1).
|
|
11
|
+
*
|
|
12
|
+
* Empty-servers edge case (audit-iter-3 fix Z): when `lsp.enabled: true` AND
|
|
13
|
+
* `lsp.servers` is empty AND `viaPortScan: false`, we log ONE informational
|
|
14
|
+
* stderr line and return an empty list — the caller (`enrich.ts`) then
|
|
15
|
+
* proceeds AST-only without throwing.
|
|
16
|
+
*
|
|
17
|
+
* VR-LSP-AUTODETECT-OFF-BY-DEFAULT: `viaPortScan` MUST be checked BEFORE any
|
|
18
|
+
* `lsof` invocation. The grep `grep -nE 'viaPortScan' auto-detect.ts` MUST
|
|
19
|
+
* show that boolean check ahead of any `lsof` call.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { LSPConfig } from '../config.ts';
|
|
23
|
+
import type { LSPServerSpec } from './client.ts';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Find LSP servers to launch / connect to. Pure config-driven by default.
|
|
27
|
+
*
|
|
28
|
+
* @param config - The `lsp` block from `massu.config.yaml`. May be undefined
|
|
29
|
+
* when LSP is not configured at all (returns empty list silently).
|
|
30
|
+
* @returns A list of `LSPServerSpec` ready to feed `LSPClient.fromCommand()`.
|
|
31
|
+
* Empty list is a valid, non-error outcome — callers MUST proceed AST-only.
|
|
32
|
+
*/
|
|
33
|
+
export async function findRunningLSPs(
|
|
34
|
+
config: LSPConfig | undefined
|
|
35
|
+
): Promise<LSPServerSpec[]> {
|
|
36
|
+
// Disabled or absent: silently no-op (no log).
|
|
37
|
+
if (!config || !config.enabled) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const explicit = (config.servers ?? []).map((s) => splitCommand(s));
|
|
42
|
+
|
|
43
|
+
// VR-LSP-AUTODETECT-OFF-BY-DEFAULT: this boolean check happens BEFORE any
|
|
44
|
+
// `lsof`/port-scan invocation. Default is false — explicit-only path.
|
|
45
|
+
const viaPortScan = config.autoDetect?.viaPortScan === true;
|
|
46
|
+
|
|
47
|
+
if (explicit.length === 0 && !viaPortScan) {
|
|
48
|
+
// Empty-servers edge case: enabled but nothing configured AND auto-detect
|
|
49
|
+
// is off. Log once, proceed AST-only.
|
|
50
|
+
process.stderr.write(
|
|
51
|
+
'[massu/lsp] INFO: LSP enabled but no servers configured and auto-detect off — skipping LSP enrichment.\n'
|
|
52
|
+
);
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Port-scan path is opt-in via `viaPortScan: true`. Implementation
|
|
57
|
+
// intentionally minimal at v1 — emits an INFO stderr line and returns
|
|
58
|
+
// explicit servers only. Plan 3d will flesh out actual `lsof` discovery
|
|
59
|
+
// once the threat model is reviewed.
|
|
60
|
+
if (viaPortScan) {
|
|
61
|
+
process.stderr.write(
|
|
62
|
+
'[massu/lsp] INFO: lsp.autoDetect.viaPortScan is enabled but port-scan auto-detect is reserved for Plan 3d — using explicit servers only.\n'
|
|
63
|
+
);
|
|
64
|
+
// (No `lsof` invocation yet. The viaPortScan gate exists so future
|
|
65
|
+
// implementations slot in here without changing the default surface.)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return explicit;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse a config-string `command` into an `LSPServerSpec`. Splits on
|
|
73
|
+
* whitespace (no shell evaluation, no globbing). Strict path validation
|
|
74
|
+
* happens later in `LSPClient.fromCommand()` — this just parses the shape.
|
|
75
|
+
*
|
|
76
|
+
* Note: callers passing untrusted commands MUST review the result before
|
|
77
|
+
* passing it to `LSPClient.fromCommand`. The factory rejects relative paths
|
|
78
|
+
* and `..`-containing argv elements.
|
|
79
|
+
*/
|
|
80
|
+
function splitCommand(server: { language: string; command: string }): LSPServerSpec {
|
|
81
|
+
const cmd = (server.command ?? '').trim();
|
|
82
|
+
// Whitespace split — not a full shell parser. Quoted args with spaces are
|
|
83
|
+
// not supported at v1; users with such commands should run a wrapper script.
|
|
84
|
+
const argv = cmd.length === 0 ? [] : cmd.split(/\s+/);
|
|
85
|
+
return {
|
|
86
|
+
language: server.language,
|
|
87
|
+
argv,
|
|
88
|
+
};
|
|
89
|
+
}
|