@mnemonik/scanner 5.151.0 → 5.151.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +99 -0
- package/dist/client.js +140 -0
- package/dist/client.js.map +1 -0
- package/dist/daemon.d.ts +51 -0
- package/dist/daemon.js +550 -0
- package/dist/daemon.js.map +1 -0
- package/dist/discovery.d.ts +21 -0
- package/dist/discovery.js +107 -0
- package/dist/discovery.js.map +1 -0
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +233 -0
- package/dist/doctor.js.map +1 -0
- package/dist/fileLog.d.ts +17 -0
- package/dist/fileLog.js +70 -0
- package/dist/fileLog.js.map +1 -0
- package/dist/git.d.ts +31 -0
- package/dist/git.js +111 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +399 -0
- package/dist/index.js.map +1 -0
- package/dist/pid.d.ts +1 -0
- package/dist/pid.js +37 -0
- package/dist/pid.js.map +1 -0
- package/dist/watcher.d.ts +30 -0
- package/dist/watcher.js +214 -0
- package/dist/watcher.js.map +1 -0
- package/package.json +7 -3
- package/src/client.ts +0 -216
- package/src/daemon.ts +0 -679
- package/src/discovery.ts +0 -124
- package/src/doctor.ts +0 -239
- package/src/fileLog.ts +0 -67
- package/src/git.ts +0 -122
- package/src/index.ts +0 -446
- package/src/pid.ts +0 -37
- package/src/watcher.ts +0 -219
- package/tests/validateServerUrl.test.ts +0 -41
- package/tsconfig.json +0 -18
- package/vitest.config.ts +0 -13
package/src/discovery.ts
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { readFile, readdir, stat } from 'fs/promises';
|
|
2
|
-
import { join, resolve } from 'path';
|
|
3
|
-
|
|
4
|
-
/** Directories to skip during discovery walk */
|
|
5
|
-
const SKIP_DIRS = new Set([
|
|
6
|
-
'node_modules',
|
|
7
|
-
'.git',
|
|
8
|
-
'dist',
|
|
9
|
-
'build',
|
|
10
|
-
'.next',
|
|
11
|
-
'.nuxt',
|
|
12
|
-
'.output',
|
|
13
|
-
'__pycache__',
|
|
14
|
-
'.venv',
|
|
15
|
-
'venv',
|
|
16
|
-
'.tox',
|
|
17
|
-
'target',
|
|
18
|
-
'.cache',
|
|
19
|
-
'coverage',
|
|
20
|
-
'.turbo',
|
|
21
|
-
'.vercel',
|
|
22
|
-
'.svelte-kit',
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
|
-
export interface DiscoveredProject {
|
|
26
|
-
projectId: string;
|
|
27
|
-
path: string;
|
|
28
|
-
projectName?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export class ProjectDiscovery {
|
|
32
|
-
private maxDepth: number;
|
|
33
|
-
private timeoutMs: number;
|
|
34
|
-
|
|
35
|
-
constructor(
|
|
36
|
-
private roots: string[],
|
|
37
|
-
options?: { maxDepth?: number; timeoutMs?: number }
|
|
38
|
-
) {
|
|
39
|
-
this.maxDepth = options?.maxDepth ?? 3;
|
|
40
|
-
this.timeoutMs = options?.timeoutMs ?? 30_000;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Discover all projects with .mnemonik.json files under the configured roots.
|
|
45
|
-
* Deduplicates by projectId (same project found at multiple paths = first wins).
|
|
46
|
-
*/
|
|
47
|
-
async discover(): Promise<DiscoveredProject[]> {
|
|
48
|
-
const seen = new Map<string, DiscoveredProject>();
|
|
49
|
-
|
|
50
|
-
for (const root of this.roots) {
|
|
51
|
-
const absRoot = resolve(root.replace(/^~/, process.env.HOME || ''));
|
|
52
|
-
try {
|
|
53
|
-
await this.walkWithTimeout(absRoot, 0, seen);
|
|
54
|
-
} catch (err) {
|
|
55
|
-
if (err instanceof DiscoveryTimeoutError) {
|
|
56
|
-
console.warn(`[scanner] Discovery timeout for root: ${absRoot} (>${this.timeoutMs}ms)`);
|
|
57
|
-
} else {
|
|
58
|
-
console.warn(`[scanner] Error scanning root ${absRoot}:`, (err as Error).message);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return Array.from(seen.values());
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
private async walkWithTimeout(
|
|
67
|
-
dir: string,
|
|
68
|
-
depth: number,
|
|
69
|
-
seen: Map<string, DiscoveredProject>
|
|
70
|
-
): Promise<void> {
|
|
71
|
-
const deadline = Date.now() + this.timeoutMs;
|
|
72
|
-
await this.walk(dir, depth, seen, deadline);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private async walk(
|
|
76
|
-
dir: string,
|
|
77
|
-
depth: number,
|
|
78
|
-
seen: Map<string, DiscoveredProject>,
|
|
79
|
-
deadline: number
|
|
80
|
-
): Promise<void> {
|
|
81
|
-
if (depth > this.maxDepth) return;
|
|
82
|
-
if (Date.now() > deadline) throw new DiscoveryTimeoutError();
|
|
83
|
-
|
|
84
|
-
// Check for .mnemonik.json in this directory
|
|
85
|
-
const configPath = join(dir, '.mnemonik.json');
|
|
86
|
-
try {
|
|
87
|
-
const raw = await readFile(configPath, 'utf-8');
|
|
88
|
-
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
89
|
-
if (typeof parsed.projectId === 'string' && parsed.projectId.length > 0) {
|
|
90
|
-
if (!seen.has(parsed.projectId)) {
|
|
91
|
-
seen.set(parsed.projectId, {
|
|
92
|
-
projectId: parsed.projectId,
|
|
93
|
-
path: dir,
|
|
94
|
-
projectName: typeof parsed.projectName === 'string' ? parsed.projectName : undefined,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
// Don't recurse into subdirectories of a project — the project owns this tree
|
|
99
|
-
return;
|
|
100
|
-
} catch {
|
|
101
|
-
// No .mnemonik.json here, continue walking
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Recurse into subdirectories
|
|
105
|
-
try {
|
|
106
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
107
|
-
for (const entry of entries) {
|
|
108
|
-
if (!entry.isDirectory()) continue;
|
|
109
|
-
if (SKIP_DIRS.has(entry.name)) continue;
|
|
110
|
-
if (entry.name.startsWith('.') && entry.name !== '.mnemonik') continue;
|
|
111
|
-
await this.walk(join(dir, entry.name), depth + 1, seen, deadline);
|
|
112
|
-
}
|
|
113
|
-
} catch {
|
|
114
|
-
// Permission denied or inaccessible directory
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
class DiscoveryTimeoutError extends Error {
|
|
120
|
-
constructor() {
|
|
121
|
-
super('Discovery walk timed out');
|
|
122
|
-
this.name = 'DiscoveryTimeoutError';
|
|
123
|
-
}
|
|
124
|
-
}
|
package/src/doctor.ts
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'child_process';
|
|
2
|
-
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import { homedir } from 'os';
|
|
5
|
-
import { fileURLToPath } from 'url';
|
|
6
|
-
import { pidIsScanner } from './pid.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* `mnemonik-scanner doctor` — the guard that turns the prose upgrade procedure
|
|
10
|
-
* into an executable check. It asserts the invariants that every past botched
|
|
11
|
-
* reinstall violated, and prints the one blessed remediation. Any single ✗
|
|
12
|
-
* exits non-zero so a wrapper (or a human) can react.
|
|
13
|
-
*
|
|
14
|
-
* Invariants:
|
|
15
|
-
* 1. Exactly one `mnemonik-scanner` on PATH, inside the user npm prefix
|
|
16
|
-
* (a second copy under /usr is the classic PATH-shadow version skew).
|
|
17
|
-
* 2. If a systemd user unit exists, it is active and its ExecStart points at
|
|
18
|
-
* that same binary.
|
|
19
|
-
* 3. Exactly one live daemon, and under systemd its PID == the unit MainPID
|
|
20
|
-
* (a stray `node …/scanner/dist/index.js` is a duplicate local-build daemon).
|
|
21
|
-
* 4. No leftover legacy per-project daemons.
|
|
22
|
-
* 5. Installed version is not behind npm latest (warning, not failure).
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
const HOME = homedir();
|
|
26
|
-
const MNEMONIK_DIR = join(HOME, '.mnemonik');
|
|
27
|
-
const PID_FILE = join(MNEMONIK_DIR, 'daemon.pid');
|
|
28
|
-
const LEGACY_DAEMONS_DIR = join(MNEMONIK_DIR, 'daemons');
|
|
29
|
-
const UNIT_FILE = join(HOME, '.config/systemd/user/mnemonik-scanner.service');
|
|
30
|
-
|
|
31
|
-
type Level = 'ok' | 'warn' | 'fail';
|
|
32
|
-
interface Check {
|
|
33
|
-
level: Level;
|
|
34
|
-
label: string;
|
|
35
|
-
detail?: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function sh(cmd: string, args: string[], timeoutMs = 5000): string | null {
|
|
39
|
-
try {
|
|
40
|
-
return execFileSync(cmd, args, {
|
|
41
|
-
encoding: 'utf-8',
|
|
42
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
43
|
-
timeout: timeoutMs,
|
|
44
|
-
}).trim();
|
|
45
|
-
} catch {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function pidAlive(pid: number): boolean {
|
|
51
|
-
try {
|
|
52
|
-
process.kill(pid, 0);
|
|
53
|
-
} catch {
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
// Existence alone is not identity — after PID reuse the recorded number can
|
|
57
|
-
// belong to an unrelated process. A live-but-foreign PID is dead to us.
|
|
58
|
-
return pidIsScanner(pid);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function installedVersion(): string | null {
|
|
62
|
-
try {
|
|
63
|
-
const here = fileURLToPath(new URL('.', import.meta.url));
|
|
64
|
-
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf-8')) as {
|
|
65
|
-
version?: string;
|
|
66
|
-
};
|
|
67
|
-
return pkg.version ?? null;
|
|
68
|
-
} catch {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export async function runDoctor(): Promise<void> {
|
|
74
|
-
const checks: Check[] = [];
|
|
75
|
-
|
|
76
|
-
// 1. Binary uniqueness + location.
|
|
77
|
-
const prefix = sh('npm', ['config', 'get', 'prefix']);
|
|
78
|
-
const whichOut = sh('which', ['-a', 'mnemonik-scanner']);
|
|
79
|
-
const binPaths = whichOut
|
|
80
|
-
? whichOut
|
|
81
|
-
.split('\n')
|
|
82
|
-
.map((s) => s.trim())
|
|
83
|
-
.filter(Boolean)
|
|
84
|
-
: [];
|
|
85
|
-
if (binPaths.length === 0) {
|
|
86
|
-
checks.push({
|
|
87
|
-
level: 'fail',
|
|
88
|
-
label: 'binary on PATH',
|
|
89
|
-
detail: 'mnemonik-scanner not found on PATH',
|
|
90
|
-
});
|
|
91
|
-
} else if (binPaths.length > 1) {
|
|
92
|
-
checks.push({
|
|
93
|
-
level: 'fail',
|
|
94
|
-
label: 'single binary on PATH',
|
|
95
|
-
detail: `found ${binPaths.length} copies (PATH shadow → version skew):\n ${binPaths.join('\n ')}`,
|
|
96
|
-
});
|
|
97
|
-
} else if (prefix && !binPaths[0]!.startsWith(prefix)) {
|
|
98
|
-
checks.push({
|
|
99
|
-
level: 'warn',
|
|
100
|
-
label: 'binary in user npm prefix',
|
|
101
|
-
detail: `${binPaths[0]} is not under npm prefix ${prefix}`,
|
|
102
|
-
});
|
|
103
|
-
} else {
|
|
104
|
-
checks.push({ level: 'ok', label: 'single binary on PATH', detail: binPaths[0] });
|
|
105
|
-
}
|
|
106
|
-
const binPath = binPaths.length === 1 ? binPaths[0]! : null;
|
|
107
|
-
|
|
108
|
-
// 2. systemd unit health + ExecStart match.
|
|
109
|
-
let unitMainPid: number | null = null;
|
|
110
|
-
const hasUnit = existsSync(UNIT_FILE);
|
|
111
|
-
if (hasUnit) {
|
|
112
|
-
const show = sh('systemctl', [
|
|
113
|
-
'--user',
|
|
114
|
-
'show',
|
|
115
|
-
'mnemonik-scanner',
|
|
116
|
-
'-p',
|
|
117
|
-
'ActiveState',
|
|
118
|
-
'-p',
|
|
119
|
-
'MainPID',
|
|
120
|
-
'-p',
|
|
121
|
-
'ExecStart',
|
|
122
|
-
]);
|
|
123
|
-
const active = /ActiveState=active/.test(show ?? '');
|
|
124
|
-
const mainPidMatch = /MainPID=(\d+)/.exec(show ?? '');
|
|
125
|
-
unitMainPid = mainPidMatch ? parseInt(mainPidMatch[1]!, 10) : null;
|
|
126
|
-
const execMatch = /path=([^\s;]+)/.exec(show ?? '');
|
|
127
|
-
const execPath = execMatch ? execMatch[1]! : null;
|
|
128
|
-
|
|
129
|
-
if (!active) {
|
|
130
|
-
checks.push({ level: 'fail', label: 'systemd unit active', detail: 'unit is not active' });
|
|
131
|
-
} else if (binPath && execPath && execPath !== binPath) {
|
|
132
|
-
checks.push({
|
|
133
|
-
level: 'fail',
|
|
134
|
-
label: 'ExecStart matches binary',
|
|
135
|
-
detail: `unit runs ${execPath} but PATH resolves ${binPath}`,
|
|
136
|
-
});
|
|
137
|
-
} else {
|
|
138
|
-
checks.push({
|
|
139
|
-
level: 'ok',
|
|
140
|
-
label: 'systemd unit active',
|
|
141
|
-
detail: `MainPID ${unitMainPid ?? '?'}`,
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
} else {
|
|
145
|
-
checks.push({
|
|
146
|
-
level: 'warn',
|
|
147
|
-
label: 'systemd unit',
|
|
148
|
-
detail: 'no user unit — daemon must be supervised another way',
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// 3. Single live daemon; under systemd it must equal MainPID.
|
|
153
|
-
let filePid: number | null = null;
|
|
154
|
-
try {
|
|
155
|
-
filePid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
|
156
|
-
if (Number.isNaN(filePid)) filePid = null;
|
|
157
|
-
} catch {
|
|
158
|
-
filePid = null;
|
|
159
|
-
}
|
|
160
|
-
const psOut = sh('ps', ['-eo', 'pid,cmd']);
|
|
161
|
-
const daemonPids = (psOut ?? '')
|
|
162
|
-
.split('\n')
|
|
163
|
-
// Only the long-running `start` daemon counts — never a `doctor`/`status`/
|
|
164
|
-
// `log`/`stop` invocation (which also runs …/scanner/dist/index.js), and
|
|
165
|
-
// never this doctor process itself.
|
|
166
|
-
.filter((l) => /(mnemonik-scanner|scanner\/dist\/index\.js)/.test(l) && /\bstart\b/.test(l))
|
|
167
|
-
.map((l) => parseInt(l.trim().split(/\s+/)[0]!, 10))
|
|
168
|
-
.filter((n) => !Number.isNaN(n) && n !== process.pid);
|
|
169
|
-
if (daemonPids.length === 0) {
|
|
170
|
-
checks.push({ level: 'fail', label: 'daemon running', detail: 'no daemon process found' });
|
|
171
|
-
} else if (daemonPids.length > 1) {
|
|
172
|
-
checks.push({
|
|
173
|
-
level: 'fail',
|
|
174
|
-
label: 'single daemon',
|
|
175
|
-
detail: `${daemonPids.length} daemon processes (duplicate) — PIDs ${daemonPids.join(', ')}`,
|
|
176
|
-
});
|
|
177
|
-
} else if (hasUnit && unitMainPid && daemonPids[0] !== unitMainPid) {
|
|
178
|
-
checks.push({
|
|
179
|
-
level: 'fail',
|
|
180
|
-
label: 'daemon is the systemd one',
|
|
181
|
-
detail: `running PID ${daemonPids[0]} != unit MainPID ${unitMainPid} (stray local-build daemon)`,
|
|
182
|
-
});
|
|
183
|
-
} else if (filePid && !pidAlive(filePid)) {
|
|
184
|
-
checks.push({
|
|
185
|
-
level: 'warn',
|
|
186
|
-
label: 'PID file fresh',
|
|
187
|
-
detail: `stale ${PID_FILE} (PID ${filePid} dead)`,
|
|
188
|
-
});
|
|
189
|
-
} else {
|
|
190
|
-
checks.push({ level: 'ok', label: 'single daemon', detail: `PID ${daemonPids[0]}` });
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// 4. Legacy per-project daemons.
|
|
194
|
-
try {
|
|
195
|
-
const legacy = readdirSync(LEGACY_DAEMONS_DIR)
|
|
196
|
-
.filter((f) => f.endsWith('.pid'))
|
|
197
|
-
.map((f) => parseInt(readFileSync(join(LEGACY_DAEMONS_DIR, f), 'utf-8').trim(), 10))
|
|
198
|
-
.filter((n) => !Number.isNaN(n) && pidAlive(n));
|
|
199
|
-
if (legacy.length > 0) {
|
|
200
|
-
checks.push({
|
|
201
|
-
level: 'fail',
|
|
202
|
-
label: 'no legacy daemons',
|
|
203
|
-
detail: `legacy per-project daemons alive: ${legacy.join(', ')}`,
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
} catch {
|
|
207
|
-
// dir absent — nothing to check
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// 5. Version vs npm latest (informational).
|
|
211
|
-
const local = installedVersion();
|
|
212
|
-
const latest = sh('npm', ['view', '@mnemonik/scanner', 'version'], 15000);
|
|
213
|
-
if (local && latest && local !== latest) {
|
|
214
|
-
checks.push({
|
|
215
|
-
level: 'warn',
|
|
216
|
-
label: 'up to date',
|
|
217
|
-
detail: `installed ${local}, npm latest ${latest} — run: make scanner-deploy`,
|
|
218
|
-
});
|
|
219
|
-
} else if (local) {
|
|
220
|
-
checks.push({ level: 'ok', label: 'up to date', detail: `v${local}` });
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Report.
|
|
224
|
-
const icon = { ok: '✓', warn: '⚠', fail: '✗' } as const;
|
|
225
|
-
console.log('mnemonik-scanner doctor\n');
|
|
226
|
-
for (const c of checks) {
|
|
227
|
-
console.log(` ${icon[c.level]} ${c.label}${c.detail ? `: ${c.detail}` : ''}`);
|
|
228
|
-
}
|
|
229
|
-
const failed = checks.filter((c) => c.level === 'fail');
|
|
230
|
-
if (failed.length > 0) {
|
|
231
|
-
console.log(
|
|
232
|
-
`\n${failed.length} problem(s). Canonical fix: \`make scanner-deploy\` (build → publish shared-first → npm i -g → systemctl restart → re-verify).\n` +
|
|
233
|
-
'Do NOT hand-copy dist/ or run `make daemon-*` on a systemd host — those are what cause this.'
|
|
234
|
-
);
|
|
235
|
-
process.exit(1);
|
|
236
|
-
}
|
|
237
|
-
console.log('\nAll invariants hold.');
|
|
238
|
-
process.exit(0);
|
|
239
|
-
}
|
package/src/fileLog.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { appendFileSync, renameSync, statSync } from 'fs';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Append-only sink that mirrors the daemon's console output to a file the
|
|
5
|
-
* `mnemonik-scanner log` command can tail. Rotates to `<file>.old` once the
|
|
6
|
-
* file would pass `maxSize`, so a daemon that runs for weeks can't grow it
|
|
7
|
-
* without bound. Every operation is best-effort: a logging failure must never
|
|
8
|
-
* crash the daemon, so all filesystem errors are swallowed.
|
|
9
|
-
*/
|
|
10
|
-
export function createRotatingFileSink(logFile: string, maxSize: number): (chunk: string) => void {
|
|
11
|
-
let size = 0;
|
|
12
|
-
try {
|
|
13
|
-
size = statSync(logFile).size;
|
|
14
|
-
} catch {
|
|
15
|
-
size = 0;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return (chunk: string): void => {
|
|
19
|
-
if (!chunk) return;
|
|
20
|
-
try {
|
|
21
|
-
const bytes = Buffer.byteLength(chunk);
|
|
22
|
-
if (size > 0 && size + bytes > maxSize) {
|
|
23
|
-
try {
|
|
24
|
-
renameSync(logFile, logFile + '.old');
|
|
25
|
-
} catch {
|
|
26
|
-
// Rotation failed (e.g. cross-device) — keep appending to the
|
|
27
|
-
// current file rather than losing the line.
|
|
28
|
-
}
|
|
29
|
-
size = 0;
|
|
30
|
-
}
|
|
31
|
-
appendFileSync(logFile, chunk);
|
|
32
|
-
size += bytes;
|
|
33
|
-
} catch {
|
|
34
|
-
// Never let logging break the daemon.
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Tee `process.stdout`/`process.stderr` into `logFile` on top of their normal
|
|
41
|
-
* destination. This is what makes `mnemonik-scanner log` reflect live activity
|
|
42
|
-
* regardless of how the daemon is supervised: under systemd stdout is wired to
|
|
43
|
-
* the journal (a socket), under a bare shell to the terminal — either way the
|
|
44
|
-
* file now receives the same lines. Call once at daemon start, before the
|
|
45
|
-
* daemon writes anything worth capturing.
|
|
46
|
-
*/
|
|
47
|
-
export function installFileLogging(logFile: string, maxSize: number): void {
|
|
48
|
-
const sink = createRotatingFileSink(logFile, maxSize);
|
|
49
|
-
|
|
50
|
-
for (const stream of [process.stdout, process.stderr]) {
|
|
51
|
-
const original = stream.write.bind(stream) as (...args: unknown[]) => boolean;
|
|
52
|
-
stream.write = ((chunk: unknown, encoding?: unknown, cb?: unknown): boolean => {
|
|
53
|
-
try {
|
|
54
|
-
if (typeof chunk === 'string') {
|
|
55
|
-
sink(chunk);
|
|
56
|
-
} else if (Buffer.isBuffer(chunk)) {
|
|
57
|
-
const enc = typeof encoding === 'string' ? (encoding as BufferEncoding) : 'utf8';
|
|
58
|
-
sink(chunk.toString(enc));
|
|
59
|
-
}
|
|
60
|
-
} catch {
|
|
61
|
-
// ignore — mirroring is best-effort
|
|
62
|
-
}
|
|
63
|
-
// Preserve the real stream's overloads (chunk, cb) / (chunk, enc, cb).
|
|
64
|
-
return original(chunk, encoding, cb);
|
|
65
|
-
}) as typeof stream.write;
|
|
66
|
-
}
|
|
67
|
-
}
|
package/src/git.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Git commit extraction on the scanner daemon side.
|
|
3
|
-
*
|
|
4
|
-
* The production server runs in ECS and cannot `git log` the user's repo —
|
|
5
|
-
* the repo lives on the user's machine, next to the daemon. We run `git log`
|
|
6
|
-
* here and push the parsed commits alongside scan results. Server-side
|
|
7
|
-
* `GitMiner.mineCommits` consumes them without touching the filesystem.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { execFile } from 'child_process';
|
|
11
|
-
import { promisify } from 'util';
|
|
12
|
-
import { access } from 'fs/promises';
|
|
13
|
-
import { join } from 'path';
|
|
14
|
-
|
|
15
|
-
const execFileAsync = promisify(execFile);
|
|
16
|
-
|
|
17
|
-
export interface ParsedCommit {
|
|
18
|
-
sha: string;
|
|
19
|
-
author: string;
|
|
20
|
-
date: string;
|
|
21
|
-
message: string;
|
|
22
|
-
files: string[];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Keep in sync with server-side MAX_COMMITS_PER_RUN in GitMiner.ts and the
|
|
26
|
-
// `commits` array cap in scanPushSchema.
|
|
27
|
-
const MAX_COMMITS = 100;
|
|
28
|
-
const GIT_TIMEOUT_MS = 20_000;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* True when `repoRoot/.git` exists and `git log -1` returns a commit.
|
|
32
|
-
* Non-git projects, `git init` with no commits, and corrupted repos all
|
|
33
|
-
* return false without throwing.
|
|
34
|
-
*/
|
|
35
|
-
export async function probeGit(repoRoot: string): Promise<boolean> {
|
|
36
|
-
try {
|
|
37
|
-
await access(join(repoRoot, '.git'));
|
|
38
|
-
} catch {
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
try {
|
|
42
|
-
const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%H'], {
|
|
43
|
-
cwd: repoRoot,
|
|
44
|
-
encoding: 'utf-8',
|
|
45
|
-
timeout: GIT_TIMEOUT_MS,
|
|
46
|
-
});
|
|
47
|
-
return /^[0-9a-f]{40}/m.test(stdout);
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Fetch commits between `sinceCommit` and HEAD (or the last MAX_COMMITS on
|
|
55
|
-
* first run). Returns [] on failure — caller omits the field from the push.
|
|
56
|
-
*
|
|
57
|
-
* Uses `-z` NUL-separated output so `--name-only` file entries cannot collide
|
|
58
|
-
* with commit metadata. Parser mirrors the server-side token-walking parser
|
|
59
|
-
* in GitMiner.parseLogOutput.
|
|
60
|
-
*/
|
|
61
|
-
export async function fetchCommits(
|
|
62
|
-
repoRoot: string,
|
|
63
|
-
sinceCommit: string | null
|
|
64
|
-
): Promise<ParsedCommit[]> {
|
|
65
|
-
const args = [
|
|
66
|
-
'log',
|
|
67
|
-
'-z',
|
|
68
|
-
'--format=%H%n%an%n%aI%n%B',
|
|
69
|
-
'--name-only',
|
|
70
|
-
`--max-count=${MAX_COMMITS}`,
|
|
71
|
-
];
|
|
72
|
-
if (sinceCommit) args.push(`${sinceCommit}..HEAD`);
|
|
73
|
-
|
|
74
|
-
let raw: string;
|
|
75
|
-
try {
|
|
76
|
-
const result = await execFileAsync('git', args, {
|
|
77
|
-
cwd: repoRoot,
|
|
78
|
-
encoding: 'utf-8',
|
|
79
|
-
timeout: GIT_TIMEOUT_MS,
|
|
80
|
-
maxBuffer: 50 * 1024 * 1024,
|
|
81
|
-
});
|
|
82
|
-
raw = result.stdout;
|
|
83
|
-
} catch (err) {
|
|
84
|
-
const msg = (err as Error).message;
|
|
85
|
-
// "fatal: Invalid revision range <sha>..HEAD" — the daemon's cached
|
|
86
|
-
// last_mined_commit is stale (e.g. history was rewritten or the server
|
|
87
|
-
// was reseeded). Fall back to a fresh full-history scan.
|
|
88
|
-
if (sinceCommit && /Invalid (?:revision range|symmetric difference expression)/i.test(msg)) {
|
|
89
|
-
return fetchCommits(repoRoot, null);
|
|
90
|
-
}
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return parseLogOutput(raw);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function parseLogOutput(raw: string): ParsedCommit[] {
|
|
98
|
-
const commits: ParsedCommit[] = [];
|
|
99
|
-
const tokens = raw.split('\0').filter((t) => t.length > 0);
|
|
100
|
-
let current: ParsedCommit | null = null;
|
|
101
|
-
|
|
102
|
-
for (const token of tokens) {
|
|
103
|
-
if (token.includes('\n')) {
|
|
104
|
-
if (current) commits.push(current);
|
|
105
|
-
const lines = token.split('\n');
|
|
106
|
-
const sha = lines[0]?.trim() ?? '';
|
|
107
|
-
const author = lines[1]?.trim() ?? '';
|
|
108
|
-
const date = lines[2]?.trim() ?? '';
|
|
109
|
-
const message = lines.slice(3).join('\n').trim();
|
|
110
|
-
if (!sha || !author || !date || !message) {
|
|
111
|
-
current = null;
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
current = { sha, author, date, message, files: [] };
|
|
115
|
-
} else if (current) {
|
|
116
|
-
const file = token.trim();
|
|
117
|
-
if (file.length > 0) current.files.push(file);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
if (current) commits.push(current);
|
|
121
|
-
return commits;
|
|
122
|
-
}
|