Skip to content

Commit 385e5aa

Browse files
fix(cross-platform): proper fixes for all 7 cross-platform issues
- fix(code): tree-sitter graceful fallback — `cleo code` commands check availability before running, exit with clear install instructions instead of crashing - fix(cli): suppress ExperimentalWarning via process.emit filter in CLI entry point (replaces non-portable -S shebang flag) - fix(crypto): Windows ACL verification via icacls for machine key (was completely skipped), set restrictive ACLs on key creation - fix(agent): Windows SIGTERM graceful shutdown via `process.on('message')` for PM2 and other Windows process managers - feat(code): isTreeSitterAvailable() exported for pre-flight checks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 480fa01 commit 385e5aa

6 files changed

Lines changed: 104 additions & 8 deletions

File tree

packages/cleo/src/cli/commands/agent.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,14 +326,20 @@ agent ${agentId}:
326326
{ command: 'agent start' },
327327
);
328328

329-
// 6. Keep process alive until SIGINT/SIGTERM
329+
// 6. Keep process alive until shutdown signal (cross-platform)
330330
const shutdown = () => {
331331
runtime.stop();
332332
void registry.update(agentId, { isActive: false }).catch(() => {});
333333
process.exit(0);
334334
};
335335
process.on('SIGINT', shutdown);
336336
process.on('SIGTERM', shutdown);
337+
// Windows: listen for 'message' from parent process managers (PM2, etc.)
338+
if (process.platform === 'win32') {
339+
process.on('message', (msg) => {
340+
if (msg === 'shutdown') shutdown();
341+
});
342+
}
337343

