cli-jaw 2.2.12 → 2.2.14
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/src/agent/error-classifier.js +11 -2
- package/dist/src/agent/error-classifier.js.map +1 -1
- package/dist/src/agent/events/claude.js +7 -1
- package/dist/src/agent/events/claude.js.map +1 -1
- package/dist/src/agent/events/fulltext-bound.js +32 -0
- package/dist/src/agent/events/fulltext-bound.js.map +1 -0
- package/dist/src/agent/events/grok.js +7 -1
- package/dist/src/agent/events/grok.js.map +1 -1
- package/dist/src/agent/events/helpers.js +17 -4
- package/dist/src/agent/events/helpers.js.map +1 -1
- package/dist/src/agent/lifecycle-handler.js +56 -5
- package/dist/src/agent/lifecycle-handler.js.map +1 -1
- package/dist/src/agent/pi-runtime.js +30 -1
- package/dist/src/agent/pi-runtime.js.map +1 -1
- package/dist/src/agent/spawn/exit-drain.js +96 -0
- package/dist/src/agent/spawn/exit-drain.js.map +1 -0
- package/dist/src/agent/spawn/line-buffer.js +26 -0
- package/dist/src/agent/spawn/line-buffer.js.map +1 -0
- package/dist/src/agent/spawn/process-kill.js +31 -0
- package/dist/src/agent/spawn/process-kill.js.map +1 -1
- package/dist/src/agent/spawn.js +80 -23
- package/dist/src/agent/spawn.js.map +1 -1
- package/dist/src/agent/watchdog.js +21 -5
- package/dist/src/agent/watchdog.js.map +1 -1
- package/dist/src/routes/link-preview.js +14 -0
- package/dist/src/routes/link-preview.js.map +1 -1
- package/package.json +3 -1
- package/public/dist/assets/CodeCanvas-CbfPP7Ad.js +3 -0
- package/public/dist/assets/{DocPanel-DtYwv3n3.js → DocPanel-Dz-J_l5M.js} +1 -1
- package/public/dist/assets/MarkdownRenderer-CXrilOsG.js +15 -0
- package/public/dist/assets/MarkdownRenderer-DZVh_xX2.js +1 -0
- package/public/dist/assets/{MilkdownWysiwygEditor-BNoLGbFC.js → MilkdownWysiwygEditor-Em7iC6y9.js} +8 -8
- package/public/dist/assets/{react-dom-AWKMTIe7.js → bounded-set-CFlZiEF4.js} +1 -1
- package/public/dist/assets/manager-DaN201mN.js +13 -0
- package/public/dist/assets/{wiki-link-resolver-BfNXN9px.js → wiki-link-resolver-DyFzBTIQ.js} +1 -1
- package/public/dist/manager/index.html +1 -1
- package/public/manager/src/SidebarRailRouter.tsx +9 -3
- package/public/manager/src/browser-panel/use-embedded-target-sync.ts +19 -1
- package/public/manager/src/code/CodeCanvas.tsx +14 -2
- package/public/manager/src/code/CodeTranscript.tsx +33 -9
- package/public/manager/src/code/CodeWorkbench.tsx +1 -1
- package/public/manager/src/code/code-event-dedupe.ts +27 -3
- package/public/manager/src/code/use-code-transcript-scroll.ts +28 -3
- package/public/manager/src/code/use-throttled-markdown.ts +75 -0
- package/public/manager/src/code/useCodeTranscriptVirtualRows.ts +19 -0
- package/public/manager/src/electron-metrics.tsx +7 -0
- package/public/manager/src/jaw-ceo/useJawCeo.ts +6 -1
- package/public/manager/src/jaw-ceo/useJawCeoVirtualTimeline.ts +5 -0
- package/public/manager/src/lib/bounded-set.ts +15 -0
- package/public/manager/src/lib/fnv1a.ts +14 -0
- package/public/manager/src/notes/rendering/CodeBlock.tsx +13 -4
- package/public/manager/src/notes/rendering/MarkdownRenderer.tsx +14 -2
- package/public/manager/src/notes/rendering/highlight-cache.ts +111 -0
- package/public/manager/src/notes/useNotesExternalSync.ts +6 -1
- package/scripts/bundle-sidecar.sh +8 -0
- package/scripts/check-electron-no-native.cjs +90 -4
- package/scripts/check-native-load.cjs +157 -0
- package/scripts/check-sidecar-smoke.mjs +151 -0
- package/scripts/ensure-native-modules.cjs +222 -18
- package/scripts/release-gates.mjs +29 -0
- package/scripts/run-electron-builder.mjs +60 -0
- package/public/dist/assets/CodeCanvas-BZo3dohZ.js +0 -3
- package/public/dist/assets/MarkdownRenderer-CHacL2M_.js +0 -15
- package/public/dist/assets/MarkdownRenderer-DyrB9FbZ.js +0 -1
- package/public/dist/assets/manager-DL1GygQL.js +0 -13
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { highlightCode, normalizeCodeLanguage, type HighlightResult } from './highlight-languages';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* D2 (260803 unit, 020 phase): `highlightCode` is pure but was called straight
|
|
5
|
+
* from a render body, so a large block was re-tokenized on every parent render
|
|
6
|
+
* and again every time a virtualized row scrolled back into view.
|
|
7
|
+
*
|
|
8
|
+
* Bounds are deliberately smaller than t3code's 500 entries / 50MB
|
|
9
|
+
* (apps/web/src/components/ChatMarkdown.tsx:110): highlighting is one panel
|
|
10
|
+
* among many here and this phase exists to reduce RAM, not trade it.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_ENTRIES = 200;
|
|
13
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Skip highlighting entirely past this size. hljs cost is superlinear in
|
|
17
|
+
* pathological input, and a minified bundle pasted into a message should not
|
|
18
|
+
* stall the main thread. t3code caps per line (tokenizeMaxLineLength: 1_000);
|
|
19
|
+
* hljs has no such knob, so we cap the whole block instead.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_HIGHLIGHT_CHARS = 100_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reject pathological single lines before hljs ever sees them. A minified
|
|
25
|
+
* bundle pasted into a message is one enormous line, and tokenizer cost is
|
|
26
|
+
* superlinear there. t3code enforces the same idea with
|
|
27
|
+
* `tokenizeMaxLineLength: 1_000` (apps/web/src/components/DiffWorkerPoolProvider.tsx:75);
|
|
28
|
+
* hljs exposes no equivalent option, so we pre-check instead.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_HIGHLIGHT_LINE_CHARS = 1_000;
|
|
31
|
+
|
|
32
|
+
type Entry = { result: HighlightResult; bytes: number };
|
|
33
|
+
|
|
34
|
+
// Map preserves insertion order, which gives LRU for free: delete + re-set on
|
|
35
|
+
// hit moves an entry to the newest position.
|
|
36
|
+
const cache = new Map<string, Entry>();
|
|
37
|
+
let cachedBytes = 0;
|
|
38
|
+
|
|
39
|
+
function estimateBytes(key: string, result: HighlightResult): number {
|
|
40
|
+
// The key embeds a full copy of the source, so counting only the html
|
|
41
|
+
// would undercount retention by roughly half.
|
|
42
|
+
return (key.length + result.html.length) * 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function evictUntilWithinBounds(): void {
|
|
46
|
+
while ((cache.size > MAX_ENTRIES || cachedBytes > MAX_BYTES) && cache.size > 0) {
|
|
47
|
+
const oldest = cache.keys().next();
|
|
48
|
+
if (oldest.done) break;
|
|
49
|
+
const entry = cache.get(oldest.value);
|
|
50
|
+
cache.delete(oldest.value);
|
|
51
|
+
if (entry) cachedBytes -= entry.bytes;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function escapeHtml(code: string): string {
|
|
56
|
+
return code.replace(/[&<>"']/g, value => ({
|
|
57
|
+
'&': '&',
|
|
58
|
+
'<': '<',
|
|
59
|
+
'>': '>',
|
|
60
|
+
'"': '"',
|
|
61
|
+
"'": ''',
|
|
62
|
+
})[value] ?? value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasOverlongLine(code: string): boolean {
|
|
66
|
+
let lineStart = 0;
|
|
67
|
+
for (let i = 0; i < code.length; i += 1) {
|
|
68
|
+
if (code.charCodeAt(i) !== 10) continue;
|
|
69
|
+
if (i - lineStart > MAX_HIGHLIGHT_LINE_CHARS) return true;
|
|
70
|
+
lineStart = i + 1;
|
|
71
|
+
}
|
|
72
|
+
return code.length - lineStart > MAX_HIGHLIGHT_LINE_CHARS;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function highlightCodeCached(code: string, language?: string): HighlightResult {
|
|
76
|
+
// Normalize first: `TypeScript`, `typescript`, and `language-typescript`
|
|
77
|
+
// all resolve to one result, so they must share one cache entry.
|
|
78
|
+
const normalized = normalizeCodeLanguage(language);
|
|
79
|
+
|
|
80
|
+
if (code.length > MAX_HIGHLIGHT_CHARS || hasOverlongLine(code)) {
|
|
81
|
+
return { html: escapeHtml(code), language: normalized, highlighted: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const key = `${normalized}\u0000${code}`;
|
|
85
|
+
const hit = cache.get(key);
|
|
86
|
+
if (hit) {
|
|
87
|
+
cache.delete(key);
|
|
88
|
+
cache.set(key, hit);
|
|
89
|
+
return hit.result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const result = highlightCode(code, language);
|
|
93
|
+
const bytes = estimateBytes(key, result);
|
|
94
|
+
// A single oversized entry would evict everything else for no benefit.
|
|
95
|
+
if (bytes <= MAX_BYTES) {
|
|
96
|
+
cache.set(key, { result, bytes });
|
|
97
|
+
cachedBytes += bytes;
|
|
98
|
+
evictUntilWithinBounds();
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Test seam. */
|
|
104
|
+
export function __resetHighlightCache(): void {
|
|
105
|
+
cache.clear();
|
|
106
|
+
cachedBytes = 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function __highlightCacheStats(): { entries: number; bytes: number } {
|
|
110
|
+
return { entries: cache.size, bytes: cachedBytes };
|
|
111
|
+
}
|
|
@@ -31,7 +31,12 @@ export function useNotesExternalSync(active: boolean): void {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
void poll();
|
|
34
|
-
|
|
34
|
+
// D4: a hidden window cannot show an external change, so polling for
|
|
35
|
+
// one is pure idle cost. Matches useRemindersFeed's guard.
|
|
36
|
+
const timer = setInterval(() => {
|
|
37
|
+
if (typeof document !== 'undefined' && document.hidden) return;
|
|
38
|
+
void poll();
|
|
39
|
+
}, POLL_INTERVAL_MS);
|
|
35
40
|
return () => { cancelled = true; clearInterval(timer); };
|
|
36
41
|
}, [active]);
|
|
37
42
|
}
|
|
@@ -165,5 +165,13 @@ fi
|
|
|
165
165
|
|
|
166
166
|
node "$PROJECT_ROOT/scripts/check-electron-sidecar-no-jwc.cjs" --server-root "$SIDECAR_DIR"
|
|
167
167
|
|
|
168
|
+
# Static prune analysis runs before the build; this runs after, on the artifact
|
|
169
|
+
# that will actually ship. The prune guard reasons about bare specifiers and
|
|
170
|
+
# cannot see a computed `import(spec)`, so it can only ever be as complete as
|
|
171
|
+
# its manual RUNTIME_LOADED list. Importing the critical modules for real
|
|
172
|
+
# closes that gap by construction — a dashboard returning 200 never proved the
|
|
173
|
+
# Telegram bot could load.
|
|
174
|
+
node "$PROJECT_ROOT/scripts/check-sidecar-smoke.mjs" --server-root "$SIDECAR_DIR"
|
|
175
|
+
|
|
168
176
|
echo "=== Sidecar ready ==="
|
|
169
177
|
du -sh "$SIDECAR_DIR"
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const { execSync } = require('node:child_process');
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
|
|
4
|
+
// Split, because a single list encoded a false invariant. node-pty IS a native
|
|
5
|
+
// addon, IS an electron dependency, and IS imported by the terminal module —
|
|
6
|
+
// so "electron source has no native deps" was simply untrue, and the one addon
|
|
7
|
+
// that actually ships was the one nothing checked.
|
|
8
|
+
//
|
|
9
|
+
// FORBIDDEN: must never appear in electron/src. Any of these would drag a
|
|
10
|
+
// second native build into the Electron ABI.
|
|
11
|
+
const FORBIDDEN = ['better-sqlite3', 'playwright-core', 'sharp', 'canvas'];
|
|
12
|
+
// EXPECTED_NATIVE: legitimately shipped. Presence here is not a failure, but it
|
|
13
|
+
// IS a promise that a runtime load probe covers it — see check-native-load.cjs.
|
|
14
|
+
const EXPECTED_NATIVE = ['node-pty'];
|
|
15
|
+
const pat = FORBIDDEN.join('|');
|
|
5
16
|
// Match any of:
|
|
6
17
|
// from "pkg" / from 'pkg'
|
|
7
18
|
// require("pkg") / require('pkg')
|
|
@@ -13,7 +24,82 @@ try {
|
|
|
13
24
|
} catch (e) { out = ''; }
|
|
14
25
|
const matches = out.trim();
|
|
15
26
|
if (matches) {
|
|
16
|
-
console.error('❌
|
|
27
|
+
console.error('❌ Forbidden native dep imports detected in electron/src:\n' + matches);
|
|
17
28
|
process.exit(1);
|
|
18
29
|
}
|
|
19
|
-
|
|
30
|
+
|
|
31
|
+
// A bare-specifier grep only sees the first hop. electron/src already reaches
|
|
32
|
+
// across the tree boundary with relative paths (electron/src/main/lib/folder/ipc.ts
|
|
33
|
+
// imports into src/manager/git/*), and src/manager/reminders/store.ts imports
|
|
34
|
+
// better-sqlite3 — so the guard is one careless import away from being wrong
|
|
35
|
+
// while still printing a pass. Follow the relative graph transitively.
|
|
36
|
+
const { existsSync, readFileSync } = require('node:fs');
|
|
37
|
+
const { dirname, resolve, join } = require('node:path');
|
|
38
|
+
|
|
39
|
+
function resolveModule(spec, fromFile) {
|
|
40
|
+
const base = resolve(dirname(fromFile), spec.replace(/\.js$/, ''));
|
|
41
|
+
for (const candidate of [
|
|
42
|
+
`${base}.ts`, `${base}.tsx`, `${base}.mts`, `${base}.cts`,
|
|
43
|
+
join(base, 'index.ts'), join(base, 'index.tsx'),
|
|
44
|
+
`${base}.js`,
|
|
45
|
+
]) {
|
|
46
|
+
if (existsSync(candidate)) return candidate;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `sharp` and `sharp/lib/x.js` are the same dependency. */
|
|
52
|
+
function isForbidden(spec) {
|
|
53
|
+
return FORBIDDEN.some(pkg => spec === pkg || spec.startsWith(`${pkg}/`));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function crawl(entryFiles) {
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
const queue = [...entryFiles];
|
|
59
|
+
const violations = [];
|
|
60
|
+
let unresolved = 0;
|
|
61
|
+
while (queue.length > 0) {
|
|
62
|
+
const file = queue.pop();
|
|
63
|
+
if (!file || seen.has(file)) continue;
|
|
64
|
+
seen.add(file);
|
|
65
|
+
let source = '';
|
|
66
|
+
try { source = readFileSync(file, 'utf8'); } catch { continue; }
|
|
67
|
+
// Covers `from 'x'`, `require('x')`, `import('x')`, and the bare
|
|
68
|
+
// side-effect form `import 'x';` — the last one is easy to miss and is
|
|
69
|
+
// exactly how an unnoticed dependency creeps in.
|
|
70
|
+
const specRe = /(?:from|require\(|import\(|^\s*import)\s*['"]([^'"]+)['"]/gm;
|
|
71
|
+
let match;
|
|
72
|
+
while ((match = specRe.exec(source)) !== null) {
|
|
73
|
+
const spec = match[1];
|
|
74
|
+
if (isForbidden(spec)) {
|
|
75
|
+
violations.push(`${file}: imports ${spec}`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!spec.startsWith('.')) continue;
|
|
79
|
+
const resolved = resolveModule(spec, file);
|
|
80
|
+
if (resolved) queue.push(resolved);
|
|
81
|
+
// A silently dropped specifier is a hole in the crawl that looks
|
|
82
|
+
// identical to a clean result, so count and report them.
|
|
83
|
+
else unresolved += 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { violations, scanned: seen.size, unresolved };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let entries = [];
|
|
90
|
+
try {
|
|
91
|
+
entries = execSync('find electron/src -name "*.ts" -o -name "*.tsx"', { encoding: 'utf8' })
|
|
92
|
+
.split('\n').map(s => s.trim()).filter(Boolean).map(f => resolve(f));
|
|
93
|
+
} catch { entries = []; }
|
|
94
|
+
|
|
95
|
+
const { violations, scanned, unresolved } = crawl(entries);
|
|
96
|
+
if (violations.length > 0) {
|
|
97
|
+
console.error('❌ Forbidden native dep reachable from electron/src via relative imports:');
|
|
98
|
+
for (const line of violations) console.error(` - ${line}`);
|
|
99
|
+
console.error('\nThese would need an Electron-ABI rebuild; the sidecar builds for Node.');
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
// Known limit, stated rather than implied: computed specifiers
|
|
103
|
+
// (`import(`../${name}.js`)`) and `createRequire(...)('pkg')` are invisible to
|
|
104
|
+
// a text scan — the same blind spot the sidecar prune guard documents.
|
|
105
|
+
console.log(`✅ Electron source has no forbidden native deps (scanned ${scanned} files transitively${unresolved ? `, ${unresolved} relative specifiers unresolved` : ''}; expected native: ${EXPECTED_NATIVE.join(', ')})`);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runtime load probe for shipped native addons (260803 unit, 040 phase D1).
|
|
4
|
+
*
|
|
5
|
+
* Static grep proves a module is imported. It does not prove the binary loads.
|
|
6
|
+
* The v2.2.10 incident made that concrete: the dashboard returned HTTP 200
|
|
7
|
+
* while the Telegram bot could not resolve a pruned dependency. Presence is
|
|
8
|
+
* not liveness.
|
|
9
|
+
*
|
|
10
|
+
* node-pty@1.1.0 is N-API based (node-addon-api, 38 napi_ symbols, zero v8/Nan),
|
|
11
|
+
* so a NODE_MODULE_VERSION mismatch is NOT the risk here. What can still break
|
|
12
|
+
* is architecture, asarUnpack placement, and the executable bit on
|
|
13
|
+
* `spawn-helper` — none of which a grep can see. So we dlopen the binary and,
|
|
14
|
+
* where possible, actually run a pty.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node scripts/check-native-load.cjs # check the repo tree
|
|
18
|
+
* node scripts/check-native-load.cjs --app <path/to.app> # check a packaged app
|
|
19
|
+
*/
|
|
20
|
+
const { existsSync, statSync, readdirSync, constants, accessSync } = require('node:fs');
|
|
21
|
+
const { join } = require('node:path');
|
|
22
|
+
const { execFileSync } = require('node:child_process');
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const appIndex = args.indexOf('--app');
|
|
26
|
+
const appPath = appIndex >= 0 ? args[appIndex + 1] : null;
|
|
27
|
+
|
|
28
|
+
const failures = [];
|
|
29
|
+
const notes = [];
|
|
30
|
+
|
|
31
|
+
function fail(message) { failures.push(message); }
|
|
32
|
+
function note(message) { notes.push(message); }
|
|
33
|
+
|
|
34
|
+
function findPtyRoot() {
|
|
35
|
+
if (appPath) {
|
|
36
|
+
// electron-builder unpacks asarUnpack entries next to the asar.
|
|
37
|
+
const unpacked = join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'node_modules', 'node-pty');
|
|
38
|
+
if (existsSync(unpacked)) return unpacked;
|
|
39
|
+
const plain = join(appPath, 'Contents', 'Resources', 'app', 'node_modules', 'node-pty');
|
|
40
|
+
if (existsSync(plain)) return plain;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const local = join(process.cwd(), 'electron', 'node_modules', 'node-pty');
|
|
44
|
+
return existsSync(local) ? local : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ptyRoot = findPtyRoot();
|
|
48
|
+
// Exit 3 = nothing was probed. The caller must not present this as a pass;
|
|
49
|
+
// a green "loads fine" when nothing loaded is the exact dishonesty this
|
|
50
|
+
// script exists to remove.
|
|
51
|
+
const EXIT_SKIPPED = 3;
|
|
52
|
+
if (!ptyRoot) {
|
|
53
|
+
if (appPath) {
|
|
54
|
+
fail(`node-pty not found in ${appPath}. asarUnpack must keep it outside the asar (electron-builder.yml asarUnpack: node_modules/node-pty/**/*); a packed addon cannot be dlopen'd.`);
|
|
55
|
+
} else if (process.env.JAW_GATE_REQUIRE_NATIVE === '1') {
|
|
56
|
+
// Opt-in requirement rather than a blanket CI check. The node-tests
|
|
57
|
+
// workflow runs `npm ci --ignore-scripts` at the root only, so it never
|
|
58
|
+
// has electron/node_modules at all: keying on CI made this gate demand an
|
|
59
|
+
// artifact that context cannot produce, and every PR went red for it.
|
|
60
|
+
// The flag is set where the artifact really exists (desktop-release, right
|
|
61
|
+
// after `npm ci --prefix electron`), so a miss there is a genuine failure.
|
|
62
|
+
fail('electron/node_modules/node-pty is absent but JAW_GATE_REQUIRE_NATIVE=1 demanded a real probe (run npm i in electron/)');
|
|
63
|
+
} else {
|
|
64
|
+
console.log('ℹ node-pty not installed in electron/node_modules — nothing probed (run npm i in electron/)');
|
|
65
|
+
process.exit(EXIT_SKIPPED);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
const binary = join(ptyRoot, 'build', 'Release', 'pty.node');
|
|
69
|
+
if (!existsSync(binary)) {
|
|
70
|
+
fail(`missing native binary: ${binary}`);
|
|
71
|
+
} else {
|
|
72
|
+
// 1. The binary must actually dlopen under this runtime.
|
|
73
|
+
try {
|
|
74
|
+
const handle = { exports: {} };
|
|
75
|
+
process.dlopen(handle, binary, constants.dlopen?.RTLD_NOW ?? undefined);
|
|
76
|
+
note(`dlopen ok: ${binary}`);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
fail(`dlopen failed for ${binary}: ${(error && error.message) || error}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. spawn-helper must be present AND executable, or every pty dies at
|
|
82
|
+
// runtime with a permission error that no import check would catch.
|
|
83
|
+
const helper = join(ptyRoot, 'build', 'Release', 'spawn-helper');
|
|
84
|
+
if (process.platform !== 'win32') {
|
|
85
|
+
if (!existsSync(helper)) {
|
|
86
|
+
fail(`missing spawn-helper: ${helper}`);
|
|
87
|
+
} else {
|
|
88
|
+
try {
|
|
89
|
+
accessSync(helper, constants.X_OK);
|
|
90
|
+
note(`spawn-helper executable: mode ${(statSync(helper).mode & 0o777).toString(8)}`);
|
|
91
|
+
} catch {
|
|
92
|
+
fail(`spawn-helper is not executable: ${helper} (mode ${(statSync(helper).mode & 0o777).toString(8)})`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. Architecture must match the host, or dlopen succeeds nowhere useful.
|
|
98
|
+
try {
|
|
99
|
+
const prebuilds = join(ptyRoot, 'prebuilds');
|
|
100
|
+
if (existsSync(prebuilds)) {
|
|
101
|
+
note(`prebuilds present: ${readdirSync(prebuilds).join(', ')}`);
|
|
102
|
+
}
|
|
103
|
+
} catch { /* informational only */ }
|
|
104
|
+
|
|
105
|
+
// 4. Actually spawn a pty and read from it. access(X_OK) says the bit is
|
|
106
|
+
// set; it does not say codesign, quarantine, or arch let the helper run.
|
|
107
|
+
// Only a real spawn covers that, and it is the failure a user would hit
|
|
108
|
+
// on their first terminal session in the shipped app.
|
|
109
|
+
if (!appPath) {
|
|
110
|
+
// Run the round-trip in a child so we can wait on it without blocking
|
|
111
|
+
// this process's event loop — pty data arrives via callbacks, so a
|
|
112
|
+
// synchronous wait here would guarantee a false negative.
|
|
113
|
+
const probe = `
|
|
114
|
+
const pty = require(${JSON.stringify(ptyRoot)});
|
|
115
|
+
// cmd.exe does not understand POSIX -c; it needs /c (with /d /s to skip
|
|
116
|
+
// AutoRun and keep quote handling predictable). Passing -c made the
|
|
117
|
+
// shell exit non-zero and print usage, so the probe would have reported
|
|
118
|
+
// a broken spawn-helper on Windows even when the addon was healthy.
|
|
119
|
+
const isWin = process.platform === 'win32';
|
|
120
|
+
const shell = isWin ? 'cmd.exe' : '/bin/sh';
|
|
121
|
+
const shellArgs = isWin ? ['/d', '/s', '/c', 'echo jaw-pty-ok'] : ['-c', 'echo jaw-pty-ok'];
|
|
122
|
+
const term = pty.spawn(shell, shellArgs, {
|
|
123
|
+
name: 'xterm-color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env,
|
|
124
|
+
});
|
|
125
|
+
let seen = '';
|
|
126
|
+
const done = (code) => { try { term.kill(); } catch {} process.exit(code); };
|
|
127
|
+
term.onData((chunk) => { seen += chunk; if (seen.includes('jaw-pty-ok')) done(0); });
|
|
128
|
+
setTimeout(() => done(seen.includes('jaw-pty-ok') ? 0 : 7), 5000);
|
|
129
|
+
`;
|
|
130
|
+
try {
|
|
131
|
+
execFileSync(process.execPath, ['-e', probe], { timeout: 15_000, stdio: 'ignore' });
|
|
132
|
+
note('pty spawn round-trip ok (spawn-helper genuinely executes)');
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const status = error && error.status;
|
|
135
|
+
if (status === 7) {
|
|
136
|
+
fail('pty spawned but produced no output within 5s — spawn-helper may be blocked by codesign/quarantine');
|
|
137
|
+
} else {
|
|
138
|
+
// The child's message embeds the whole probe source; keep only the
|
|
139
|
+
// first meaningful line so the build log stays readable.
|
|
140
|
+
const raw = String((error && error.message) || error);
|
|
141
|
+
const firstLine = raw.split('\n').find(l => /Error|EACCES|EPERM|ENOENT/.test(l)) || raw.split('\n')[0];
|
|
142
|
+
fail(`pty spawn failed: ${firstLine.trim()}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
note('packaged app: spawn round-trip skipped (needs the Electron runtime); dlopen + permissions checked');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const line of notes) console.log(` ${line}`);
|
|
152
|
+
if (failures.length > 0) {
|
|
153
|
+
console.error('❌ native load probe failed:');
|
|
154
|
+
for (const line of failures) console.error(` - ${line}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
console.log(`✅ native addons load${appPath ? ` in ${appPath}` : ''}`);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Post-bundle import smoke for the packaged sidecar (260803 unit, 040 phase D3).
|
|
4
|
+
*
|
|
5
|
+
* `check-sidecar-prune-safety.mjs` reasons about the prune list statically: it
|
|
6
|
+
* parses PRUNE_PKGS out of the shell script, scans source for bare specifiers,
|
|
7
|
+
* and walks the transitive closure through dependencies/optionalDependencies/
|
|
8
|
+
* peerDependencies. That closure is what catches the second half of the v2.2.10
|
|
9
|
+
* incident (`node-fetch → fetch-blob → web-streams-polyfill`).
|
|
10
|
+
*
|
|
11
|
+
* What it cannot see is a computed specifier — `import(someVariable)`. The
|
|
12
|
+
* authors knew, which is why RUNTIME_LOADED exists as a manual escape hatch
|
|
13
|
+
* with exactly one entry. Any future dynamic import that nobody remembers to
|
|
14
|
+
* register reproduces the incident in a new shape.
|
|
15
|
+
*
|
|
16
|
+
* This closes that by construction: after the bundle exists, actually import
|
|
17
|
+
* the modules whose failure was invisible last time. A dashboard returning 200
|
|
18
|
+
* never proved the Telegram bot could load; importing it does.
|
|
19
|
+
*
|
|
20
|
+
* Usage: node scripts/check-sidecar-smoke.mjs [--server-root <dir>]
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
23
|
+
import { join, resolve } from 'node:path';
|
|
24
|
+
import { pathToFileURL } from 'node:url';
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const rootIndex = args.indexOf('--server-root');
|
|
30
|
+
// An explicit --server-root means the caller built the bundle and knows where
|
|
31
|
+
// it should be (bundle-sidecar.sh passes it immediately after bundling), so a
|
|
32
|
+
// missing tree there is a real failure rather than "nothing to check".
|
|
33
|
+
const explicitServerRoot = rootIndex >= 0;
|
|
34
|
+
if (explicitServerRoot && !args[rootIndex + 1]) {
|
|
35
|
+
console.error('❌ --server-root requires a directory argument');
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
const serverRoot = resolve(explicitServerRoot ? args[rootIndex + 1] : 'electron/sidecar/server');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Entry surfaces whose load failure would be silent in a running app: each is
|
|
42
|
+
* reached only on a specific user action, so a healthy dashboard says nothing
|
|
43
|
+
* about them. `telegram/bot.js` is the exact module the v2.2.10 prune broke.
|
|
44
|
+
*/
|
|
45
|
+
const CRITICAL_MODULES = [
|
|
46
|
+
'dist/src/telegram/bot.js',
|
|
47
|
+
'dist/server.js',
|
|
48
|
+
'dist/src/manager/server.js',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Exit code 3 = "nothing to check". The caller must NOT report this as a pass:
|
|
53
|
+
* a gate that says "imports verified" when it imported nothing is the same
|
|
54
|
+
* dishonest-green this script exists to eliminate.
|
|
55
|
+
*/
|
|
56
|
+
const EXIT_SKIPPED = 3;
|
|
57
|
+
|
|
58
|
+
if (!existsSync(serverRoot)) {
|
|
59
|
+
// Keying this on CI made the gate demand a bundle that the node-tests
|
|
60
|
+
// workflow never builds, so every PR failed here. Require a real check
|
|
61
|
+
// only where the caller actually produced (or explicitly named) the tree.
|
|
62
|
+
if (explicitServerRoot || process.env['JAW_GATE_REQUIRE_SIDECAR'] === '1') {
|
|
63
|
+
console.error(`❌ sidecar not bundled at ${serverRoot} but the caller required a real smoke test`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
console.log(`ℹ sidecar not bundled at ${serverRoot} — skipping smoke (run scripts/bundle-sidecar.sh first)`);
|
|
67
|
+
process.exit(EXIT_SKIPPED);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const failures = [];
|
|
71
|
+
let checked = 0;
|
|
72
|
+
const seenCauses = new Set();
|
|
73
|
+
|
|
74
|
+
// Redirect the probe's home to a throwaway directory. Importing these modules
|
|
75
|
+
// regenerates AGENTS.md and touches the DB; a gate must not mutate the
|
|
76
|
+
// developer's real ~/.cli-jaw just by checking that the bundle loads.
|
|
77
|
+
const probeHome = mkdtempSync(join(tmpdir(), 'jaw-smoke-home-'));
|
|
78
|
+
|
|
79
|
+
for (const relative of CRITICAL_MODULES) {
|
|
80
|
+
const target = join(serverRoot, relative);
|
|
81
|
+
if (!existsSync(target)) {
|
|
82
|
+
failures.push(`${relative}: not present in the bundle`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
checked += 1;
|
|
86
|
+
// Import in a CHILD process, never in-process. These modules are not inert:
|
|
87
|
+
// dist/server.js binds ports, opens the DB, starts timers and writes user
|
|
88
|
+
// state. Importing them here would hang the build forever and mutate the
|
|
89
|
+
// developer's ~/.cli-jaw. We only need to know that resolution + top-level
|
|
90
|
+
// evaluation get far enough to not throw, so a bounded child that we kill
|
|
91
|
+
// is both sufficient and safe.
|
|
92
|
+
const child = spawnSync(
|
|
93
|
+
process.execPath,
|
|
94
|
+
['--input-type=module', '-e', `await import(${JSON.stringify(pathToFileURL(target).href)}); process.exit(0);`],
|
|
95
|
+
{
|
|
96
|
+
encoding: 'utf8',
|
|
97
|
+
timeout: 30_000,
|
|
98
|
+
killSignal: 'SIGKILL',
|
|
99
|
+
env: {
|
|
100
|
+
...process.env,
|
|
101
|
+
JAW_SMOKE_PROBE: '1',
|
|
102
|
+
CLI_JAW_HOME: probeHome,
|
|
103
|
+
// Keep the probe off any port the developer or CI is using.
|
|
104
|
+
PORT: '0',
|
|
105
|
+
DASHBOARD_PORT: '0',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// A module that boots a server never exits on its own; the timeout kill is
|
|
111
|
+
// the SUCCESS signal there, because it means the import itself resolved.
|
|
112
|
+
const timedOut = child.error && child.error.code === 'ETIMEDOUT';
|
|
113
|
+
const importFailed = !timedOut
|
|
114
|
+
&& child.status !== 0
|
|
115
|
+
&& /ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM|Cannot find package|Cannot find module/.test(child.stderr || '');
|
|
116
|
+
|
|
117
|
+
if (importFailed) {
|
|
118
|
+
// Prefer the human-readable "Cannot find package 'x'" line over the
|
|
119
|
+
// internal `throw new ERR_MODULE_NOT_FOUND(...)` frame, which names no
|
|
120
|
+
// package and is useless in a build log.
|
|
121
|
+
const stderrLines = (child.stderr || '').split('\n');
|
|
122
|
+
const line = stderrLines.find(l => /Cannot find (package|module)/.test(l))
|
|
123
|
+
|| stderrLines.find(l => /ERR_[A-Z_]+/.test(l))
|
|
124
|
+
|| `exit ${child.status}`;
|
|
125
|
+
const cause = line.trim();
|
|
126
|
+
if (!seenCauses.has(cause)) {
|
|
127
|
+
seenCauses.add(cause);
|
|
128
|
+
failures.push(`${relative}: ${cause}`);
|
|
129
|
+
} else {
|
|
130
|
+
failures.push(`${relative}: (same cause as above)`);
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
console.log(` loaded: ${relative}${timedOut ? ' (kept running — import resolved)' : ''}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
rmSync(probeHome, { recursive: true, force: true });
|
|
139
|
+
} catch {
|
|
140
|
+
// best effort
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (failures.length > 0) {
|
|
144
|
+
console.error('❌ sidecar smoke failed — the bundle is missing something it needs at runtime:');
|
|
145
|
+
for (const line of failures) console.error(` - ${line}`);
|
|
146
|
+
console.error('\nThis is the v2.2.10 class: the app would start and look healthy, then fail');
|
|
147
|
+
console.error('on first use of the affected surface. Check the prune list in scripts/bundle-sidecar.sh.');
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(`✅ sidecar smoke ok (${checked} critical modules imported from ${serverRoot})`);
|