Skip to content

Commit ca6d37f

Browse files
fix: make Windows provider execution observable and safe
1 parent 6455cfa commit ca6d37f

9 files changed

Lines changed: 362 additions & 43 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,20 @@ permissions:
1010

1111
jobs:
1212
test:
13-
runs-on: ubuntu-latest
1413
strategy:
14+
fail-fast: false
1515
matrix:
16-
node-version: [22, 24]
16+
include:
17+
- os: ubuntu-latest
18+
node-version: 22
19+
upload-source: false
20+
- os: ubuntu-latest
21+
node-version: 24
22+
upload-source: true
23+
- os: windows-latest
24+
node-version: 24
25+
upload-source: false
26+
runs-on: ${{ matrix.os }}
1727
steps:
1828
- uses: actions/checkout@v4
1929
- uses: actions/setup-node@v4
@@ -30,7 +40,7 @@ jobs:
3040
- name: Installer dry run
3141
run: node install.mjs --dry-run --no-link-cli
3242
- name: Upload exact CI source
33-
if: always() && matrix.node-version == 24
43+
if: always() && matrix.upload-source
3444
uses: actions/upload-artifact@v4
3545
with:
3646
name: docgen-ci-source

global-template/docgen/lib/core.mjs

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
33
import crypto from 'node:crypto';
4-
import { spawnSync } from 'node:child_process';
4+
import { spawn, spawnSync } from 'node:child_process';
55
import { fileURLToPath } from 'node:url';
66

