gm-skill 0.1.2 → 2.0.1081
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/AGENTS.md +1 -0
- package/LICENSE +21 -0
- package/README.md +20 -84
- package/agents/gm.md +22 -0
- package/agents/memorize.md +100 -0
- package/agents/research-worker.md +36 -0
- package/agents/textprocessing.md +47 -0
- package/bin/bootstrap.js +702 -0
- package/bin/plugkit.js +136 -0
- package/bin/plugkit.sha256 +7 -0
- package/bin/plugkit.version +1 -0
- package/bin/plugkit.wasm +0 -0
- package/bin/plugkit.wasm.sha256 +1 -0
- package/bin/rtk.sha256 +6 -0
- package/bin/rtk.version +1 -0
- package/gm-plugkit/bootstrap.js +694 -0
- package/gm-plugkit/cli.js +48 -0
- package/gm-plugkit/index.js +12 -0
- package/gm-plugkit/package.json +26 -0
- package/gm-plugkit/plugkit-wasm-wrapper.js +190 -0
- package/gm-plugkit/plugkit.sha256 +6 -0
- package/gm-plugkit/plugkit.version +1 -0
- package/gm.json +27 -0
- package/lang/browser.js +45 -0
- package/lang/ssh.js +166 -0
- package/lib/browser-spool-handler.js +130 -0
- package/lib/browser.js +131 -0
- package/lib/codeinsight.js +109 -0
- package/lib/daemon-bootstrap.js +253 -132
- package/lib/git.js +0 -1
- package/lib/learning.js +169 -0
- package/lib/skill-bootstrap.js +406 -0
- package/lib/spool-dispatch.js +100 -0
- package/lib/spool.js +87 -49
- package/lib/wasm-host.js +241 -0
- package/package.json +38 -20
- package/prompts/bash-deny.txt +22 -0
- package/prompts/pre-compact.txt +21 -0
- package/prompts/prompt-submit.txt +83 -0
- package/prompts/session-start.txt +15 -0
- package/scripts/run-hook.sh +7 -0
- package/scripts/watch-cascade.js +166 -0
- package/skills/browser/SKILL.md +80 -0
- package/skills/code-search/SKILL.md +48 -0
- package/skills/create-lang-plugin/SKILL.md +121 -0
- package/skills/gm/SKILL.md +10 -49
- package/skills/gm-complete/SKILL.md +16 -87
- package/skills/gm-emit/SKILL.md +17 -50
- package/skills/gm-execute/SKILL.md +18 -69
- package/skills/gm-skill/SKILL.md +43 -0
- package/skills/gm-skill/index.js +21 -0
- package/skills/governance/SKILL.md +97 -0
- package/skills/pages/SKILL.md +208 -0
- package/skills/planning/SKILL.md +21 -97
- package/skills/research/SKILL.md +43 -0
- package/skills/ssh/SKILL.md +71 -0
- package/skills/textprocessing/SKILL.md +40 -0
- package/skills/update-docs/SKILL.md +24 -43
- package/gm-complete.SKILL.md +0 -106
- package/gm-emit.SKILL.md +0 -70
- package/gm-execute.SKILL.md +0 -88
- package/gm.SKILL.md +0 -63
- package/index.js +0 -1
- package/lib/index.js +0 -37
- package/lib/loader.js +0 -66
- package/lib/manifest.js +0 -99
- package/lib/prepare.js +0 -14
- package/planning.SKILL.md +0 -118
- package/skills/gm/index.js +0 -113
- package/skills/gm-complete/index.js +0 -118
- package/skills/gm-complete.SKILL.md +0 -106
- package/skills/gm-emit/index.js +0 -90
- package/skills/gm-emit.SKILL.md +0 -70
- package/skills/gm-execute/index.js +0 -91
- package/skills/gm-execute.SKILL.md +0 -88
- package/skills/gm.SKILL.md +0 -63
- package/skills/planning/index.js +0 -107
- package/skills/planning.SKILL.md +0 -118
- package/skills/update-docs/index.js +0 -108
- package/skills/update-docs.SKILL.md +0 -66
- package/test-build.js +0 -29
- package/test-e2e.js +0 -117
- package/test-unified.js +0 -24
- package/test.js +0 -89
- package/update-docs.SKILL.md +0 -66
package/bin/bootstrap.js
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
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 crypto = require('crypto');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const NPM_PACKAGE = '@anentrypoint/plugkit-wasm';
|
|
11
|
+
const ATTEMPT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
12
|
+
const MAX_ATTEMPTS = 3;
|
|
13
|
+
const BACKOFF_MS = [5000, 15000];
|
|
14
|
+
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
function log(msg) {
|
|
17
|
+
try { process.stderr.write(`[plugkit-bootstrap] ${msg}\n`); } catch (_) {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function probeBinaryVersion(binPath) {
|
|
21
|
+
try {
|
|
22
|
+
const { spawnSync } = require('child_process');
|
|
23
|
+
const r = spawnSync(binPath, ['--version'], { timeout: 3000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
24
|
+
if (r.error) return null;
|
|
25
|
+
const text = `${r.stdout || ''} ${r.stderr || ''}`.trim();
|
|
26
|
+
const m = text.match(/(\d+\.\d+\.\d+)/);
|
|
27
|
+
return m ? m[1] : null;
|
|
28
|
+
} catch (_) { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeBootstrapError(spec) {
|
|
32
|
+
try {
|
|
33
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
34
|
+
const spoolDir = path.join(projectDir, '.gm', 'exec-spool');
|
|
35
|
+
fs.mkdirSync(spoolDir, { recursive: true });
|
|
36
|
+
const out = path.join(spoolDir, '.bootstrap-error.json');
|
|
37
|
+
fs.writeFileSync(out, JSON.stringify({ ts: new Date().toISOString(), ...spec }, null, 2));
|
|
38
|
+
} catch (_) {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clearBootstrapError() {
|
|
42
|
+
try {
|
|
43
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
44
|
+
const out = path.join(projectDir, '.gm', 'exec-spool', '.bootstrap-error.json');
|
|
45
|
+
fs.unlinkSync(out);
|
|
46
|
+
} catch (_) {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function obsEvent(subsystem, event, fields) {
|
|
50
|
+
if (process.env.GM_LOG_DISABLE) return;
|
|
51
|
+
try {
|
|
52
|
+
const root = process.env.GM_LOG_DIR
|
|
53
|
+
|| path.join(os.homedir(), '.claude', 'gm-log');
|
|
54
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
55
|
+
const dir = path.join(root, day);
|
|
56
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
57
|
+
const line = JSON.stringify({
|
|
58
|
+
ts: new Date().toISOString(),
|
|
59
|
+
sub: subsystem,
|
|
60
|
+
event,
|
|
61
|
+
pid: process.pid,
|
|
62
|
+
sess: process.env.CLAUDE_SESSION_ID || process.env.GM_SESSION_ID || '',
|
|
63
|
+
...fields,
|
|
64
|
+
});
|
|
65
|
+
fs.appendFileSync(path.join(dir, `${subsystem}.jsonl`), line + '\n');
|
|
66
|
+
} catch (_) {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
function rtkBinaryName() {
|
|
71
|
+
const key = platformKey();
|
|
72
|
+
return key.startsWith('win32') ? `rtk-${key}.exe` : `rtk-${key}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cacheRoot() {
|
|
76
|
+
const home = os.homedir();
|
|
77
|
+
if (process.env.PLUGKIT_CACHE_DIR) return process.env.PLUGKIT_CACHE_DIR;
|
|
78
|
+
if (os.platform() === 'win32') {
|
|
79
|
+
const base = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
80
|
+
return path.join(base, 'plugkit', 'bin');
|
|
81
|
+
}
|
|
82
|
+
if (os.platform() === 'darwin') return path.join(home, 'Library', 'Caches', 'plugkit', 'bin');
|
|
83
|
+
const xdg = process.env.XDG_CACHE_HOME || path.join(home, '.cache');
|
|
84
|
+
return path.join(xdg, 'plugkit', 'bin');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fallbackCacheRoot() {
|
|
88
|
+
return path.join(os.tmpdir(), 'plugkit-cache', 'bin');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function gmToolsDir() {
|
|
92
|
+
const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
|
|
93
|
+
return path.join(home, '.claude', 'gm-tools');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Copy the freshly-resolved plugkit binary + its version+sha manifests to
|
|
97
|
+
// ~/.claude/gm-tools so hooks.json can invoke plugkit directly without going
|
|
98
|
+
// through node. Self-update inside the Rust binary keeps gm-tools fresh from
|
|
99
|
+
// here on. Skipped silently on any error — the next session-start hook will
|
|
100
|
+
// retry via ensure_tools_current.
|
|
101
|
+
|
|
102
|
+
function copyWasmToGmTools(wasmPath, wrapperDir, version) {
|
|
103
|
+
const dst = gmToolsDir();
|
|
104
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
105
|
+
const target = path.join(dst, 'plugkit.wasm');
|
|
106
|
+
if (fs.existsSync(target)) {
|
|
107
|
+
try {
|
|
108
|
+
const cur = sha256OfFileSync(target);
|
|
109
|
+
const src = sha256OfFileSync(wasmPath);
|
|
110
|
+
if (cur === src) {
|
|
111
|
+
try { fs.writeFileSync(path.join(dst, 'plugkit.version'), version); } catch (_) {}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
} catch (_) {}
|
|
115
|
+
}
|
|
116
|
+
fs.copyFileSync(wasmPath, target);
|
|
117
|
+
fs.writeFileSync(path.join(dst, 'plugkit.version'), version);
|
|
118
|
+
try {
|
|
119
|
+
const srcSha = path.join(wrapperDir, 'plugkit.sha256');
|
|
120
|
+
if (fs.existsSync(srcSha)) fs.copyFileSync(srcSha, path.join(dst, 'plugkit.sha256'));
|
|
121
|
+
} catch (_) {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ensureDir(dir) {
|
|
125
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readVersionFile(wrapperDir) {
|
|
129
|
+
const p = path.join(wrapperDir, 'plugkit.version');
|
|
130
|
+
if (!fs.existsSync(p)) throw new Error(`plugkit.version not found at ${p}`);
|
|
131
|
+
return fs.readFileSync(p, 'utf8').trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function readRtkVersion(wrapperDir) {
|
|
135
|
+
const p = path.join(wrapperDir, 'rtk.version');
|
|
136
|
+
if (!fs.existsSync(p)) return null;
|
|
137
|
+
const v = fs.readFileSync(p, 'utf8').trim();
|
|
138
|
+
return v || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sha256OfFileSync(filePath) {
|
|
142
|
+
const h = crypto.createHash('sha256');
|
|
143
|
+
const fd = fs.openSync(filePath, 'r');
|
|
144
|
+
try {
|
|
145
|
+
const buf = Buffer.alloc(1024 * 1024);
|
|
146
|
+
for (;;) {
|
|
147
|
+
const n = fs.readSync(fd, buf, 0, buf.length, null);
|
|
148
|
+
if (n <= 0) break;
|
|
149
|
+
h.update(buf.subarray(0, n));
|
|
150
|
+
}
|
|
151
|
+
} finally { try { fs.closeSync(fd); } catch (_) {} }
|
|
152
|
+
return h.digest('hex');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function healIfShaMatches(binPath, expectedSha, sentinelPath, partialPath, kind) {
|
|
156
|
+
if (!fs.existsSync(binPath)) return false;
|
|
157
|
+
if (partialPath) { try { if (fs.existsSync(partialPath)) fs.unlinkSync(partialPath); } catch (_) {} }
|
|
158
|
+
if (!expectedSha) return false;
|
|
159
|
+
let got;
|
|
160
|
+
try { got = sha256OfFileSync(binPath); }
|
|
161
|
+
catch (_) { return false; }
|
|
162
|
+
if (got !== expectedSha) {
|
|
163
|
+
try { fs.unlinkSync(binPath); } catch (_) {}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
try { fs.writeFileSync(sentinelPath, new Date().toISOString()); } catch (_) { return false; }
|
|
167
|
+
obsEvent('bootstrap', 'cache.heal', { path: binPath, kind });
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function readShaManifest(wrapperDir, manifestName) {
|
|
172
|
+
const p = path.join(wrapperDir, manifestName || 'plugkit.sha256');
|
|
173
|
+
if (!fs.existsSync(p)) return null;
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
|
|
176
|
+
const m = line.match(/^([0-9a-f]{64})\s+(\S+)\s*$/i);
|
|
177
|
+
if (m) out[m[2]] = m[1].toLowerCase();
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function pidAlive(pid) {
|
|
183
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function acquireLock(lockPath) {
|
|
187
|
+
const start = Date.now();
|
|
188
|
+
for (;;) {
|
|
189
|
+
try {
|
|
190
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
191
|
+
fs.writeSync(fd, String(process.pid));
|
|
192
|
+
fs.closeSync(fd);
|
|
193
|
+
return true;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err.code !== 'EEXIST') throw err;
|
|
196
|
+
let stale = false;
|
|
197
|
+
try {
|
|
198
|
+
const st = fs.statSync(lockPath);
|
|
199
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) stale = true;
|
|
200
|
+
const owner = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
|
201
|
+
if (Number.isFinite(owner) && owner !== process.pid && !pidAlive(owner)) stale = true;
|
|
202
|
+
} catch (_) { stale = true; }
|
|
203
|
+
if (stale) {
|
|
204
|
+
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (Date.now() - start > ATTEMPT_TIMEOUT_MS) throw new Error(`lock wait timeout: ${lockPath}`);
|
|
208
|
+
try { const { spawnSync } = require('child_process'); spawnSync(process.execPath, ['-e', 'setTimeout(()=>{}, 2000)'], { timeout: 2500, killSignal: 'SIGKILL', stdio: 'ignore', windowsHide: true }); } catch (_) {}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function releaseLock(lockPath) {
|
|
214
|
+
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sha256OfFile(filePath) {
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
const h = crypto.createHash('sha256');
|
|
220
|
+
const s = fs.createReadStream(filePath);
|
|
221
|
+
s.on('data', c => h.update(c));
|
|
222
|
+
s.on('end', () => resolve(h.digest('hex')));
|
|
223
|
+
s.on('error', reject);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function extractNpmPackageWasm(destPath, version) {
|
|
228
|
+
const tempDir = path.join(path.dirname(destPath), '.npm-extract-' + Date.now());
|
|
229
|
+
try {
|
|
230
|
+
ensureDir(tempDir);
|
|
231
|
+
const startMs = Date.now();
|
|
232
|
+
log(`extracting npm package ${NPM_PACKAGE}@${version} to ${tempDir}`);
|
|
233
|
+
obsEvent('bootstrap', 'npm.extract.start', { package: NPM_PACKAGE, version });
|
|
234
|
+
|
|
235
|
+
const result = spawnSync(
|
|
236
|
+
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
|
237
|
+
[NPM_PACKAGE + '@' + version, '--prefix', tempDir],
|
|
238
|
+
{
|
|
239
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
240
|
+
timeout: ATTEMPT_TIMEOUT_MS,
|
|
241
|
+
encoding: 'utf8',
|
|
242
|
+
windowsHide: true,
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (result.error) throw result.error;
|
|
247
|
+
if (result.status !== 0) {
|
|
248
|
+
throw new Error(`npx extraction failed: ${result.stderr || result.stdout || 'unknown error'}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const nodeModulesPath = path.join(tempDir, 'node_modules', NPM_PACKAGE, 'plugkit.wasm');
|
|
252
|
+
if (!fs.existsSync(nodeModulesPath)) {
|
|
253
|
+
throw new Error(`plugkit.wasm not found in extracted npm package at ${nodeModulesPath}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fs.copyFileSync(nodeModulesPath, destPath);
|
|
257
|
+
log(`extracted ${nodeModulesPath} → ${destPath}`);
|
|
258
|
+
obsEvent('bootstrap', 'npm.extract.end', { dur_ms: Date.now() - startMs, ok: true });
|
|
259
|
+
} finally {
|
|
260
|
+
try { fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 1, retryDelay: 50 }); } catch (_) {}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function extractNpmPackageWithRetry(destPath, version) {
|
|
265
|
+
let lastErr;
|
|
266
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
267
|
+
try {
|
|
268
|
+
log(`npm extract attempt ${attempt}/${MAX_ATTEMPTS}: ${NPM_PACKAGE}@${version}`);
|
|
269
|
+
await extractNpmPackageWasm(destPath, version);
|
|
270
|
+
return;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
lastErr = err;
|
|
273
|
+
log(`attempt ${attempt} failed: ${err.message}`);
|
|
274
|
+
obsEvent('bootstrap', 'npm.extract.attempt_failed', { package: NPM_PACKAGE, attempt, max: MAX_ATTEMPTS, err: String(err.message || err) });
|
|
275
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
276
|
+
const wait = BACKOFF_MS[attempt - 1] || 120000;
|
|
277
|
+
log(`backing off ${wait}ms`);
|
|
278
|
+
await new Promise(r => setTimeout(r, wait));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
throw lastErr;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isLockStale(lockPath) {
|
|
286
|
+
try {
|
|
287
|
+
const st = fs.statSync(lockPath);
|
|
288
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) return true;
|
|
289
|
+
const owner = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
|
290
|
+
if (Number.isFinite(owner) && !pidAlive(owner)) return true;
|
|
291
|
+
} catch (_) { return true; }
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function pruneOldVersions(root, keepVersion, keepRtkVersion) {
|
|
296
|
+
try {
|
|
297
|
+
const entries = fs.readdirSync(root);
|
|
298
|
+
for (const e of entries) {
|
|
299
|
+
const isPlugkit = e.startsWith('v') && !e.startsWith('rtk-');
|
|
300
|
+
const isRtk = e.startsWith('rtk-v');
|
|
301
|
+
if (!isPlugkit && !isRtk) continue;
|
|
302
|
+
if (isPlugkit && e === `v${keepVersion}`) continue;
|
|
303
|
+
if (isRtk && keepRtkVersion && e === `rtk-v${keepRtkVersion}`) continue;
|
|
304
|
+
const dir = path.join(root, e);
|
|
305
|
+
const lock = path.join(dir, '.lock');
|
|
306
|
+
if (fs.existsSync(lock) && !isLockStale(lock)) continue;
|
|
307
|
+
if (fs.existsSync(lock)) { try { fs.unlinkSync(lock); } catch (_) {} }
|
|
308
|
+
try {
|
|
309
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 1, retryDelay: 50 });
|
|
310
|
+
log(`pruned ${dir}`);
|
|
311
|
+
} catch (err) { log(`prune skip ${dir}: ${err.message}`); }
|
|
312
|
+
}
|
|
313
|
+
} catch (_) {}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function bootstrap(opts) {
|
|
317
|
+
opts = opts || {};
|
|
318
|
+
const wrapperDir = opts.wrapperDir || __dirname;
|
|
319
|
+
const version = opts.version || readVersionFile(wrapperDir);
|
|
320
|
+
const shaManifest = readShaManifest(wrapperDir);
|
|
321
|
+
const wasmName = 'plugkit.wasm';
|
|
322
|
+
const wasmExpectedSha = shaManifest ? shaManifest[wasmName] : null;
|
|
323
|
+
|
|
324
|
+
let root = cacheRoot();
|
|
325
|
+
try { ensureDir(root); }
|
|
326
|
+
catch (_) { root = fallbackCacheRoot(); ensureDir(root); }
|
|
327
|
+
|
|
328
|
+
const verDir = path.join(root, `v${version}`);
|
|
329
|
+
ensureDir(verDir);
|
|
330
|
+
|
|
331
|
+
const wasmFinalPath = path.join(verDir, wasmName);
|
|
332
|
+
const wasmOkSentinel = path.join(verDir, '.wasm-ok');
|
|
333
|
+
const wasmPartialPath = `${wasmFinalPath}.partial`;
|
|
334
|
+
|
|
335
|
+
if (fs.existsSync(wasmFinalPath) && fs.existsSync(wasmOkSentinel)) {
|
|
336
|
+
if (wasmExpectedSha) {
|
|
337
|
+
const actualSha = sha256OfFileSync(wasmFinalPath);
|
|
338
|
+
if (actualSha === wasmExpectedSha) {
|
|
339
|
+
obsEvent('bootstrap', 'decision.hit', { reason: 'sha-match', version, path: wasmFinalPath });
|
|
340
|
+
copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
|
|
341
|
+
clearBootstrapError();
|
|
342
|
+
return wasmFinalPath;
|
|
343
|
+
}
|
|
344
|
+
log(`decision: fetch reason: cache-hit-sha-mismatch (dir=v${version} expected ${wasmExpectedSha.slice(0,12)}… got ${(actualSha||'').slice(0,12)}…)`);
|
|
345
|
+
writeBootstrapError({
|
|
346
|
+
expected_version: version,
|
|
347
|
+
cached_version: null,
|
|
348
|
+
error_phase: 'cache-hit-sha-mismatch',
|
|
349
|
+
error_message: `cached wasm at ${wasmFinalPath} sha=${actualSha} but manifest expects ${wasmExpectedSha}`,
|
|
350
|
+
});
|
|
351
|
+
try { fs.unlinkSync(wasmFinalPath); } catch (_) {}
|
|
352
|
+
try { fs.unlinkSync(wasmOkSentinel); } catch (_) {}
|
|
353
|
+
} else {
|
|
354
|
+
obsEvent('bootstrap', 'decision.hit', { reason: 'sentinel+no-sha-manifest', path: wasmFinalPath });
|
|
355
|
+
copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
|
|
356
|
+
clearBootstrapError();
|
|
357
|
+
return wasmFinalPath;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (healIfShaMatches(wasmFinalPath, wasmExpectedSha, wasmOkSentinel, wasmPartialPath, 'wasm')) {
|
|
362
|
+
obsEvent('bootstrap', 'decision.heal', { reason: 'sha-match', path: wasmFinalPath });
|
|
363
|
+
spawnDetachedRtkFetch(wrapperDir);
|
|
364
|
+
copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
|
|
365
|
+
clearBootstrapError();
|
|
366
|
+
return wasmFinalPath;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const lockPath = path.join(verDir, '.lock');
|
|
370
|
+
acquireLock(lockPath);
|
|
371
|
+
try {
|
|
372
|
+
if (fs.existsSync(wasmFinalPath) && fs.existsSync(wasmOkSentinel)) {
|
|
373
|
+
obsEvent('bootstrap', 'decision.hit', { reason: 'lock-race-resolved', path: wasmFinalPath });
|
|
374
|
+
copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
|
|
375
|
+
clearBootstrapError();
|
|
376
|
+
return wasmFinalPath;
|
|
377
|
+
}
|
|
378
|
+
if (healIfShaMatches(wasmFinalPath, wasmExpectedSha, wasmOkSentinel, wasmPartialPath, 'wasm')) {
|
|
379
|
+
obsEvent('bootstrap', 'decision.heal', { reason: 'sha-match-under-lock', path: wasmFinalPath });
|
|
380
|
+
spawnDetachedRtkFetch(wrapperDir);
|
|
381
|
+
copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
|
|
382
|
+
clearBootstrapError();
|
|
383
|
+
return wasmFinalPath;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (fs.existsSync(wasmPartialPath)) {
|
|
387
|
+
try {
|
|
388
|
+
const st = fs.statSync(wasmPartialPath);
|
|
389
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
390
|
+
fs.unlinkSync(wasmPartialPath);
|
|
391
|
+
log(`cleared stale partial: ${wasmPartialPath}`);
|
|
392
|
+
}
|
|
393
|
+
} catch (_) {}
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
await extractNpmPackageWithRetry(wasmPartialPath, version);
|
|
397
|
+
} catch (extractErr) {
|
|
398
|
+
writeBootstrapError({
|
|
399
|
+
expected_version: version,
|
|
400
|
+
cached_version: null,
|
|
401
|
+
error_phase: 'npm-extract',
|
|
402
|
+
error_message: extractErr && extractErr.message ? extractErr.message : String(extractErr),
|
|
403
|
+
});
|
|
404
|
+
throw extractErr;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (wasmExpectedSha) {
|
|
408
|
+
const got = await sha256OfFile(wasmPartialPath);
|
|
409
|
+
if (got !== wasmExpectedSha) {
|
|
410
|
+
try { fs.unlinkSync(wasmPartialPath); } catch (_) {}
|
|
411
|
+
writeBootstrapError({
|
|
412
|
+
expected_version: version,
|
|
413
|
+
cached_version: null,
|
|
414
|
+
error_phase: 'sha256-mismatch',
|
|
415
|
+
error_message: `sha256 mismatch for ${wasmName}: expected ${wasmExpectedSha}, got ${got}`,
|
|
416
|
+
});
|
|
417
|
+
throw new Error(`sha256 mismatch for ${wasmName}: expected ${wasmExpectedSha}, got ${got}`);
|
|
418
|
+
}
|
|
419
|
+
log('sha256 verified');
|
|
420
|
+
} else {
|
|
421
|
+
log('no sha256 manifest — skipping verify');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
try { fs.renameSync(wasmPartialPath, wasmFinalPath); }
|
|
425
|
+
catch (err) {
|
|
426
|
+
if (err.code === 'EEXIST' || err.code === 'EPERM') {
|
|
427
|
+
try { fs.unlinkSync(wasmFinalPath); } catch (_) {}
|
|
428
|
+
fs.renameSync(wasmPartialPath, wasmFinalPath);
|
|
429
|
+
} else throw err;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
fs.writeFileSync(wasmOkSentinel, new Date().toISOString());
|
|
433
|
+
log(`decision: fetch reason: install-complete (${wasmFinalPath})`);
|
|
434
|
+
obsEvent('bootstrap', 'install.done', { path: wasmFinalPath, version, kind: 'wasm' });
|
|
435
|
+
pruneOldVersions(root, version, readRtkVersion(wrapperDir));
|
|
436
|
+
spawnDetachedRtkFetch(wrapperDir);
|
|
437
|
+
copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
|
|
438
|
+
|
|
439
|
+
clearBootstrapError();
|
|
440
|
+
return wasmFinalPath;
|
|
441
|
+
} finally {
|
|
442
|
+
releaseLock(lockPath);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function spawnDetachedRtkFetch(wrapperDir) {
|
|
447
|
+
try {
|
|
448
|
+
const { spawn } = require('child_process');
|
|
449
|
+
const child = spawn(process.execPath, [__filename, '--rtk-only', '--wrapper-dir', wrapperDir], {
|
|
450
|
+
detached: true,
|
|
451
|
+
stdio: 'ignore',
|
|
452
|
+
windowsHide: true,
|
|
453
|
+
});
|
|
454
|
+
child.unref();
|
|
455
|
+
obsEvent('bootstrap', 'rtk.detached.spawned', { pid: child.pid, wrapperDir });
|
|
456
|
+
} catch (err) {
|
|
457
|
+
log(`rtk detach spawn failed: ${err.message}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function rtkCacheDir(root, wrapperDir, plugkitVerDir) {
|
|
462
|
+
const rtkVer = readRtkVersion(wrapperDir);
|
|
463
|
+
if (!rtkVer) return plugkitVerDir;
|
|
464
|
+
const dir = path.join(root, `rtk-v${rtkVer}`);
|
|
465
|
+
ensureDir(dir);
|
|
466
|
+
return dir;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function bootstrapRtk(plugkitVerDir, plugkitVersion, wrapperDir, silent, root) {
|
|
470
|
+
const rtkName = rtkBinaryName();
|
|
471
|
+
const cacheDir = rtkCacheDir(root || cacheRoot(), wrapperDir, plugkitVerDir);
|
|
472
|
+
const rtkPath = path.join(cacheDir, rtkName);
|
|
473
|
+
const rtkOk = path.join(cacheDir, '.rtk-ok');
|
|
474
|
+
if (fs.existsSync(rtkPath) && fs.existsSync(rtkOk)) {
|
|
475
|
+
if (!silent) log(`rtk cache hit: ${rtkPath}`);
|
|
476
|
+
return rtkPath;
|
|
477
|
+
}
|
|
478
|
+
const rtkSha = readShaManifest(wrapperDir, 'rtk.sha256');
|
|
479
|
+
const expected = rtkSha ? rtkSha[rtkName] : null;
|
|
480
|
+
const tmp = `${rtkPath}.partial`;
|
|
481
|
+
if (healIfShaMatches(rtkPath, expected, rtkOk, tmp, 'rtk')) {
|
|
482
|
+
if (!silent) log(`rtk cache heal (sha match): ${rtkPath}`);
|
|
483
|
+
return rtkPath;
|
|
484
|
+
}
|
|
485
|
+
const RTKS_RELEASE_REPO = 'AnEntrypoint/plugkit-bin';
|
|
486
|
+
const url = `https://github.com/${RTKS_RELEASE_REPO}/releases/download/v${plugkitVersion}/${rtkName}`;
|
|
487
|
+
const startMs = Date.now();
|
|
488
|
+
let lastErr;
|
|
489
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
490
|
+
try {
|
|
491
|
+
log(`rtk download attempt ${attempt}/${MAX_ATTEMPTS}: ${url}`);
|
|
492
|
+
const result = spawnSync(
|
|
493
|
+
'curl',
|
|
494
|
+
['-fSL', '--max-time', String(Math.floor(ATTEMPT_TIMEOUT_MS / 1000)), '-o', tmp, url],
|
|
495
|
+
{ stdio: 'pipe', timeout: ATTEMPT_TIMEOUT_MS + 5000, windowsHide: true }
|
|
496
|
+
);
|
|
497
|
+
if (result.error) throw result.error;
|
|
498
|
+
if (result.status !== 0) throw new Error(`curl failed with status ${result.status}`);
|
|
499
|
+
break;
|
|
500
|
+
} catch (err) {
|
|
501
|
+
lastErr = err;
|
|
502
|
+
log(`rtk attempt ${attempt} failed: ${err.message}`);
|
|
503
|
+
obsEvent('bootstrap', 'rtk.download.attempt_failed', { attempt, max: MAX_ATTEMPTS, err: String(err.message || err) });
|
|
504
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
505
|
+
const wait = BACKOFF_MS[attempt - 1] || 120000;
|
|
506
|
+
log(`backing off ${wait}ms`);
|
|
507
|
+
await new Promise(r => setTimeout(r, wait));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (lastErr) throw lastErr;
|
|
512
|
+
if (expected) {
|
|
513
|
+
const got = await sha256OfFile(tmp);
|
|
514
|
+
if (got !== expected) {
|
|
515
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
516
|
+
throw new Error(`rtk sha256 mismatch: expected ${expected}, got ${got}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
try { fs.renameSync(tmp, rtkPath); }
|
|
520
|
+
catch (err) {
|
|
521
|
+
if (err.code === 'EEXIST' || err.code === 'EPERM') {
|
|
522
|
+
try { fs.unlinkSync(rtkPath); } catch (_) {}
|
|
523
|
+
fs.renameSync(tmp, rtkPath);
|
|
524
|
+
} else throw err;
|
|
525
|
+
}
|
|
526
|
+
if (os.platform() !== 'win32') { try { fs.chmodSync(rtkPath, 0o755); } catch (_) {} }
|
|
527
|
+
fs.writeFileSync(rtkOk, new Date().toISOString());
|
|
528
|
+
log(`installed ${rtkPath}`);
|
|
529
|
+
obsEvent('bootstrap', 'install.done', { path: rtkPath, plugkit_version: plugkitVersion, rtk_version: readRtkVersion(wrapperDir) || plugkitVersion, kind: 'rtk', dur_ms: Date.now() - startMs });
|
|
530
|
+
return rtkPath;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function resolveCachedRtk(opts) {
|
|
534
|
+
opts = opts || {};
|
|
535
|
+
const wrapperDir = opts.wrapperDir || __dirname;
|
|
536
|
+
const version = opts.version || readVersionFile(wrapperDir);
|
|
537
|
+
const root = (() => {
|
|
538
|
+
try { const r = cacheRoot(); ensureDir(r); return r; }
|
|
539
|
+
catch (_) { const r = fallbackCacheRoot(); ensureDir(r); return r; }
|
|
540
|
+
})();
|
|
541
|
+
const plugkitVerDir = path.join(root, `v${version}`);
|
|
542
|
+
const cacheDir = rtkCacheDir(root, wrapperDir, plugkitVerDir);
|
|
543
|
+
const rtkPath = path.join(cacheDir, rtkBinaryName());
|
|
544
|
+
const rtkOk = path.join(cacheDir, '.rtk-ok');
|
|
545
|
+
if (fs.existsSync(rtkPath) && fs.existsSync(rtkOk)) return rtkPath;
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function getWasmPath(opts) {
|
|
550
|
+
opts = opts || {};
|
|
551
|
+
const wrapperDir = opts.wrapperDir || __dirname;
|
|
552
|
+
const version = opts.version || readVersionFile(wrapperDir);
|
|
553
|
+
const root = (() => {
|
|
554
|
+
try { const r = cacheRoot(); ensureDir(r); return r; }
|
|
555
|
+
catch (_) { const r = fallbackCacheRoot(); ensureDir(r); return r; }
|
|
556
|
+
})();
|
|
557
|
+
const verDir = path.join(root, `v${version}`);
|
|
558
|
+
const wasmPath = path.join(verDir, 'plugkit.wasm');
|
|
559
|
+
const okSentinel = path.join(verDir, '.wasm-ok');
|
|
560
|
+
if (fs.existsSync(wasmPath) && fs.existsSync(okSentinel)) return wasmPath;
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Daemon kill on version change.
|
|
566
|
+
//
|
|
567
|
+
// The plugin tarball pins `plugkit.version`. When that pin advances and we
|
|
568
|
+
// install a newer cached binary, any long-running daemon (the runner) holds
|
|
569
|
+
// stale code and serves stale RPCs until killed. We track which version the
|
|
570
|
+
// daemon was last started under via `.daemon-version`; on every wrapper
|
|
571
|
+
// invocation, if the wrapper-pinned version differs, we kill the daemon so
|
|
572
|
+
// the next exec spawns it fresh under the new binary.
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
function daemonVersionSentinel() {
|
|
576
|
+
const root = (() => {
|
|
577
|
+
try { const r = cacheRoot(); ensureDir(r); return r; }
|
|
578
|
+
catch (_) { const r = fallbackCacheRoot(); ensureDir(r); return r; }
|
|
579
|
+
})();
|
|
580
|
+
return path.join(root, '.daemon-version');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function readDaemonVersion() {
|
|
584
|
+
try { return fs.readFileSync(daemonVersionSentinel(), 'utf8').trim(); }
|
|
585
|
+
catch (_) { return null; }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function writeDaemonVersion(v) {
|
|
589
|
+
try { fs.writeFileSync(daemonVersionSentinel(), String(v)); } catch (_) {}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function killPid(pid) {
|
|
593
|
+
if (!Number.isFinite(pid) || pid === process.pid || !pidAlive(pid)) return false;
|
|
594
|
+
try { process.kill(pid, 'SIGTERM'); }
|
|
595
|
+
catch (_) { try { process.kill(pid); } catch (_) {} }
|
|
596
|
+
if (os.platform() === 'win32' && pidAlive(pid)) {
|
|
597
|
+
try {
|
|
598
|
+
const { spawnSync } = require('child_process');
|
|
599
|
+
spawnSync('taskkill', ['/F', '/PID', String(pid)], { stdio: 'ignore', windowsHide: true, timeout: 3000, killSignal: 'SIGKILL' });
|
|
600
|
+
} catch (_) {}
|
|
601
|
+
}
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function killRunningDaemons(reason) {
|
|
606
|
+
const tmp = os.tmpdir();
|
|
607
|
+
const killedPids = [];
|
|
608
|
+
for (const pidFile of ['glootie-runner.pid', 'plugkit-runner.pid']) {
|
|
609
|
+
const pidPath = path.join(tmp, pidFile);
|
|
610
|
+
if (!fs.existsSync(pidPath)) continue;
|
|
611
|
+
try {
|
|
612
|
+
const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
|
|
613
|
+
if (killPid(pid)) {
|
|
614
|
+
killedPids.push(pid);
|
|
615
|
+
obsEvent('bootstrap', 'daemon.killed', { pid, pidFile, reason });
|
|
616
|
+
}
|
|
617
|
+
try { fs.unlinkSync(pidPath); } catch (_) {}
|
|
618
|
+
} catch (_) {}
|
|
619
|
+
}
|
|
620
|
+
return killedPids;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
function killSpoolWatcherInCwd(reason) {
|
|
625
|
+
try {
|
|
626
|
+
const pidPath = path.join(process.cwd(), '.gm', 'exec-spool', '.watcher.pid');
|
|
627
|
+
if (!fs.existsSync(pidPath)) return null;
|
|
628
|
+
const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
|
|
629
|
+
if (killPid(pid)) {
|
|
630
|
+
obsEvent('bootstrap', 'watcher.killed', { pid, reason });
|
|
631
|
+
try { fs.unlinkSync(pidPath); } catch (_) {}
|
|
632
|
+
return pid;
|
|
633
|
+
}
|
|
634
|
+
try { fs.unlinkSync(pidPath); } catch (_) {}
|
|
635
|
+
} catch (_) {}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function proactiveKillForNewInstall(installedVersion) {
|
|
640
|
+
try {
|
|
641
|
+
const reason = `install:v${installedVersion}`;
|
|
642
|
+
killRunningDaemons(reason);
|
|
643
|
+
killSpoolWatcherInCwd(reason);
|
|
644
|
+
writeDaemonVersion(installedVersion);
|
|
645
|
+
} catch (_) {}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function killStaleDaemonIfVersionChanged(wrapperDir) {
|
|
649
|
+
let currentVersion;
|
|
650
|
+
try { currentVersion = readVersionFile(wrapperDir); } catch (_) { return; }
|
|
651
|
+
const cached = getWasmPath({ wrapperDir, version: currentVersion });
|
|
652
|
+
if (cached) {
|
|
653
|
+
proactiveKillForNewInstall(currentVersion);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const recorded = readDaemonVersion();
|
|
657
|
+
if (recorded === currentVersion) return;
|
|
658
|
+
if (recorded) killRunningDaemons(`version_change:${recorded}->${currentVersion}`);
|
|
659
|
+
writeDaemonVersion(currentVersion);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
module.exports = { bootstrap, getWasmPath, resolveCachedRtk, rtkBinaryName, cacheRoot, obsEvent, killRunningDaemons, killStaleDaemonIfVersionChanged, killSpoolWatcherInCwd, proactiveKillForNewInstall };
|
|
663
|
+
|
|
664
|
+
if (require.main === module) {
|
|
665
|
+
const argv = process.argv.slice(2);
|
|
666
|
+
if (argv.includes('--rtk-only')) {
|
|
667
|
+
const wIdx = argv.indexOf('--wrapper-dir');
|
|
668
|
+
const wrapperDir = wIdx >= 0 ? argv[wIdx + 1] : __dirname;
|
|
669
|
+
(async () => {
|
|
670
|
+
try {
|
|
671
|
+
const version = readVersionFile(wrapperDir);
|
|
672
|
+
let root = cacheRoot();
|
|
673
|
+
try { ensureDir(root); }
|
|
674
|
+
catch (_) { root = fallbackCacheRoot(); ensureDir(root); }
|
|
675
|
+
const verDir = path.join(root, `v${version}`);
|
|
676
|
+
ensureDir(verDir);
|
|
677
|
+
await bootstrapRtk(verDir, version, wrapperDir, true, root);
|
|
678
|
+
process.exit(0);
|
|
679
|
+
} catch (err) {
|
|
680
|
+
obsEvent('bootstrap', 'rtk.detached.failed', { err: String(err.message || err) });
|
|
681
|
+
process.exit(1);
|
|
682
|
+
}
|
|
683
|
+
})();
|
|
684
|
+
} else {
|
|
685
|
+
bootstrap({ silent: false })
|
|
686
|
+
.then(p => { process.stdout.write(p + '\n'); process.exit(0); })
|
|
687
|
+
.catch(err => {
|
|
688
|
+
log(`FATAL: ${err.message}`);
|
|
689
|
+
obsEvent('bootstrap', 'fatal', { err: String(err.message || err) });
|
|
690
|
+
try {
|
|
691
|
+
const pinned = (() => { try { return readVersionFile(__dirname); } catch (_) { return null; } })();
|
|
692
|
+
writeBootstrapError({
|
|
693
|
+
expected_version: pinned,
|
|
694
|
+
cached_version: null,
|
|
695
|
+
error_phase: 'fatal',
|
|
696
|
+
error_message: String(err && err.message || err),
|
|
697
|
+
});
|
|
698
|
+
} catch (_) {}
|
|
699
|
+
process.exit(1);
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|