338344
// Keep alive
339345
await new Promise(() => {});
@@ -780,6 +786,9 @@ agent ${agentId}:
780786
};
781787
process.on('SIGINT', shutdown);
782788
process.on('SIGTERM', shutdown);
789+
if (process.platform === 'win32') {
790+
process.on('message', (msg) => { if (msg === 'shutdown') shutdown(); });
791+
}
783792
await new Promise(() => {});
784793
} catch (err) {
785794
cliOutput(
@@ -1057,6 +1066,9 @@ agent ${agentId}:
10571066
};
10581067
process.on('SIGINT', shutdown);
10591068
process.on('SIGTERM', shutdown);
1069+
if (process.platform === 'win32') {
1070+
process.on('message', (msg) => { if (msg === 'shutdown') shutdown(); });
1071+
}
10601072
} catch (err) {
10611073
cliOutput(
10621074
{ success: false, error: { code: 'E_WATCH', message: String(err) } },

packages/cleo/src/cli/commands/code.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@
1010

1111
import { defineCommand } from 'citty';
1212

13+
/** Check tree-sitter availability before running code analysis. Exits with clear message if missing. */
14+
async function requireTreeSitter(): Promise<void> {
15+
const { isTreeSitterAvailable } = await import('@cleocode/core/internal');
16+
if (!isTreeSitterAvailable()) {
17+
console.error(
18+
'Error: tree-sitter is not installed. Code analysis features require tree-sitter grammar packages.\n\n' +
19+
'Install with:\n' +
20+
' npm install -g tree-sitter-cli\n' +
21+
' # Or in the project: pnpm add tree-sitter-cli tree-sitter-typescript tree-sitter-javascript\n\n' +
22+
'All other CLEO features work without tree-sitter. Only `cleo code` commands require it.',
23+
);
24+
process.exit(7); // exit code 7 = service unavailable
25+
}
26+
}
27+
1328
export const codeCommand = defineCommand({
1429
meta: { name: 'code', description: 'Code analysis via tree-sitter AST' },
1530
subCommands: {
@@ -19,6 +34,7 @@ export const codeCommand = defineCommand({
1934
file: { type: 'positional', description: 'Source file path', required: true },
2035
},
2136
async run({ args }) {
37+
await requireTreeSitter();
2238
const { smartOutline } = await import('@cleocode/core/internal');
2339
const { join } = await import('node:path');
2440
const root = process.cwd();
@@ -52,6 +68,7 @@ export const codeCommand = defineCommand({
5268
path: { type: 'string', description: 'File pattern filter (e.g. src/**)' },
5369
},
5470
async run({ args }) {
71+
await requireTreeSitter();
5572
const { smartSearch } = await import('@cleocode/core/internal');
5673
const root = process.cwd();
5774
const results = smartSearch(args.query, {
@@ -86,6 +103,7 @@ export const codeCommand = defineCommand({
86103
},
87104
},
88105
async run({ args }) {
106+
await requireTreeSitter();
89107
const { smartUnfold } = await import('@cleocode/core/internal');
90108
const { join } = await import('node:path');
91109
const root = process.cwd();

packages/cleo/src/cli/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@
66
* TODO: Migrate all 89 commands to native citty pattern (epic T5730)
77
*/
88

9+
// Suppress ExperimentalWarning from node:sqlite and other experimental APIs.
10+
// This replaces the non-portable `#!/usr/bin/env -S node --disable-warning=ExperimentalWarning` shebang.
11+
const originalEmit = process.emit.bind(process);
12+
// @ts-expect-error -- overriding process.emit to filter warnings
13+
process.emit = function (event: string, ...args: unknown[]) {
14+
if (event === 'warning' && args[0] && (args[0] as { name?: string }).name === 'ExperimentalWarning') {
15+
return false;
16+
}
17+
return originalEmit(event, ...args);
18+
};
19+
920
import { readFileSync } from 'node:fs';
1021
import { dirname, join } from 'node:path';
1122
import { fileURLToPath } from 'node:url';

packages/core/src/code/parser.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,46 @@ import { detectLanguage, type TreeSitterLanguage } from '../lib/tree-sitter-lang
2424
// Tree-sitter CLI resolution
2525
// ---------------------------------------------------------------------------
2626

27+
/** Whether tree-sitter is available on this system. */
28+
let _treeSitterAvailable: boolean | null = null;
29+
30+
/** Check if tree-sitter CLI is available. Cached after first call. */
31+
export function isTreeSitterAvailable(): boolean {
32+
if (_treeSitterAvailable !== null) return _treeSitterAvailable;
33+
try {
34+
resolveTreeSitterBin();
35+
_treeSitterAvailable = true;
36+
} catch {
37+
_treeSitterAvailable = false;
38+
}
39+
return _treeSitterAvailable;
40+
}
41+
2742
/** Resolve the tree-sitter CLI binary from node_modules. */
2843
function resolveTreeSitterBin(): string {
44+
// Also check platform-specific binary extension for Windows
45+
const ext = process.platform === 'win32' ? '.exe' : '';
46+
const binName = `tree-sitter${ext}`;
2947
const candidates = [
30-
join(process.cwd(), 'packages', 'core', 'node_modules', '.bin', 'tree-sitter'),
31-
join(process.cwd(), 'node_modules', '.bin', 'tree-sitter'),
48+
join(process.cwd(), 'packages', 'core', 'node_modules', '.bin', binName),
49+
join(process.cwd(), 'node_modules', '.bin', binName),
50+
// npm global install paths
51+
join(process.cwd(), 'node_modules', 'tree-sitter-cli', binName),
3252
];
53+
// Also try without extension (npm .cmd shim on Windows)
54+
if (ext) {
55+
candidates.push(
56+
join(process.cwd(), 'packages', 'core', 'node_modules', '.bin', 'tree-sitter'),
57+
join(process.cwd(), 'node_modules', '.bin', 'tree-sitter'),
58+
);
59+
}
3360
for (const p of candidates) {
3461
if (existsSync(p)) return p;
3562
}
36-
throw new Error('tree-sitter CLI not found. Run: pnpm add -F @cleocode/core tree-sitter-cli');
63+
throw new Error(
64+
'tree-sitter CLI not found. Code analysis features (cleo code outline/search/unfold) ' +
65+
'require tree-sitter. Install with: npm install tree-sitter-cli',
66+
);
3767
}
3868

3969
// ---------------------------------------------------------------------------

packages/core/src/crypto/credentials.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717

1818
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto';
19+
import { execFileSync } from 'node:child_process';
1920
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2021
import { dirname, join } from 'node:path';
2122

@@ -63,9 +64,24 @@ async function getMachineKey(): Promise<Buffer> {
6364
const keyPath = getMachineKeyPath();
6465

6566
try {
66-
// Check existing key — skip permission check on Windows (NTFS doesn't support Unix modes)
67+
// Verify key file permissions
6768
const stats = await stat(keyPath);
68-
if (process.platform !== 'win32') {
69+
if (process.platform === 'win32') {
70+
// Windows: use icacls to verify the key is not world-readable.
71+
try {
72+
const output = execFileSync('icacls', [keyPath], { encoding: 'utf-8', timeout: 5000 });
73+
const unsafePatterns = /\\(Users|Everyone|Authenticated Users):/i;
74+
if (unsafePatterns.test(output)) {
75+
throw new Error(
76+
`Machine key has unsafe Windows ACLs (accessible to other users). ` +
77+
`Fix with: icacls "${keyPath}" /inheritance:r /grant:r "%USERNAME%":F`,
78+
);
79+
}
80+
} catch (aclErr) {
81+
if (aclErr instanceof Error && aclErr.message.includes('unsafe')) throw aclErr;
82+
}
83+
} else {
84+
// Unix: verify 0600 permissions
6985
const mode = stats.mode & 0o777;
7086
if (mode !== 0o600) {
7187
throw new Error(
@@ -89,7 +105,16 @@ async function getMachineKey(): Promise<Buffer> {
89105
const key = randomBytes(KEY_LENGTH);
90106
await mkdir(dirname(keyPath), { recursive: true });
91107
await writeFile(keyPath, key, { mode: 0o600 });
92-
await chmod(keyPath, 0o600); // Ensure perms even on existing dirs
108+
if (process.platform === 'win32') {
109+
// Lock down Windows ACLs: remove inherited permissions, grant only current user
110+
try {
111+
execFileSync('icacls', [keyPath, '/inheritance:r', '/grant:r', `${process.env['USERNAME'] ?? 'CURRENT_USER'}:F`], { timeout: 5000 });
112+
} catch {
113+
// Best-effort — icacls may not be available in all environments
114+
}
115+
} else {
116+
await chmod(keyPath, 0o600);
117+
}
93118
return key;
94119
}
95120
throw err;

packages/core/src/internal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export {
4444
} from './bootstrap.js';
4545
export { smartOutline } from './code/outline.js';
4646
// Code analysis (Smart Explore)
47-
export { batchParse, parseFile } from './code/parser.js';
47+
export { batchParse, isTreeSitterAvailable, parseFile } from './code/parser.js';
4848
export { smartSearch } from './code/search.js';
4949
export { smartUnfold } from './code/unfold.js';
5050
export type { ViolationLogEntry } from './compliance/protocol-enforcement.js';

0 commit comments

Comments
 (0)