77
export const engineHome = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -66,12 +66,101 @@ export function stableJson(value) {
6666
export function stableHash(value) { return sha256(JSON.stringify(stableJson(value))); }
6767
export function estimateTokens(value) { return Math.ceil(Buffer.byteLength(String(value), 'utf8') / 3.6); }
6868
export function slug(value) { return String(value ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item'; }
69-
export function commandExists(command) {
70-
const executable = process.platform === 'win32' ? 'where' : 'which';
71-
return spawnSync(executable, [command], { stdio: 'ignore', shell: process.platform === 'win32' }).status === 0;
69+
70+
function directCommand(command) {
71+
if (!command) return null;
72+
const value = String(command);
73+
if (path.isAbsolute(value) || value.includes('/') || value.includes('\\')) {
74+
const full = path.resolve(value);
75+
return fs.existsSync(full) ? full : null;
76+
}
77+
return null;
7278
}
79+
80+
export function resolveCommand(command) {
81+
const direct = directCommand(command);
82+
if (direct) return direct;
83+
const locator = process.platform === 'win32'
84+
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'where.exe')
85+
: 'which';
86+
const result = spawnSync(locator, [String(command)], {
87+
encoding: 'utf8',
88+
stdio: ['ignore', 'pipe', 'ignore'],
89+
shell: false,
90+
windowsHide: true
91+
});
92+
if (result.status !== 0) return null;
93+
const candidates = String(result.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
94+
if (!candidates.length) return null;
95+
if (process.platform !== 'win32') return candidates[0];
96+
return candidates.find((item) => /\.(?:exe|com)$/i.test(item))
97+
?? candidates.find((item) => /\.(?:cmd|bat)$/i.test(item))
98+
?? candidates.find((item) => fs.existsSync(item))
99+
?? null;
100+
}
101+
102+
export function commandExists(command) { return Boolean(resolveCommand(command)); }
103+
104+
function powershellHost() {
105+
return resolveCommand('pwsh.exe') ?? resolveCommand('powershell.exe');
106+
}
107+
108+
export function spawnCommand(command, args = [], options = {}) {
109+
const resolved = resolveCommand(command) ?? String(command);
110+
const baseOptions = { ...options, shell: false };
111+
if (process.platform !== 'win32' || !/\.(?:cmd|bat)$/i.test(resolved)) {
112+
return spawn(resolved, args.map(String), baseOptions);
113+
}
114+
const host = powershellHost();
115+
if (!host) throw new Error(`Cannot safely launch Windows command shim: ${resolved}. PowerShell was not found.`);
116+
const encodedArgs = Buffer.from(JSON.stringify(args.map(String)), 'utf8').toString('base64');
117+
const env = {
118+
...(options.env ?? process.env),
119+
DOCGEN_CHILD_EXECUTABLE: resolved,
120+
DOCGEN_CHILD_ARGS_B64: encodedArgs
121+
};
122+
const script = [
123+
"$ErrorActionPreference='Stop'",
124+
'$exe=$env:DOCGEN_CHILD_EXECUTABLE',
125+
'$json=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:DOCGEN_CHILD_ARGS_B64))',
126+
'$childArgs=@(ConvertFrom-Json -InputObject $json)',
127+
'& $exe @childArgs',
128+
'if ($null -eq $LASTEXITCODE) { exit 0 } else { exit $LASTEXITCODE }'
129+
].join('; ');
130+
return spawn(host, ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
131+
...baseOptions,
132+
env
133+
});
134+
}
135+
136+
export function terminateProcessTree(child) {
137+
if (!child?.pid) return;
138+
if (process.platform === 'win32') {
139+
const taskkill = resolveCommand('taskkill.exe') ?? 'taskkill.exe';
140+
spawnSync(taskkill, ['/PID', String(child.pid), '/T', '/F'], {
141+
stdio: 'ignore',
142+
shell: false,
143+
windowsHide: true
144+
});
145+
return;
146+
}
147+
try { child.kill('SIGTERM'); } catch {}
148+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 5_000);
149+
timer.unref?.();
150+
}
151+
152+
export function formatDuration(milliseconds) {
153+
const seconds = Math.max(0, Math.floor(Number(milliseconds) / 1000));
154+
const hours = Math.floor(seconds / 3600);
155+
const minutes = Math.floor((seconds % 3600) / 60);
156+
const remaining = seconds % 60;
157+
return hours ? `${hours}h ${String(minutes).padStart(2, '0')}m ${String(remaining).padStart(2, '0')}s`
158+
: `${minutes}m ${String(remaining).padStart(2, '0')}s`;
159+
}
160+
73161
export function git(root, args, fallback = null) {
74-
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
162+
const executable = resolveCommand('git') ?? 'git';
163+
const result = spawnSync(executable, ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: false });
75164
return result.status === 0 ? result.stdout.trim() : fallback;
76165
}
77166
export function sourceSnapshot(root) {

global-template/docgen/lib/indexer.mjs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,22 @@ function replaceFacts(db, rel, facts) {
8080
export function indexRepository(root, { force = false } = {}) {
8181
const paths = projectPaths(root); const inventory = buildInventory(root); const db = openDatabase(paths.database);
8282
const known = new Map(db.prepare('SELECT path,hash FROM files').all().map((row) => [row.path, row.hash])); const current = new Set(inventory.files.map((x) => x.path));
83+
const progress = process.env.DOCGEN_PROGRESS !== '0'; const started = Date.now(); let lastReport = started;
8384
let changed = 0; let unchanged = 0; let factCount = 0;
85+
if (progress) console.log(`[docgen] index RUNNING | ${inventory.files.length.toLocaleString()} included files | force=${force}`);
8486
db.exec('BEGIN');
8587
try {
86-
for (const file of inventory.files) {
87-
if (!force && known.get(file.path) === file.hash) { unchanged++; continue; }
88-
const text = fs.readFileSync(path.join(root, file.path), 'utf8'); const facts = extractFacts(file.path, text); factCount += facts.length;
89-
replaceFacts(db, file.path, facts);
90-
db.prepare('INSERT INTO files(path,hash,size,extension,indexed_at) VALUES(?,?,?,?,?) ON CONFLICT(path) DO UPDATE SET hash=excluded.hash,size=excluded.size,extension=excluded.extension,indexed_at=excluded.indexed_at').run(file.path, file.hash, file.size, file.extension, now());
91-
changed++;
88+
for (let index = 0; index < inventory.files.length; index++) {
89+
const file = inventory.files[index];
90+
if (!force && known.get(file.path) === file.hash) unchanged++;
91+
else {
92+
const text = fs.readFileSync(path.join(root, file.path), 'utf8'); const facts = extractFacts(file.path, text); factCount += facts.length;
93+
replaceFacts(db, file.path, facts);
94+
db.prepare('INSERT INTO files(path,hash,size,extension,indexed_at) VALUES(?,?,?,?,?) ON CONFLICT(path) DO UPDATE SET hash=excluded.hash,size=excluded.size,extension=excluded.extension,indexed_at=excluded.indexed_at').run(file.path, file.hash, file.size, file.extension, now());
95+
changed++;
96+
}
97+
const nowMs = Date.now();
98+
if (progress && nowMs - lastReport >= 5_000) { lastReport = nowMs; console.log(`[docgen] index RUNNING | ${index + 1}/${inventory.files.length} | changed ${changed} | unchanged ${unchanged} | extracted facts ${factCount.toLocaleString()}`); }
9299
}
93100
for (const rel of known.keys()) if (!current.has(rel)) { replaceFacts(db, rel, []); db.prepare('DELETE FROM files WHERE path=?').run(rel); changed++; }
94101
db.prepare("INSERT INTO metadata(key,value) VALUES('inventory_fingerprint',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(inventory.fingerprint);
@@ -97,6 +104,7 @@ export function indexRepository(root, { force = false } = {}) {
97104
} catch (error) { db.exec('ROLLBACK'); db.close(); throw error; }
98105
const totals = { files: db.prepare('SELECT COUNT(*) n FROM files').get().n, facts: db.prepare('SELECT COUNT(*) n FROM facts').get().n };
99106
db.close();
107+
if (progress) console.log(`[docgen] index COMPLETED | changed ${changed} | unchanged ${unchanged} | facts ${totals.facts.toLocaleString()} | elapsed ${Math.max(0, Math.round((Date.now() - started) / 1000))}s`);
100108
return { schemaVersion: '2.0', indexedAt: now(), inventoryFingerprint: inventory.fingerprint, changedFiles: changed, unchangedFiles: unchanged, extractedFacts: factCount, totals };
101109
}
102110

global-template/docgen/lib/inventory.mjs

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ function candidatePaths(root) {
6161
if (!insideGit) return { paths: walk(root), insideGit: false };
6262
const result = spawnSync('git', ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], { encoding: 'buffer', stdio: ['ignore', 'pipe', 'ignore'] });
6363
if (result.status !== 0) return { paths: walk(root), insideGit: false };
64-
return { paths: result.stdout.toString('utf8').split('\0').filter(Boolean).map(posix).sort(), insideGit: true };
64+
return { paths: result.stdout.toString('utf8').split('\0').filter(Bolean.bind(Boolean)).map(posix).sort(), insideGit: true };
6565
}
6666

6767
function binaryMagic(buffer) {
@@ -80,7 +80,7 @@ export function classifyFile(root, rel, config = loadConfig(root)) {
8080
const probeBytes = Math.max(512, Number(binary.probeBytes ?? 16384)); const fd = fs.openSync(full, 'r'); const buffer = Buffer.alloc(Math.min(stat.size, probeBytes));
8181
try { fs.readSync(fd, buffer, 0, buffer.length, 0); } finally { fs.closeSync(fd); }
8282
if (binaryMagic(buffer)) return { included: false, reason: 'binary-magic', size: stat.size };
83-
if (buffer.includes(0)) return { included: false, reason: 'nul-byte', size: stat.size };
83+
if (buffer.includes(0)) return { included: false, reason: 'null-byte', size: stat.size };
8484
const text = buffer.toString('utf8'); if (text.includes('\uFFFD')) return { included: false, reason: 'invalid-utf8', size: stat.size };
8585
const controls = [...buffer].filter((b) => b < 32 && ![9,10,13].includes(b)).length;
8686
if (buffer.length && controls / buffer.length > Number(binary.controlCharacterRatio ?? 0.08)) return { included: false, reason: 'control-characters', size: stat.size };
@@ -90,17 +90,24 @@ export function classifyFile(root, rel, config = loadConfig(root)) {
9090
export function buildInventory(root) {
9191
ruleCache.clear();
9292
const paths = projectPaths(root); const config = loadConfig(root); const candidates = candidatePaths(root); const docgenRules = loadRules(path.join(root, '.docgenignore')); const included = []; const excluded = [];
93-
for (const rel of candidates.paths) {
94-
const top = rel.split('/')[0];
95-
if (HARD_DIRS.has(top)) { excluded.push({ path: rel, reason: 'hard-exclusion' }); continue; }
96-
if (!candidates.insideGit && config.ignore?.useGitignore !== false && ignoredByFallbackGitignore(root, rel)) { excluded.push({ path: rel, reason: '.gitignore' }); continue; }
97-
if (config.ignore?.useDocgenignore !== false && ignoredByRules(rel, docgenRules)) { excluded.push({ path: rel, reason: '.docgenignore' }); continue; }
98-
const result = classifyFile(root, rel, config);
99-
if (!result.included) { excluded.push({ path: rel, reason: result.reason, size: result.size ?? 0 }); continue; }
100-
const content = fs.readFileSync(path.join(root, rel)); included.push({ path: rel, size: result.size, extension: result.extension, hash: sha256(content) });
93+
const progress = config.execution?.progress !== false && process.env.DOCGEN_PROGRESS !== '0'; const started = Date.now(); let lastReport = started;
94+
if (progress) console.log(`[docgen] inventory RUNNING | ${candidates.paths.length.toLocaleString()} candidate files | ${candidates.insideGit ? 'git-aware' : 'filesystem fallback'}`);
95+
for (let index = 0; index < candidates.paths.length; index++) {
96+
const rel = candidates.paths[index]; const top = rel.split('/')[0];
97+
if (HARD_DIRS.has(top)) excluded.push({ path: rel, reason: 'hard-exclusion' });
98+
else if (!candidates.insideGit && config.ignore?.useGitignore !== false && ignoredByFallbackGitignore(root, rel)) excluded.push({ path: rel, reason: '.gitignore' });
99+
else if (config.ignore?.useDocgenignore !== false && ignoredByRules(rel, docgenRules)) excluded.push({ path: rel, reason: '.docgenignore' });
100+
else {
101+
const result = classifyFile(root, rel, config);
102+
if (!result.included) excluded.push({ path: rel, reason: result.reason, size: result.size ?? 0 });
103+
else { const content = fs.readFileSync(path.join(root, rel)); included.push({ path: rel, size: result.size, extension: result.extension, hash: sha256(content) }); }
104+
}
105+
const nowMs = Date.now();
106+
if (progress && nowMs - lastReport >= 5_000) { lastReport = nowMs; console.log(`[docgen] inventory RUNNING | ${index + 1}/${candidates.paths.length} | included ${included.length} | excluded ${excluded.length}`); }
101107
}
102108
const inventory = { schemaVersion: '2.0', generatedAt: now(), files: included, excluded, metrics: { includedFiles: included.length, includedBytes: included.reduce((n, x) => n + x.size, 0), excludedFiles: excluded.length }, fingerprint: sha256(included.map((x) => `${x.path}\0${x.hash}`).join('\n')) };
103109
ensureDir(path.dirname(paths.inventory)); writeJson(paths.inventory, inventory);
104110
fs.writeFileSync(path.join(path.dirname(paths.inventory), 'source-files.txt'), included.map((x) => x.path).join('\n') + (included.length ? '\n' : ''));
111+
if (progress) console.log(`[docgen] inventory COMPLETED | included ${included.length.toLocaleString()} | excluded ${excluded.length.toLocaleString()} | elapsed ${Math.max(0, Math.round((Date.now() - started) / 1000))}s`);
105112
return inventory;
106113
}

0 commit comments

Comments
 (0)