gm-thebird 2.0.1012

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.
@@ -0,0 +1,863 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const https = require('https');
8
+ const crypto = require('crypto');
9
+ const { URL } = require('url');
10
+
11
+ const RELEASE_REPO = 'AnEntrypoint/plugkit-bin';
12
+ const ATTEMPT_TIMEOUT_MS = 5 * 60 * 1000;
13
+ const STALL_TIMEOUT_MS = 15 * 1000;
14
+ const MAX_ATTEMPTS = 5;
15
+ const BACKOFF_MS = [2000, 5000, 15000, 30000];
16
+ // Worst case: a slow link downloading 140MB at 1MB/s = ~140s. Allow 30 minutes
17
+ // before another bootstrap process treats this lock as abandoned. Below this,
18
+ // concurrent bootstrap calls would wipe an in-progress download mid-stream
19
+ // (see the v0.1.294 incident where a race between two wrappers blew away the
20
+ // .partial during a 10-minute fetch).
21
+ const LOCK_STALE_MS = 30 * 60 * 1000;
22
+
23
+ function log(msg) {
24
+ try { process.stderr.write(`[plugkit-bootstrap] ${msg}\n`); } catch (_) {}
25
+ }
26
+
27
+ function probeBinaryVersion(binPath) {
28
+ try {
29
+ const { spawnSync } = require('child_process');
30
+ const r = spawnSync(binPath, ['--version'], { timeout: 3000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
31
+ if (r.error) return null;
32
+ const text = `${r.stdout || ''} ${r.stderr || ''}`.trim();
33
+ const m = text.match(/(\d+\.\d+\.\d+)/);
34
+ return m ? m[1] : null;
35
+ } catch (_) { return null; }
36
+ }
37
+
38
+ function writeBootstrapError(spec) {
39
+ try {
40
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
41
+ const spoolDir = path.join(projectDir, '.gm', 'exec-spool');
42
+ fs.mkdirSync(spoolDir, { recursive: true });
43
+ const out = path.join(spoolDir, '.bootstrap-error.json');
44
+ fs.writeFileSync(out, JSON.stringify({ ts: new Date().toISOString(), ...spec }, null, 2));
45
+ } catch (_) {}
46
+ }
47
+
48
+ function clearBootstrapError() {
49
+ try {
50
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
51
+ const out = path.join(projectDir, '.gm', 'exec-spool', '.bootstrap-error.json');
52
+ fs.unlinkSync(out);
53
+ } catch (_) {}
54
+ }
55
+
56
+ function obsEvent(subsystem, event, fields) {
57
+ if (process.env.GM_LOG_DISABLE) return;
58
+ try {
59
+ const root = process.env.GM_LOG_DIR
60
+ || path.join(os.homedir(), '.claude', 'gm-log');
61
+ const day = new Date().toISOString().slice(0, 10);
62
+ const dir = path.join(root, day);
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ const line = JSON.stringify({
65
+ ts: new Date().toISOString(),
66
+ sub: subsystem,
67
+ event,
68
+ pid: process.pid,
69
+ sess: process.env.CLAUDE_SESSION_ID || process.env.GM_SESSION_ID || '',
70
+ ...fields,
71
+ });
72
+ fs.appendFileSync(path.join(dir, `${subsystem}.jsonl`), line + '\n');
73
+ } catch (_) {}
74
+ }
75
+
76
+ function platformKey() {
77
+ const p = os.platform();
78
+ const a = os.arch();
79
+ if (p === 'win32') return a === 'arm64' ? 'win32-arm64' : 'win32-x64';
80
+ if (p === 'darwin') return a === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
81
+ return (a === 'arm64' || a === 'aarch64') ? 'linux-arm64' : 'linux-x64';
82
+ }
83
+
84
+ function binaryName() {
85
+ const key = platformKey();
86
+ return key.startsWith('win32') ? `plugkit-${key}.exe` : `plugkit-${key}`;
87
+ }
88
+
89
+ function rtkBinaryName() {
90
+ const key = platformKey();
91
+ return key.startsWith('win32') ? `rtk-${key}.exe` : `rtk-${key}`;
92
+ }
93
+
94
+ function cacheRoot() {
95
+ const home = os.homedir();
96
+ if (process.env.PLUGKIT_CACHE_DIR) return process.env.PLUGKIT_CACHE_DIR;
97
+ if (os.platform() === 'win32') {
98
+ const base = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
99
+ return path.join(base, 'plugkit', 'bin');
100
+ }
101
+ if (os.platform() === 'darwin') return path.join(home, 'Library', 'Caches', 'plugkit', 'bin');
102
+ const xdg = process.env.XDG_CACHE_HOME || path.join(home, '.cache');
103
+ return path.join(xdg, 'plugkit', 'bin');
104
+ }
105
+
106
+ function fallbackCacheRoot() {
107
+ return path.join(os.tmpdir(), 'plugkit-cache', 'bin');
108
+ }
109
+
110
+ function gmToolsDir() {
111
+ const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
112
+ return path.join(home, '.claude', 'gm-tools');
113
+ }
114
+
115
+ // Copy the freshly-resolved plugkit binary + its version+sha manifests to
116
+ // ~/.claude/gm-tools so hooks.json can invoke plugkit directly without going
117
+ // through node. Self-update inside the Rust binary keeps gm-tools fresh from
118
+ // here on. Skipped silently on any error — the next session-start hook will
119
+ // retry via ensure_tools_current.
120
+ function killHoldersOfPath(targetPath) {
121
+ if (process.platform !== 'win32') return 0;
122
+ try {
123
+ const { spawnSync } = require('child_process');
124
+ const norm = path.resolve(targetPath).replace(/\//g, '\\');
125
+ const r = spawnSync('wmic', ['process', 'where', `ExecutablePath='${norm.replace(/\\/g, '\\\\')}'`, 'get', 'ProcessId', '/format:value'], { encoding: 'utf8', windowsHide: true, timeout: 5000 });
126
+ if (r.status !== 0 || !r.stdout) return 0;
127
+ const pids = [];
128
+ for (const line of r.stdout.split(/\r?\n/)) {
129
+ const m = line.match(/ProcessId=(\d+)/);
130
+ if (m) {
131
+ const pid = parseInt(m[1], 10);
132
+ if (Number.isFinite(pid) && pid !== process.pid) pids.push(pid);
133
+ }
134
+ }
135
+ for (const pid of pids) {
136
+ try { spawnSync('taskkill', ['/F', '/PID', String(pid)], { windowsHide: true, timeout: 3000 }); } catch (_) {}
137
+ }
138
+ return pids.length;
139
+ } catch (_) { return 0; }
140
+ }
141
+
142
+ function cleanOrphanNewFiles(dst, exeName) {
143
+ try {
144
+ for (const name of fs.readdirSync(dst)) {
145
+ if (name === exeName + '.new') continue;
146
+ if (/^plugkit(\.\d+\.\d+\.\d+)?\.new$/i.test(name)) {
147
+ try { fs.unlinkSync(path.join(dst, name)); } catch (_) {}
148
+ }
149
+ }
150
+ } catch (_) {}
151
+ }
152
+
153
+ function renameWithRetry(src, dst, attempts) {
154
+ for (let i = 0; i < attempts; i++) {
155
+ try { fs.renameSync(src, dst); return true; }
156
+ catch (err) {
157
+ if (err.code !== 'EEXIST' && err.code !== 'EPERM' && err.code !== 'EBUSY' && err.code !== 'EACCES') throw err;
158
+ if (i === Math.floor(attempts / 2)) killHoldersOfPath(dst);
159
+ try { const { spawnSync } = require('child_process'); spawnSync(process.execPath, ['-e', 'setTimeout(()=>{}, 200)'], { timeout: 400, killSignal: 'SIGKILL', stdio: 'ignore', windowsHide: true }); } catch (_) {}
160
+ }
161
+ }
162
+ return false;
163
+ }
164
+
165
+ function copyToGmTools(finalPath, wrapperDir, version) {
166
+ const dst = gmToolsDir();
167
+ fs.mkdirSync(dst, { recursive: true });
168
+ const exeName = process.platform === 'win32' ? 'plugkit.exe' : 'plugkit';
169
+ const target = path.join(dst, exeName);
170
+ const targetTmp = target + '.new';
171
+ cleanOrphanNewFiles(dst, exeName);
172
+ fs.copyFileSync(finalPath, targetTmp);
173
+ if (!renameWithRetry(targetTmp, target, 8)) {
174
+ obsEvent('bootstrap', 'gmtools.rename.failed', { target });
175
+ throw new Error(`gm-tools update blocked: cannot replace ${target} (held open by running plugkit and kill-retry exhausted)`);
176
+ }
177
+ if (process.platform !== 'win32') {
178
+ try { fs.chmodSync(target, 0o755); } catch (_) {}
179
+ }
180
+ fs.writeFileSync(path.join(dst, 'plugkit.version'), version);
181
+ try {
182
+ const srcSha = path.join(wrapperDir, 'plugkit.sha256');
183
+ if (fs.existsSync(srcSha)) fs.copyFileSync(srcSha, path.join(dst, 'plugkit.sha256'));
184
+ } catch (_) {}
185
+ }
186
+
187
+ function ensureDir(dir) {
188
+ fs.mkdirSync(dir, { recursive: true });
189
+ }
190
+
191
+ function readVersionFile(wrapperDir) {
192
+ const p = path.join(wrapperDir, 'plugkit.version');
193
+ if (!fs.existsSync(p)) throw new Error(`plugkit.version not found at ${p}`);
194
+ return fs.readFileSync(p, 'utf8').trim();
195
+ }
196
+
197
+ function readRtkVersion(wrapperDir) {
198
+ const p = path.join(wrapperDir, 'rtk.version');
199
+ if (!fs.existsSync(p)) return null;
200
+ const v = fs.readFileSync(p, 'utf8').trim();
201
+ return v || null;
202
+ }
203
+
204
+ function sha256OfFileSync(filePath) {
205
+ const h = crypto.createHash('sha256');
206
+ const fd = fs.openSync(filePath, 'r');
207
+ try {
208
+ const buf = Buffer.alloc(1024 * 1024);
209
+ for (;;) {
210
+ const n = fs.readSync(fd, buf, 0, buf.length, null);
211
+ if (n <= 0) break;
212
+ h.update(buf.subarray(0, n));
213
+ }
214
+ } finally { try { fs.closeSync(fd); } catch (_) {} }
215
+ return h.digest('hex');
216
+ }
217
+
218
+ function healIfShaMatches(binPath, expectedSha, sentinelPath, partialPath, kind) {
219
+ if (!fs.existsSync(binPath)) return false;
220
+ if (partialPath) { try { if (fs.existsSync(partialPath)) fs.unlinkSync(partialPath); } catch (_) {} }
221
+ if (!expectedSha) return false;
222
+ let got;
223
+ try { got = sha256OfFileSync(binPath); }
224
+ catch (_) { return false; }
225
+ if (got !== expectedSha) {
226
+ try { fs.unlinkSync(binPath); } catch (_) {}
227
+ return false;
228
+ }
229
+ try { fs.writeFileSync(sentinelPath, new Date().toISOString()); } catch (_) { return false; }
230
+ obsEvent('bootstrap', 'cache.heal', { path: binPath, kind });
231
+ return true;
232
+ }
233
+
234
+ function readShaManifest(wrapperDir, manifestName) {
235
+ const p = path.join(wrapperDir, manifestName || 'plugkit.sha256');
236
+ if (!fs.existsSync(p)) return null;
237
+ const out = {};
238
+ for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
239
+ const m = line.match(/^([0-9a-f]{64})\s+(\S+)\s*$/i);
240
+ if (m) out[m[2]] = m[1].toLowerCase();
241
+ }
242
+ return out;
243
+ }
244
+
245
+ function pidAlive(pid) {
246
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
247
+ }
248
+
249
+ function acquireLock(lockPath) {
250
+ const start = Date.now();
251
+ for (;;) {
252
+ try {
253
+ const fd = fs.openSync(lockPath, 'wx');
254
+ fs.writeSync(fd, String(process.pid));
255
+ fs.closeSync(fd);
256
+ return true;
257
+ } catch (err) {
258
+ if (err.code !== 'EEXIST') throw err;
259
+ let stale = false;
260
+ try {
261
+ const st = fs.statSync(lockPath);
262
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) stale = true;
263
+ const owner = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
264
+ if (Number.isFinite(owner) && owner !== process.pid && !pidAlive(owner)) stale = true;
265
+ } catch (_) { stale = true; }
266
+ if (stale) {
267
+ try { fs.unlinkSync(lockPath); } catch (_) {}
268
+ continue;
269
+ }
270
+ if (Date.now() - start > ATTEMPT_TIMEOUT_MS) throw new Error(`lock wait timeout: ${lockPath}`);
271
+ try { const { spawnSync } = require('child_process'); spawnSync(process.execPath, ['-e', 'setTimeout(()=>{}, 2000)'], { timeout: 2500, killSignal: 'SIGKILL', stdio: 'ignore', windowsHide: true }); } catch (_) {}
272
+ }
273
+ }
274
+ }
275
+
276
+ function releaseLock(lockPath) {
277
+ try { fs.unlinkSync(lockPath); } catch (_) {}
278
+ }
279
+
280
+ function sha256OfFile(filePath) {
281
+ return new Promise((resolve, reject) => {
282
+ const h = crypto.createHash('sha256');
283
+ const s = fs.createReadStream(filePath);
284
+ s.on('data', c => h.update(c));
285
+ s.on('end', () => resolve(h.digest('hex')));
286
+ s.on('error', reject);
287
+ });
288
+ }
289
+
290
+ function fetchToFile(url, destPath, expectedTotal) {
291
+ return new Promise((resolve, reject) => {
292
+ let existing = 0;
293
+ try { existing = fs.statSync(destPath).size; } catch (_) {}
294
+ const headers = { 'User-Agent': 'plugkit-bootstrap', 'Accept': '*/*' };
295
+ if (existing > 0) headers['Range'] = `bytes=${existing}-`;
296
+
297
+ const u = new URL(url);
298
+ const req = https.request({
299
+ method: 'GET',
300
+ hostname: u.hostname,
301
+ path: u.pathname + u.search,
302
+ headers,
303
+ timeout: ATTEMPT_TIMEOUT_MS,
304
+ }, (res) => {
305
+ if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) {
306
+ res.resume();
307
+ return resolve(fetchToFile(res.headers.location, destPath, expectedTotal));
308
+ }
309
+ if (res.statusCode === 416) {
310
+ res.resume();
311
+ try { fs.unlinkSync(destPath); } catch (_) {}
312
+ return reject(new Error('range-not-satisfiable: cleared partial, retry'));
313
+ }
314
+ if (!(res.statusCode === 200 || res.statusCode === 206)) {
315
+ res.resume();
316
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
317
+ }
318
+ const append = res.statusCode === 206 && existing > 0;
319
+ // Ensure parent dir exists — a concurrent prune may have removed it
320
+ // between lock-acquire and now. Recreating is cheap and avoids a
321
+ // confusing ENOENT later.
322
+ try { ensureDir(path.dirname(destPath)); } catch (_) {}
323
+ const out = fs.createWriteStream(destPath, { flags: append ? 'a' : 'w' });
324
+ let bytes = append ? existing : 0;
325
+ let lastStderr = Date.now();
326
+ let lastByte = Date.now();
327
+ const fetchStart = Date.now();
328
+ const safeUrl = (() => { try { const p = new URL(url); return p.hostname + p.pathname; } catch(_) { return url.split('?')[0]; } })();
329
+ obsEvent('bootstrap', 'fetch.start', { url: safeUrl, resume_from: existing, status: res.statusCode });
330
+ const stallTimer = setInterval(() => {
331
+ if (Date.now() - lastByte > STALL_TIMEOUT_MS) {
332
+ clearInterval(stallTimer);
333
+ req.destroy(new Error(`stalled: no bytes for ${STALL_TIMEOUT_MS}ms`));
334
+ }
335
+ }, 2000);
336
+ res.on('data', c => {
337
+ bytes += c.length;
338
+ lastByte = Date.now();
339
+ if (Date.now() - lastStderr > 5000) {
340
+ const pct = expectedTotal ? ` ${Math.floor(bytes / expectedTotal * 100)}%` : '';
341
+ try { process.stderr.write(`[plugkit-bootstrap] downloading: ${(bytes / 1048576).toFixed(1)} MiB${pct}\n`); } catch (_) {}
342
+ lastStderr = Date.now();
343
+ }
344
+ });
345
+ res.pipe(out);
346
+ out.on('finish', () => {
347
+ clearInterval(stallTimer);
348
+ obsEvent('bootstrap', 'fetch.end', { url: safeUrl, bytes, dur_ms: Date.now() - fetchStart, ok: true });
349
+ out.close(() => resolve(bytes));
350
+ });
351
+ out.on('error', err => { clearInterval(stallTimer); obsEvent('bootstrap', 'fetch.end', { url: safeUrl, bytes, dur_ms: Date.now() - fetchStart, ok: false, err: String(err.message || err) }); reject(err); });
352
+ res.on('error', err => { clearInterval(stallTimer); reject(err); });
353
+ res.on('end', () => clearInterval(stallTimer));
354
+ });
355
+ req.on('timeout', () => { req.destroy(new Error(`timeout after ${ATTEMPT_TIMEOUT_MS}ms`)); });
356
+ req.on('error', reject);
357
+ req.end();
358
+ });
359
+ }
360
+
361
+ async function downloadWithRetry(url, destPath) {
362
+ let lastErr;
363
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
364
+ try {
365
+ log(`fetch attempt ${attempt}/${MAX_ATTEMPTS}: ${url}`);
366
+ await fetchToFile(url, destPath);
367
+ return;
368
+ } catch (err) {
369
+ lastErr = err;
370
+ log(`attempt ${attempt} failed: ${err.message}`);
371
+ obsEvent('bootstrap', 'fetch.attempt_failed', { url, attempt, max: MAX_ATTEMPTS, err: String(err.message || err) });
372
+ if (attempt < MAX_ATTEMPTS) {
373
+ const wait = BACKOFF_MS[attempt - 1] || 120000;
374
+ log(`backing off ${wait}ms`);
375
+ await new Promise(r => setTimeout(r, wait));
376
+ }
377
+ }
378
+ }
379
+ throw lastErr;
380
+ }
381
+
382
+ function isLockStale(lockPath) {
383
+ try {
384
+ const st = fs.statSync(lockPath);
385
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) return true;
386
+ const owner = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
387
+ if (Number.isFinite(owner) && !pidAlive(owner)) return true;
388
+ } catch (_) { return true; }
389
+ return false;
390
+ }
391
+
392
+ function pruneOldVersions(root, keepVersion, keepRtkVersion) {
393
+ try {
394
+ const entries = fs.readdirSync(root);
395
+ for (const e of entries) {
396
+ const isPlugkit = e.startsWith('v') && !e.startsWith('rtk-');
397
+ const isRtk = e.startsWith('rtk-v');
398
+ if (!isPlugkit && !isRtk) continue;
399
+ if (isPlugkit && e === `v${keepVersion}`) continue;
400
+ if (isRtk && keepRtkVersion && e === `rtk-v${keepRtkVersion}`) continue;
401
+ const dir = path.join(root, e);
402
+ const lock = path.join(dir, '.lock');
403
+ if (fs.existsSync(lock) && !isLockStale(lock)) continue;
404
+ if (fs.existsSync(lock)) { try { fs.unlinkSync(lock); } catch (_) {} }
405
+ try {
406
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 1, retryDelay: 50 });
407
+ log(`pruned ${dir}`);
408
+ } catch (err) { log(`prune skip ${dir}: ${err.message}`); }
409
+ }
410
+ } catch (_) {}
411
+ }
412
+
413
+ async function bootstrap(opts) {
414
+ opts = opts || {};
415
+ const wrapperDir = opts.wrapperDir || __dirname;
416
+ const version = opts.version || readVersionFile(wrapperDir);
417
+ const shaManifest = readShaManifest(wrapperDir);
418
+ const binName = binaryName();
419
+ const expectedSha = shaManifest ? shaManifest[binName] : null;
420
+
421
+ let root = cacheRoot();
422
+ try { ensureDir(root); }
423
+ catch (_) { root = fallbackCacheRoot(); ensureDir(root); }
424
+
425
+ const verDir = path.join(root, `v${version}`);
426
+ ensureDir(verDir);
427
+
428
+ const finalPath = path.join(verDir, binName);
429
+ const okSentinel = path.join(verDir, '.ok');
430
+ const partialPath = `${finalPath}.partial`;
431
+
432
+ if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) {
433
+ if (expectedSha) {
434
+ const actualSha = sha256OfFileSync(finalPath);
435
+ if (actualSha === expectedSha) {
436
+ obsEvent('bootstrap', 'decision.hit', { reason: 'sha-match', version, path: finalPath });
437
+ copyToGmTools(finalPath, wrapperDir, version);
438
+ clearBootstrapError();
439
+ return finalPath;
440
+ }
441
+ log(`decision: fetch reason: cache-hit-sha-mismatch (dir=v${version} expected ${expectedSha.slice(0,12)}… got ${(actualSha||'').slice(0,12)}…)`);
442
+ writeBootstrapError({
443
+ expected_version: version,
444
+ cached_version: null,
445
+ error_phase: 'cache-hit-sha-mismatch',
446
+ error_message: `cached binary at ${finalPath} sha=${actualSha} but manifest expects ${expectedSha}`,
447
+ });
448
+ try { fs.unlinkSync(finalPath); } catch (_) {}
449
+ try { fs.unlinkSync(okSentinel); } catch (_) {}
450
+ } else {
451
+ obsEvent('bootstrap', 'decision.hit', { reason: 'sentinel+no-sha-manifest', path: finalPath });
452
+ copyToGmTools(finalPath, wrapperDir, version);
453
+ clearBootstrapError();
454
+ return finalPath;
455
+ }
456
+ }
457
+
458
+ if (healIfShaMatches(finalPath, expectedSha, okSentinel, partialPath, 'plugkit')) {
459
+ obsEvent('bootstrap', 'decision.heal', { reason: 'sha-match', path: finalPath });
460
+ spawnDetachedRtkFetch(wrapperDir);
461
+ copyToGmTools(finalPath, wrapperDir, version);
462
+ clearBootstrapError();
463
+ return finalPath;
464
+ }
465
+
466
+ const lockPath = path.join(verDir, '.lock');
467
+ acquireLock(lockPath);
468
+ try {
469
+ if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) {
470
+ obsEvent('bootstrap', 'decision.hit', { reason: 'lock-race-resolved', path: finalPath });
471
+ copyToGmTools(finalPath, wrapperDir, version);
472
+ clearBootstrapError();
473
+ return finalPath;
474
+ }
475
+ if (healIfShaMatches(finalPath, expectedSha, okSentinel, partialPath, 'plugkit')) {
476
+ obsEvent('bootstrap', 'decision.heal', { reason: 'sha-match-under-lock', path: finalPath });
477
+ spawnDetachedRtkFetch(wrapperDir);
478
+ copyToGmTools(finalPath, wrapperDir, version);
479
+ clearBootstrapError();
480
+ return finalPath;
481
+ }
482
+
483
+ if (fs.existsSync(partialPath)) {
484
+ try {
485
+ const st = fs.statSync(partialPath);
486
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
487
+ fs.unlinkSync(partialPath);
488
+ log(`cleared stale partial: ${partialPath}`);
489
+ }
490
+ } catch (_) {}
491
+ }
492
+ const url = `https://github.com/${RELEASE_REPO}/releases/download/v${version}/${binName}`;
493
+ try {
494
+ await downloadWithRetry(url, partialPath);
495
+ } catch (fetchErr) {
496
+ writeBootstrapError({
497
+ expected_version: version,
498
+ cached_version: null,
499
+ error_phase: 'download',
500
+ error_message: fetchErr && fetchErr.message ? fetchErr.message : String(fetchErr),
501
+ });
502
+ throw fetchErr;
503
+ }
504
+
505
+ if (expectedSha) {
506
+ const got = await sha256OfFile(partialPath);
507
+ if (got !== expectedSha) {
508
+ try { fs.unlinkSync(partialPath); } catch (_) {}
509
+ writeBootstrapError({
510
+ expected_version: version,
511
+ cached_version: null,
512
+ error_phase: 'sha256-mismatch',
513
+ error_message: `sha256 mismatch for ${binName}: expected ${expectedSha}, got ${got}`,
514
+ });
515
+ throw new Error(`sha256 mismatch for ${binName}: expected ${expectedSha}, got ${got}`);
516
+ }
517
+ log('sha256 verified');
518
+ } else {
519
+ log('no sha256 manifest — skipping verify');
520
+ }
521
+
522
+ try { fs.renameSync(partialPath, finalPath); }
523
+ catch (err) {
524
+ if (err.code === 'EEXIST' || err.code === 'EPERM') {
525
+ try { fs.unlinkSync(finalPath); } catch (_) {}
526
+ fs.renameSync(partialPath, finalPath);
527
+ } else throw err;
528
+ }
529
+
530
+ if (os.platform() !== 'win32') {
531
+ try { fs.chmodSync(finalPath, 0o755); } catch (_) {}
532
+ }
533
+
534
+ fs.writeFileSync(okSentinel, new Date().toISOString());
535
+ log(`decision: fetch reason: install-complete (${finalPath})`);
536
+ obsEvent('bootstrap', 'install.done', { path: finalPath, version, kind: 'plugkit' });
537
+ proactiveKillForNewInstall(version, finalPath);
538
+ pruneOldVersions(root, version, readRtkVersion(wrapperDir));
539
+ spawnDetachedRtkFetch(wrapperDir);
540
+ copyToGmTools(finalPath, wrapperDir, version);
541
+ clearBootstrapError();
542
+ return finalPath;
543
+ } finally {
544
+ releaseLock(lockPath);
545
+ }
546
+ }
547
+
548
+ function spawnDetachedRtkFetch(wrapperDir) {
549
+ try {
550
+ const { spawn } = require('child_process');
551
+ const child = spawn(process.execPath, [__filename, '--rtk-only', '--wrapper-dir', wrapperDir], {
552
+ detached: true,
553
+ stdio: 'ignore',
554
+ windowsHide: true,
555
+ });
556
+ child.unref();
557
+ obsEvent('bootstrap', 'rtk.detached.spawned', { pid: child.pid, wrapperDir });
558
+ } catch (err) {
559
+ log(`rtk detach spawn failed: ${err.message}`);
560
+ }
561
+ }
562
+
563
+ function rtkCacheDir(root, wrapperDir, plugkitVerDir) {
564
+ const rtkVer = readRtkVersion(wrapperDir);
565
+ if (!rtkVer) return plugkitVerDir;
566
+ const dir = path.join(root, `rtk-v${rtkVer}`);
567
+ ensureDir(dir);
568
+ return dir;
569
+ }
570
+
571
+ async function bootstrapRtk(plugkitVerDir, plugkitVersion, wrapperDir, silent, root) {
572
+ const rtkName = rtkBinaryName();
573
+ const cacheDir = rtkCacheDir(root || cacheRoot(), wrapperDir, plugkitVerDir);
574
+ const rtkPath = path.join(cacheDir, rtkName);
575
+ const rtkOk = path.join(cacheDir, '.rtk-ok');
576
+ if (fs.existsSync(rtkPath) && fs.existsSync(rtkOk)) {
577
+ if (!silent) log(`rtk cache hit: ${rtkPath}`);
578
+ return rtkPath;
579
+ }
580
+ const rtkSha = readShaManifest(wrapperDir, 'rtk.sha256');
581
+ const expected = rtkSha ? rtkSha[rtkName] : null;
582
+ const tmp = `${rtkPath}.partial`;
583
+ if (healIfShaMatches(rtkPath, expected, rtkOk, tmp, 'rtk')) {
584
+ if (!silent) log(`rtk cache heal (sha match): ${rtkPath}`);
585
+ return rtkPath;
586
+ }
587
+ const url = `https://github.com/${RELEASE_REPO}/releases/download/v${plugkitVersion}/${rtkName}`;
588
+ await downloadWithRetry(url, tmp);
589
+ if (expected) {
590
+ const got = await sha256OfFile(tmp);
591
+ if (got !== expected) {
592
+ try { fs.unlinkSync(tmp); } catch (_) {}
593
+ throw new Error(`rtk sha256 mismatch: expected ${expected}, got ${got}`);
594
+ }
595
+ }
596
+ try { fs.renameSync(tmp, rtkPath); }
597
+ catch (err) {
598
+ if (err.code === 'EEXIST' || err.code === 'EPERM') {
599
+ try { fs.unlinkSync(rtkPath); } catch (_) {}
600
+ fs.renameSync(tmp, rtkPath);
601
+ } else throw err;
602
+ }
603
+ if (os.platform() !== 'win32') { try { fs.chmodSync(rtkPath, 0o755); } catch (_) {} }
604
+ fs.writeFileSync(rtkOk, new Date().toISOString());
605
+ log(`installed ${rtkPath}`);
606
+ obsEvent('bootstrap', 'install.done', { path: rtkPath, plugkit_version: plugkitVersion, rtk_version: readRtkVersion(wrapperDir) || plugkitVersion, kind: 'rtk' });
607
+ return rtkPath;
608
+ }
609
+
610
+ function resolveCachedRtk(opts) {
611
+ opts = opts || {};
612
+ const wrapperDir = opts.wrapperDir || __dirname;
613
+ const version = opts.version || readVersionFile(wrapperDir);
614
+ const root = (() => {
615
+ try { const r = cacheRoot(); ensureDir(r); return r; }
616
+ catch (_) { const r = fallbackCacheRoot(); ensureDir(r); return r; }
617
+ })();
618
+ const plugkitVerDir = path.join(root, `v${version}`);
619
+ const cacheDir = rtkCacheDir(root, wrapperDir, plugkitVerDir);
620
+ const rtkPath = path.join(cacheDir, rtkBinaryName());
621
+ const rtkOk = path.join(cacheDir, '.rtk-ok');
622
+ if (fs.existsSync(rtkPath) && fs.existsSync(rtkOk)) return rtkPath;
623
+ return null;
624
+ }
625
+
626
+ function resolveCachedBinary(opts) {
627
+ opts = opts || {};
628
+ const wrapperDir = opts.wrapperDir || __dirname;
629
+ const version = opts.version || readVersionFile(wrapperDir);
630
+ const root = (() => {
631
+ try { const r = cacheRoot(); ensureDir(r); return r; }
632
+ catch (_) { const r = fallbackCacheRoot(); ensureDir(r); return r; }
633
+ })();
634
+ const verDir = path.join(root, `v${version}`);
635
+ const finalPath = path.join(verDir, binaryName());
636
+ const okSentinel = path.join(verDir, '.ok');
637
+ if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) return finalPath;
638
+ return null;
639
+ }
640
+
641
+ // ---------------------------------------------------------------------------
642
+ // Daemon kill on version change.
643
+ //
644
+ // The plugin tarball pins `plugkit.version`. When that pin advances and we
645
+ // install a newer cached binary, any long-running daemon (the runner) holds
646
+ // stale code and serves stale RPCs until killed. We track which version the
647
+ // daemon was last started under via `.daemon-version`; on every wrapper
648
+ // invocation, if the wrapper-pinned version differs, we kill the daemon so
649
+ // the next exec spawns it fresh under the new binary.
650
+ // ---------------------------------------------------------------------------
651
+
652
+ function daemonVersionSentinel() {
653
+ const root = (() => {
654
+ try { const r = cacheRoot(); ensureDir(r); return r; }
655
+ catch (_) { const r = fallbackCacheRoot(); ensureDir(r); return r; }
656
+ })();
657
+ return path.join(root, '.daemon-version');
658
+ }
659
+
660
+ function readDaemonVersion() {
661
+ try { return fs.readFileSync(daemonVersionSentinel(), 'utf8').trim(); }
662
+ catch (_) { return null; }
663
+ }
664
+
665
+ function writeDaemonVersion(v) {
666
+ try { fs.writeFileSync(daemonVersionSentinel(), String(v)); } catch (_) {}
667
+ }
668
+
669
+ function killPid(pid) {
670
+ if (!Number.isFinite(pid) || pid === process.pid || !pidAlive(pid)) return false;
671
+ try { process.kill(pid, 'SIGTERM'); }
672
+ catch (_) { try { process.kill(pid); } catch (_) {} }
673
+ if (os.platform() === 'win32' && pidAlive(pid)) {
674
+ try {
675
+ const { spawnSync } = require('child_process');
676
+ spawnSync('taskkill', ['/F', '/PID', String(pid)], { stdio: 'ignore', windowsHide: true, timeout: 3000, killSignal: 'SIGKILL' });
677
+ } catch (_) {}
678
+ }
679
+ return true;
680
+ }
681
+
682
+ function killRunningDaemons(reason) {
683
+ const tmp = os.tmpdir();
684
+ const killedPids = [];
685
+ for (const pidFile of ['glootie-runner.pid', 'plugkit-runner.pid']) {
686
+ const pidPath = path.join(tmp, pidFile);
687
+ if (!fs.existsSync(pidPath)) continue;
688
+ try {
689
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
690
+ if (killPid(pid)) {
691
+ killedPids.push(pid);
692
+ obsEvent('bootstrap', 'daemon.killed', { pid, pidFile, reason });
693
+ }
694
+ try { fs.unlinkSync(pidPath); } catch (_) {}
695
+ } catch (_) {}
696
+ }
697
+ return killedPids;
698
+ }
699
+
700
+ function listRunningPlugkitImagePaths() {
701
+ const out = [];
702
+ try {
703
+ const { spawnSync } = require('child_process');
704
+ if (os.platform() === 'win32') {
705
+ let parsed = null;
706
+ try {
707
+ const p = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', "Get-Process plugkit* -ErrorAction SilentlyContinue | Select-Object Id,Path | ConvertTo-Json -Compress"], { windowsHide: true, encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
708
+ const text = ((p && p.stdout) || '').trim();
709
+ if (text) {
710
+ const j = JSON.parse(text);
711
+ parsed = Array.isArray(j) ? j : [j];
712
+ }
713
+ } catch (_) {}
714
+ if (parsed) {
715
+ for (const item of parsed) {
716
+ if (!item) continue;
717
+ const pid = parseInt(item.Id, 10);
718
+ if (!Number.isFinite(pid)) continue;
719
+ out.push({ pid, path: (item.Path || '').trim() });
720
+ }
721
+ } else {
722
+ const r = spawnSync('tasklist', ['/FI', 'IMAGENAME eq plugkit*', '/FO', 'CSV', '/NH'], { windowsHide: true, encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
723
+ const text = (r && r.stdout) || '';
724
+ const seen = new Set();
725
+ for (const line of text.split(/\r?\n/)) {
726
+ const m = line.match(/^"([^"]+)","(\d+)"/);
727
+ if (!m) continue;
728
+ const pid = parseInt(m[2], 10);
729
+ if (!Number.isFinite(pid) || seen.has(pid)) continue;
730
+ seen.add(pid);
731
+ out.push({ pid, path: '' });
732
+ }
733
+ }
734
+ } else if (os.platform() === 'linux') {
735
+ let entries = [];
736
+ try { entries = fs.readdirSync('/proc'); } catch (_) {}
737
+ for (const e of entries) {
738
+ if (!/^\d+$/.test(e)) continue;
739
+ const pid = parseInt(e, 10);
740
+ let comm = '';
741
+ try { comm = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim(); } catch (_) { continue; }
742
+ if (!/^plugkit/i.test(comm)) continue;
743
+ let imagePath = '';
744
+ try { imagePath = fs.readlinkSync(`/proc/${pid}/exe`); } catch (_) {}
745
+ out.push({ pid, path: imagePath });
746
+ }
747
+ } else {
748
+ const r = spawnSync('ps', ['-axo', 'pid=,comm='], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
749
+ const text = (r && r.stdout) || '';
750
+ for (const line of text.split(/\r?\n/)) {
751
+ const m = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
752
+ if (!m) continue;
753
+ if (!/plugkit/i.test(m[2])) continue;
754
+ const pid = parseInt(m[1], 10);
755
+ let imagePath = '';
756
+ try {
757
+ const p = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', timeout: 3000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
758
+ imagePath = ((p && p.stdout) || '').trim().split(/\s+/)[0] || '';
759
+ } catch (_) {}
760
+ out.push({ pid, path: imagePath });
761
+ }
762
+ }
763
+ } catch (_) {}
764
+ return out;
765
+ }
766
+
767
+ function killSpoolWatcherInCwd(reason) {
768
+ try {
769
+ const pidPath = path.join(process.cwd(), '.gm', 'exec-spool', '.watcher.pid');
770
+ if (!fs.existsSync(pidPath)) return null;
771
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
772
+ if (killPid(pid)) {
773
+ obsEvent('bootstrap', 'watcher.killed', { pid, reason });
774
+ try { fs.unlinkSync(pidPath); } catch (_) {}
775
+ return pid;
776
+ }
777
+ try { fs.unlinkSync(pidPath); } catch (_) {}
778
+ } catch (_) {}
779
+ return null;
780
+ }
781
+
782
+ function proactiveKillForNewInstall(installedVersion, finalPath) {
783
+ try {
784
+ const reason = `install:v${installedVersion}`;
785
+ const target = finalPath ? path.resolve(finalPath).toLowerCase() : null;
786
+ const cacheRootNorm = (() => {
787
+ try { return path.resolve(cacheRoot()).toLowerCase(); } catch (_) { return null; }
788
+ })();
789
+ const procs = listRunningPlugkitImagePaths();
790
+ for (const { pid, path: imagePath } of procs) {
791
+ if (!Number.isFinite(pid) || pid === process.pid) continue;
792
+ if (!imagePath) continue;
793
+ const norm = path.resolve(imagePath).toLowerCase();
794
+ if (target && norm === target) continue;
795
+ if (!cacheRootNorm || !norm.startsWith(cacheRootNorm + path.sep.toLowerCase())) continue;
796
+ if (killPid(pid)) {
797
+ try { process.stderr.write(`[bootstrap] killed stale daemon pid=${pid} path=${imagePath} (current install: v${installedVersion})\n`); } catch (_) {}
798
+ obsEvent('bootstrap', 'daemon.killed', { pid, oldPath: imagePath, installedVersion, mechanism: 'process-path' });
799
+ }
800
+ }
801
+ killRunningDaemons(reason);
802
+ killSpoolWatcherInCwd(reason);
803
+ writeDaemonVersion(installedVersion);
804
+ } catch (_) {}
805
+ }
806
+
807
+ // Compare wrapper-pinned version against last-recorded daemon version. If
808
+ // they differ, kill the daemon so it respawns under the new binary.
809
+ function killStaleDaemonIfVersionChanged(wrapperDir) {
810
+ let currentVersion;
811
+ try { currentVersion = readVersionFile(wrapperDir); } catch (_) { return; }
812
+ const cached = resolveCachedBinary({ wrapperDir, version: currentVersion });
813
+ if (cached) {
814
+ proactiveKillForNewInstall(currentVersion, cached);
815
+ return;
816
+ }
817
+ const recorded = readDaemonVersion();
818
+ if (recorded === currentVersion) return;
819
+ if (recorded) killRunningDaemons(`version_change:${recorded}->${currentVersion}`);
820
+ writeDaemonVersion(currentVersion);
821
+ }
822
+
823
+ module.exports = { bootstrap, resolveCachedBinary, resolveCachedRtk, platformKey, binaryName, rtkBinaryName, cacheRoot, obsEvent, killRunningDaemons, killStaleDaemonIfVersionChanged, killSpoolWatcherInCwd, proactiveKillForNewInstall };
824
+
825
+ if (require.main === module) {
826
+ const argv = process.argv.slice(2);
827
+ if (argv.includes('--rtk-only')) {
828
+ const wIdx = argv.indexOf('--wrapper-dir');
829
+ const wrapperDir = wIdx >= 0 ? argv[wIdx + 1] : __dirname;
830
+ (async () => {
831
+ try {
832
+ const version = readVersionFile(wrapperDir);
833
+ let root = cacheRoot();
834
+ try { ensureDir(root); }
835
+ catch (_) { root = fallbackCacheRoot(); ensureDir(root); }
836
+ const verDir = path.join(root, `v${version}`);
837
+ ensureDir(verDir);
838
+ await bootstrapRtk(verDir, version, wrapperDir, true, root);
839
+ process.exit(0);
840
+ } catch (err) {
841
+ obsEvent('bootstrap', 'rtk.detached.failed', { err: String(err.message || err) });
842
+ process.exit(1);
843
+ }
844
+ })();
845
+ } else {
846
+ bootstrap({ silent: false })
847
+ .then(p => { process.stdout.write(p + '\n'); process.exit(0); })
848
+ .catch(err => {
849
+ log(`FATAL: ${err.message}`);
850
+ obsEvent('bootstrap', 'fatal', { err: String(err.message || err) });
851
+ try {
852
+ const pinned = (() => { try { return readVersionFile(__dirname); } catch (_) { return null; } })();
853
+ writeBootstrapError({
854
+ expected_version: pinned,
855
+ cached_version: null,
856
+ error_phase: 'fatal',
857
+ error_message: String(err && err.message || err),
858
+ });
859
+ } catch (_) {}
860
+ process.exit(1);
861
+ });
862
+ }
863
+ }