agy-context-saver 1.4.0 ā 1.6.0
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/CHANGELOG.md +61 -0
- package/README.md +48 -4
- package/assets/logo.svg +51 -0
- package/hooks.json +1 -1
- package/install.ps1 +86 -42
- package/mcp/index.js +323 -101
- package/package.json +18 -2
- package/plugin.json +9 -2
- package/rules/AGENTS.md +9 -4
- package/scripts/execution-guard-hook.mjs +347 -280
- package/scripts/install-register.mjs +178 -64
- package/scripts/provision-rtk.mjs +67 -14
- package/scripts/release-preflight.mjs +147 -0
- package/skills/context-governance/SKILL.md +26 -50
|
@@ -22,7 +22,7 @@ const fsPromises = fs.promises;
|
|
|
22
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
23
|
const repoRoot = path.resolve(__dirname, "..");
|
|
24
24
|
|
|
25
|
-
const homeDir = os.homedir();
|
|
25
|
+
const homeDir = process.env.AGY_TEST_PROFILE_DIR || os.homedir();
|
|
26
26
|
const geminiConfigDir = path.join(homeDir, ".gemini", "config");
|
|
27
27
|
const pluginsDir = path.join(geminiConfigDir, "plugins");
|
|
28
28
|
const pluginDest = path.join(pluginsDir, "agy-context-saver");
|
|
@@ -138,13 +138,62 @@ export function detectExistingInstallation() {
|
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
export function parseAndValidateJsonConfig(filePath, configName) {
|
|
142
|
+
if (!fs.existsSync(filePath)) return null;
|
|
143
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(content);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw new Error(`Configuration error: ${filePath} contains invalid JSON (${err.message}). Installation aborted to prevent data loss. Original file is unchanged.`);
|
|
149
|
+
}
|
|
150
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
151
|
+
throw new Error(`Configuration error: ${filePath} must contain a JSON object, but found ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}. Installation aborted to prevent data loss. Original file is unchanged.`);
|
|
152
|
+
}
|
|
153
|
+
if (configName === "mcp" && parsed.mcpServers !== undefined) {
|
|
154
|
+
if (typeof parsed.mcpServers !== "object" || parsed.mcpServers === null || Array.isArray(parsed.mcpServers)) {
|
|
155
|
+
throw new Error(`Configuration error: ${filePath} property "mcpServers" must be a JSON object. Installation aborted to prevent data loss. Original file is unchanged.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return parsed;
|
|
159
|
+
}
|
|
160
|
+
|
|
141
161
|
export async function runInstall(options = {}) {
|
|
142
162
|
const log = options.silent ? () => {} : console.log;
|
|
143
163
|
const warn = options.silent ? () => {} : console.warn;
|
|
144
164
|
|
|
145
165
|
const startTime = performance.now();
|
|
166
|
+
|
|
167
|
+
// Pre-flight Fail-Closed Validation: Verify existing configs BEFORE ANY DISK MUTATION OR CHECK RETURN
|
|
168
|
+
const validatedHooks = parseAndValidateJsonConfig(hooksJsonPath, "hooks");
|
|
169
|
+
const validatedMcp = parseAndValidateJsonConfig(mcpJsonPath, "mcp");
|
|
170
|
+
|
|
146
171
|
const existing = detectExistingInstallation();
|
|
147
172
|
|
|
173
|
+
// If checkOnly is requested, execute strictly read-only inspection (zero disk writes, zero network calls)
|
|
174
|
+
if (options.checkOnly) {
|
|
175
|
+
if (existing.isInstalled) {
|
|
176
|
+
log("š Existing installation detected:");
|
|
177
|
+
if (existing.details.plugin.exists) {
|
|
178
|
+
log(` ā Native Plugin Link: ${pluginDest}`);
|
|
179
|
+
}
|
|
180
|
+
if (existing.details.hook.registered) {
|
|
181
|
+
log(` ā Governor Hook: Registered in ${hooksJsonPath}`);
|
|
182
|
+
}
|
|
183
|
+
if (existing.details.mcp.registered) {
|
|
184
|
+
log(` ā Universal MCP Server: Configured in ${mcpJsonPath}`);
|
|
185
|
+
}
|
|
186
|
+
if (existing.details.schemas.exists) {
|
|
187
|
+
log(` ā Antigravity Tool Schemas: Present in ${antigravityMcpDir}`);
|
|
188
|
+
}
|
|
189
|
+
log("\n[OK] Pre-flight check complete. Installation is active.");
|
|
190
|
+
} else {
|
|
191
|
+
log("š Pre-flight check: No active installation detected (clean profile).");
|
|
192
|
+
log("\n[OK] Pre-flight check complete. Zero changes written.");
|
|
193
|
+
}
|
|
194
|
+
return existing;
|
|
195
|
+
}
|
|
196
|
+
|
|
148
197
|
if (existing.isInstalled) {
|
|
149
198
|
log("š Existing installation detected:");
|
|
150
199
|
if (existing.details.plugin.exists) {
|
|
@@ -159,11 +208,6 @@ export async function runInstall(options = {}) {
|
|
|
159
208
|
if (existing.details.schemas.exists) {
|
|
160
209
|
log(` ā Antigravity Tool Schemas: Present in ${antigravityMcpDir}`);
|
|
161
210
|
}
|
|
162
|
-
|
|
163
|
-
if (options.checkOnly) {
|
|
164
|
-
log("\n[OK] Pre-flight check complete. Installation is active.");
|
|
165
|
-
return existing;
|
|
166
|
-
}
|
|
167
211
|
log("\nā” Re-verifying and synchronizing to latest version...");
|
|
168
212
|
} else {
|
|
169
213
|
log("ā” Fresh installation of Agy-Context-Saver (Zero-Delay Mode)...");
|
|
@@ -184,17 +228,27 @@ export async function runInstall(options = {}) {
|
|
|
184
228
|
const rtkResult = await ensureRtkInstalled({ silent: options.silent, existingRtk: existing.details.rtk });
|
|
185
229
|
log(`[OK] RTK Command Optimizer verified: ${rtkResult.version} (${rtkResult.path})`);
|
|
186
230
|
} catch (err) {
|
|
187
|
-
|
|
231
|
+
if (options.allowDegraded) {
|
|
232
|
+
warn(`[WARN] RTK provisioning check encountered an issue: ${err.message}. Continuing in degraded mode.`);
|
|
233
|
+
} else {
|
|
234
|
+
throw new Error(`RTK provisioning failed: ${err.message}. To install without RTK (degraded mode), re-run with --allow-degraded.`);
|
|
235
|
+
}
|
|
188
236
|
}
|
|
189
237
|
|
|
190
238
|
// 1. Instant Native Plugin Junction / Symlink or Permanent Copy (Takes ~5ms)
|
|
191
239
|
await fsPromises.mkdir(pluginsDir, { recursive: true });
|
|
192
240
|
const isGitRepo = fs.existsSync(path.join(repoRoot, ".git"));
|
|
193
241
|
const isTempOrNpx = !isGitRepo || repoRoot.includes("_npx") || repoRoot.includes("npm-cache") || repoRoot.includes("node_modules");
|
|
242
|
+
const copyFilter = (src) => {
|
|
243
|
+
const rel = path.relative(repoRoot, src);
|
|
244
|
+
if (!rel) return true;
|
|
245
|
+
const topSegment = rel.split(path.sep)[0];
|
|
246
|
+
return ![".git", "node_modules", ".venv", "scratch", "site"].includes(topSegment);
|
|
247
|
+
};
|
|
194
248
|
|
|
195
249
|
if (!fs.existsSync(pluginDest)) {
|
|
196
|
-
if (isTempOrNpx) {
|
|
197
|
-
await fsPromises.cp(repoRoot, pluginDest, { recursive: true });
|
|
250
|
+
if (isTempOrNpx || options.forceCopyFallback) {
|
|
251
|
+
await fsPromises.cp(repoRoot, pluginDest, { recursive: true, filter: copyFilter });
|
|
198
252
|
log(`[OK] Native Antigravity Plugin installed to: ${pluginDest}`);
|
|
199
253
|
} else {
|
|
200
254
|
try {
|
|
@@ -202,14 +256,14 @@ export async function runInstall(options = {}) {
|
|
|
202
256
|
fs.symlinkSync(repoRoot, pluginDest, isWin ? "junction" : "dir");
|
|
203
257
|
log(`[OK] Native Antigravity Plugin linked: ${pluginDest} -> ${repoRoot}`);
|
|
204
258
|
} catch (err) {
|
|
205
|
-
await fsPromises.cp(repoRoot, pluginDest, { recursive: true });
|
|
259
|
+
await fsPromises.cp(repoRoot, pluginDest, { recursive: true, filter: copyFilter });
|
|
206
260
|
log(`[OK] Native Antigravity Plugin copied to: ${pluginDest}`);
|
|
207
261
|
}
|
|
208
262
|
}
|
|
209
263
|
} else {
|
|
210
|
-
if (isTempOrNpx) {
|
|
264
|
+
if (isTempOrNpx || options.forceCopyFallback) {
|
|
211
265
|
try {
|
|
212
|
-
await fsPromises.cp(repoRoot, pluginDest, { recursive: true });
|
|
266
|
+
await fsPromises.cp(repoRoot, pluginDest, { recursive: true, filter: copyFilter });
|
|
213
267
|
} catch {}
|
|
214
268
|
}
|
|
215
269
|
log(`[OK] Native Antigravity Plugin verified: ${pluginDest}`);
|
|
@@ -221,25 +275,24 @@ export async function runInstall(options = {}) {
|
|
|
221
275
|
log(`[OK] Governor hook script mirrored to: ${destHook}${hookUpdated ? "" : " (up-to-date)"}`);
|
|
222
276
|
|
|
223
277
|
// 3. Register Hook in ~/.gemini/config/hooks.json (Preserves existing hooks + creates backup)
|
|
224
|
-
let hooksConfig = {};
|
|
225
|
-
if (fs.existsSync(hooksJsonPath)) {
|
|
278
|
+
let hooksConfig = validatedHooks ? { ...validatedHooks } : {};
|
|
279
|
+
if (fs.existsSync(hooksJsonPath) && !fs.existsSync(hooksBakPath)) {
|
|
226
280
|
try {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (!fs.existsSync(hooksBakPath)) {
|
|
230
|
-
await fsPromises.copyFile(hooksJsonPath, hooksBakPath);
|
|
231
|
-
log(`[OK] Created configuration backup: ${hooksBakPath}`);
|
|
232
|
-
}
|
|
281
|
+
await fsPromises.copyFile(hooksJsonPath, hooksBakPath);
|
|
282
|
+
log(`[OK] Created configuration backup: ${hooksBakPath}`);
|
|
233
283
|
} catch (err) {
|
|
234
|
-
|
|
284
|
+
warn(`[WARN] Could not create backup of ${hooksJsonPath}: ${err.message}`);
|
|
235
285
|
}
|
|
236
286
|
}
|
|
237
287
|
|
|
238
288
|
const alreadyHadHook = Boolean(hooksConfig["execution-guard"]);
|
|
289
|
+
if (hooksConfig["agy-context-saver"]) {
|
|
290
|
+
delete hooksConfig["agy-context-saver"];
|
|
291
|
+
}
|
|
239
292
|
hooksConfig["execution-guard"] = {
|
|
240
293
|
PreToolUse: [
|
|
241
294
|
{
|
|
242
|
-
matcher: "manage_task|schedule|run_command|view_file|read_file|read_many_files|grep_search|find_by_name|list_dir",
|
|
295
|
+
matcher: "manage_task|schedule|run_command|view_file|read_file|read_many_files|grep_search|find_by_name|list_dir|replace_file_content|write_to_file",
|
|
243
296
|
hooks: [
|
|
244
297
|
{
|
|
245
298
|
type: "command",
|
|
@@ -254,18 +307,18 @@ export async function runInstall(options = {}) {
|
|
|
254
307
|
log(`[OK] Lifecycle hook ${alreadyHadHook ? "re-verified" : "registered"} in: ${hooksJsonPath}${hooksUpdated ? "" : " (up-to-date)"}`);
|
|
255
308
|
|
|
256
309
|
// 4. Register MCP Server in ~/.gemini/config/mcp_config.json (Preserves existing servers + creates backup)
|
|
257
|
-
let mcpConfig = { mcpServers: {} };
|
|
258
|
-
if (
|
|
310
|
+
let mcpConfig = validatedMcp ? { ...validatedMcp } : { mcpServers: {} };
|
|
311
|
+
if (!mcpConfig.mcpServers || typeof mcpConfig.mcpServers !== "object") {
|
|
312
|
+
mcpConfig.mcpServers = {};
|
|
313
|
+
} else {
|
|
314
|
+
mcpConfig.mcpServers = { ...mcpConfig.mcpServers };
|
|
315
|
+
}
|
|
316
|
+
if (fs.existsSync(mcpJsonPath) && !fs.existsSync(mcpBakPath)) {
|
|
259
317
|
try {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
// Create backup if none exists
|
|
263
|
-
if (!fs.existsSync(mcpBakPath)) {
|
|
264
|
-
await fsPromises.copyFile(mcpJsonPath, mcpBakPath);
|
|
265
|
-
log(`[OK] Created configuration backup: ${mcpBakPath}`);
|
|
266
|
-
}
|
|
318
|
+
await fsPromises.copyFile(mcpJsonPath, mcpBakPath);
|
|
319
|
+
log(`[OK] Created configuration backup: ${mcpBakPath}`);
|
|
267
320
|
} catch (err) {
|
|
268
|
-
|
|
321
|
+
warn(`[WARN] Could not create backup of ${mcpJsonPath}: ${err.message}`);
|
|
269
322
|
}
|
|
270
323
|
}
|
|
271
324
|
|
|
@@ -318,7 +371,7 @@ Agy-Context-Saver is the universal Model Context Protocol server and lifecycle g
|
|
|
318
371
|
|
|
319
372
|
## Available MCP Tools
|
|
320
373
|
- \`check_context_health\`: Diagnoses transcript turn counts, byte payload, and polling loops. Accepts \`transcriptPath\` or \`conversationId\`.
|
|
321
|
-
- \`subagent_brief\`:
|
|
374
|
+
- \`subagent_brief\`: Formulates scope-isolated prompts and ready-to-use invoke_subagent JSON payloads for single or multiple delegated subagents with Pointer-Over-Wire delivery contract.
|
|
322
375
|
- \`read_transcript\`: Streams recent turns from \`transcript.jsonl\` or \`transcript_full.jsonl\` formatted as clean Markdown.
|
|
323
376
|
- \`query_transcript\`: Forensic regex search and filtering engine across transcript steps with optional \`summaryOnly: true\`, and surgical step extraction via \`stepIndex\` and \`field\`.
|
|
324
377
|
- \`get_installation_status\`: Audits live health of the plugin link, hook, MCP server, tool schemas, and RTK.
|
|
@@ -351,15 +404,37 @@ Agy-Context-Saver is the universal Model Context Protocol server and lifecycle g
|
|
|
351
404
|
|
|
352
405
|
const subagentBriefSchema = {
|
|
353
406
|
name: "subagent_brief",
|
|
354
|
-
description: "Generates
|
|
407
|
+
description: "Generates scope-isolated, anti-bloat prompts and ready-to-use invoke_subagent JSON payloads for single or multiple delegated subagents. Instructs subagents to absorb intermediate file reads/searches and return only high-signal findings.",
|
|
355
408
|
parameters: {
|
|
356
409
|
type: "object",
|
|
357
410
|
properties: {
|
|
358
|
-
objective: { type: "string", description: "The core research or investigative objective." },
|
|
411
|
+
objective: { type: "string", description: "The core research or investigative objective (for single subagent)." },
|
|
412
|
+
role: { type: "string", description: "2-5 word role description (e.g. 'Comprehensive Test Engineer', 'Codebase Security Auditor'). Defaults to 'Scope-Isolated Deep Research Subagent'." },
|
|
413
|
+
instructions: { type: "string", description: "Optional step-by-step instructions or specialized directives." },
|
|
359
414
|
scopeFiles: { type: "array", items: { type: "string" }, description: "List of files or directories to investigate." },
|
|
360
|
-
expectedDeliverable: { type: "string", description: "The concise format expected back by the parent agent." }
|
|
361
|
-
|
|
362
|
-
|
|
415
|
+
expectedDeliverable: { type: "string", description: "The concise format expected back by the parent agent." },
|
|
416
|
+
typeName: { type: "string", description: "Subagent type name: 'self' (default, inherits parent tools/prompt/model) or 'research' (read-only)." },
|
|
417
|
+
model: { type: "string", enum: ["inherit", "flash_lite", "flash", "pro"], description: "Model tier: 'inherit' (default), 'flash_lite', 'flash', or 'pro'." },
|
|
418
|
+
workspace: { type: "string", enum: ["inherit", "branch", "share"], description: "Workspace isolation: 'inherit' (default), 'branch', or 'share'." },
|
|
419
|
+
subagents: {
|
|
420
|
+
type: "array",
|
|
421
|
+
description: "Optional array of subagent configurations to formulate multiple subagents at once.",
|
|
422
|
+
items: {
|
|
423
|
+
type: "object",
|
|
424
|
+
properties: {
|
|
425
|
+
role: { type: "string", description: "2-5 word role description." },
|
|
426
|
+
objective: { type: "string", description: "The core objective for this subagent." },
|
|
427
|
+
instructions: { type: "string", description: "Optional step-by-step instructions." },
|
|
428
|
+
scopeFiles: { type: "array", items: { type: "string" }, description: "Files/directories to investigate." },
|
|
429
|
+
expectedDeliverable: { type: "string", description: "Expected deliverable format." },
|
|
430
|
+
typeName: { type: "string", description: "Subagent type name: 'self' or 'research'." },
|
|
431
|
+
model: { type: "string", enum: ["inherit", "flash_lite", "flash", "pro"], description: "Model tier." },
|
|
432
|
+
workspace: { type: "string", enum: ["inherit", "branch", "share"], description: "Workspace isolation." }
|
|
433
|
+
},
|
|
434
|
+
required: ["objective"]
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
363
438
|
}
|
|
364
439
|
};
|
|
365
440
|
|
|
@@ -494,7 +569,8 @@ Agy-Context-Saver is the universal Model Context Protocol server and lifecycle g
|
|
|
494
569
|
const elapsed = (performance.now() - startTime).toFixed(1);
|
|
495
570
|
const statusVerb = existing.isInstalled ? "synchronized & up-to-date" : "completed";
|
|
496
571
|
log(`\nā Installation ${statusVerb} in ${elapsed}ms (Zero Delay)!\n`);
|
|
497
|
-
|
|
572
|
+
const postInstall = detectExistingInstallation();
|
|
573
|
+
return { ...postInstall, isInstalled: true, elapsedMs: elapsed };
|
|
498
574
|
}
|
|
499
575
|
|
|
500
576
|
export async function runUninstall(options = {}) {
|
|
@@ -557,25 +633,42 @@ export async function runUninstall(options = {}) {
|
|
|
557
633
|
|
|
558
634
|
// 6. Optional: Restore configuration files from backups if requested
|
|
559
635
|
if (options.restoreBackups) {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
636
|
+
await runRestoreBackups(options);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
log("\nā Uninstallation complete.\n");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export async function runRestoreBackups(options = {}) {
|
|
643
|
+
const log = options.silent ? () => {} : console.log;
|
|
644
|
+
const warn = options.silent ? () => {} : console.warn;
|
|
645
|
+
let restoredCount = 0;
|
|
646
|
+
|
|
647
|
+
if (fs.existsSync(hooksBakPath)) {
|
|
648
|
+
try {
|
|
649
|
+
await fsPromises.copyFile(hooksBakPath, hooksJsonPath);
|
|
650
|
+
log(`[OK] Restored hooks.json from backup: ${hooksBakPath}`);
|
|
651
|
+
restoredCount++;
|
|
652
|
+
} catch (err) {
|
|
653
|
+
warn(`[WARN] Could not restore hooks backup: ${err.message}`);
|
|
567
654
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
655
|
+
} else {
|
|
656
|
+
log(`[INFO] No hooks.json backup found at ${hooksBakPath}`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (fs.existsSync(mcpBakPath)) {
|
|
660
|
+
try {
|
|
661
|
+
await fsPromises.copyFile(mcpBakPath, mcpJsonPath);
|
|
662
|
+
log(`[OK] Restored mcp_config.json from backup: ${mcpBakPath}`);
|
|
663
|
+
restoredCount++;
|
|
664
|
+
} catch (err) {
|
|
665
|
+
warn(`[WARN] Could not restore mcp backup: ${err.message}`);
|
|
575
666
|
}
|
|
667
|
+
} else {
|
|
668
|
+
log(`[INFO] No mcp_config.json backup found at ${mcpBakPath}`);
|
|
576
669
|
}
|
|
577
670
|
|
|
578
|
-
|
|
671
|
+
return restoredCount > 0;
|
|
579
672
|
}
|
|
580
673
|
|
|
581
674
|
export function runStatus(options = {}) {
|
|
@@ -613,7 +706,8 @@ Commands & Options:
|
|
|
613
706
|
--status, status Check live integration status across all layers + RTK
|
|
614
707
|
--check, -c Run pre-flight validation check without writing changes
|
|
615
708
|
--uninstall, uninstall Remove governor hook, plugin link, and MCP server
|
|
616
|
-
--restore-backups Restore original configuration backup files (*.bak) during uninstall
|
|
709
|
+
--restore-backups Restore original configuration backup files (*.bak) (standalone or during uninstall)
|
|
710
|
+
--allow-degraded Allow installation to proceed even if RTK optimizer provisioning fails
|
|
617
711
|
--help, -h, help Show this help message
|
|
618
712
|
(no arguments) Synchronize and verify full 4-layer installation
|
|
619
713
|
`);
|
|
@@ -621,18 +715,38 @@ Commands & Options:
|
|
|
621
715
|
|
|
622
716
|
// Auto-run if executed directly as script
|
|
623
717
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
624
|
-
const
|
|
625
|
-
|
|
718
|
+
const args = process.argv.slice(2);
|
|
719
|
+
const isHelp = args.includes("--help") || args.includes("-h") || args.includes("help");
|
|
720
|
+
const isUninstall = args.includes("--uninstall") || args.includes("uninstall");
|
|
721
|
+
const isStatus = args.includes("--status") || args.includes("status");
|
|
722
|
+
const isCheck = args.includes("--check") || args.includes("-c");
|
|
723
|
+
const isRestoreBackups = args.includes("--restore-backups") || args.includes("restore-backups");
|
|
724
|
+
const allowDegraded = args.includes("--allow-degraded");
|
|
725
|
+
|
|
726
|
+
if (isHelp) {
|
|
626
727
|
printCliHelp();
|
|
627
728
|
process.exit(0);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Reject conflicting read-only and mutating flags explicitly
|
|
732
|
+
if ((isCheck || isStatus) && (isUninstall || isRestoreBackups)) {
|
|
733
|
+
console.error("Error: Read-only flags (--check, --status) cannot be combined with mutating flags (--uninstall, --restore-backups).");
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
if (isCheck && isStatus) {
|
|
737
|
+
console.error("Error: Conflicting flags: specify either --check or --status, not both.");
|
|
738
|
+
process.exit(1);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (isCheck) {
|
|
742
|
+
await runInstall({ checkOnly: true, allowDegraded });
|
|
743
|
+
} else if (isStatus) {
|
|
632
744
|
runStatus();
|
|
633
|
-
} else if (
|
|
634
|
-
await
|
|
745
|
+
} else if (isUninstall) {
|
|
746
|
+
await runUninstall({ restoreBackups: isRestoreBackups });
|
|
747
|
+
} else if (isRestoreBackups) {
|
|
748
|
+
await runRestoreBackups();
|
|
635
749
|
} else {
|
|
636
|
-
await runInstall();
|
|
750
|
+
await runInstall({ allowDegraded });
|
|
637
751
|
}
|
|
638
752
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
4
5
|
import { execSync } from "node:child_process";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
|
|
@@ -9,6 +10,26 @@ const repoRoot = path.resolve(__dirname, "..");
|
|
|
9
10
|
const binDir = path.join(repoRoot, "bin");
|
|
10
11
|
|
|
11
12
|
export const RTK_VERSION = "v0.51.0";
|
|
13
|
+
export const MIN_COMPATIBLE_RTK_VERSION = "0.50.0";
|
|
14
|
+
export const MAX_COMPATIBLE_RTK_VERSION = "0.52.0";
|
|
15
|
+
|
|
16
|
+
export const RTK_CHECKSUMS = {
|
|
17
|
+
"rtk-x86_64-pc-windows-msvc.zip": "1623e9b45d28b15122d69e7314776e1123a804224885ce07e7182fb40080f05c",
|
|
18
|
+
"rtk-aarch64-apple-darwin.tar.gz": "8817d8b71afc02ac8bf06eb24bcc41c306592ab735b68e8fee9db1ba0de7cb59",
|
|
19
|
+
"rtk-x86_64-apple-darwin.tar.gz": "bd39c8153f4147358360c7dc51665a8131cc9ee16f81f69a9402ffa500be3cc2",
|
|
20
|
+
"rtk-x86_64-unknown-linux-musl.tar.gz": "5028d3b19a8f0990d30fec9fbb07e32782bc5698e618fb1861aad8a9ccba4eb5",
|
|
21
|
+
"rtk-aarch64-unknown-linux-gnu.tar.gz": "8d6d1aad9e69b42481eda7039507d1f7ee93698f87713cecd873d287c1931632"
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function isCompatibleRtkVersion(versionStr) {
|
|
25
|
+
if (!versionStr || typeof versionStr !== "string") return false;
|
|
26
|
+
const match = versionStr.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
27
|
+
if (!match) return false;
|
|
28
|
+
const major = parseInt(match[1], 10);
|
|
29
|
+
const minor = parseInt(match[2], 10);
|
|
30
|
+
// Compatible if 0.50.x or 0.51.x (>= 0.50.0 and < 0.52.0)
|
|
31
|
+
return major === 0 && (minor === 50 || minor === 51);
|
|
32
|
+
}
|
|
12
33
|
|
|
13
34
|
export function getRtkPlatformAsset() {
|
|
14
35
|
const platform = os.platform();
|
|
@@ -32,21 +53,36 @@ export function getRtkPlatformAsset() {
|
|
|
32
53
|
return null;
|
|
33
54
|
}
|
|
34
55
|
|
|
35
|
-
export function findExistingRtk() {
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
export function findExistingRtk(options = {}) {
|
|
57
|
+
const names = os.platform() === "win32" ? ["rtk.exe", "rtk.cmd"] : ["rtk"];
|
|
58
|
+
const profileHome = process.env.AGY_TEST_PROFILE_DIR || os.homedir();
|
|
59
|
+
const pluginBinDir = path.join(profileHome, ".gemini", "config", "plugins", "agy-context-saver", "bin");
|
|
60
|
+
const candidateDirs = [binDir, pluginBinDir];
|
|
61
|
+
|
|
62
|
+
for (const dir of candidateDirs) {
|
|
63
|
+
for (const exeName of names) {
|
|
64
|
+
const localExe = path.join(dir, exeName);
|
|
65
|
+
if (fs.existsSync(localExe)) {
|
|
66
|
+
try {
|
|
67
|
+
const out = execSync(`"${localExe}" --version`, { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
68
|
+
if (isCompatibleRtkVersion(out)) {
|
|
69
|
+
return { path: localExe, version: out, isLocal: true };
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
44
74
|
}
|
|
45
75
|
|
|
46
76
|
// Check PATH
|
|
47
77
|
try {
|
|
48
|
-
const out = execSync("rtk --version", { encoding: "utf-8", timeout: 5000 }).trim();
|
|
49
|
-
|
|
78
|
+
const out = execSync("rtk --version", { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
79
|
+
if (isCompatibleRtkVersion(out)) {
|
|
80
|
+
return { path: "rtk", version: out, isLocal: false };
|
|
81
|
+
} else {
|
|
82
|
+
if (!options.silent) {
|
|
83
|
+
console.warn(`[WARN] System RTK version '${out}' is outside supported range [>=${MIN_COMPATIBLE_RTK_VERSION} <${MAX_COMPATIBLE_RTK_VERSION}]. Local pinned ${RTK_VERSION} will be required.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
50
86
|
} catch {}
|
|
51
87
|
|
|
52
88
|
return null;
|
|
@@ -54,9 +90,9 @@ export function findExistingRtk() {
|
|
|
54
90
|
|
|
55
91
|
export async function ensureRtkInstalled(options = {}) {
|
|
56
92
|
const log = options.silent ? () => {} : console.log;
|
|
57
|
-
const existing = options.existingRtk || findExistingRtk();
|
|
93
|
+
const existing = options.existingRtk || findExistingRtk(options);
|
|
58
94
|
if (existing) {
|
|
59
|
-
log(`ā RTK is already installed: ${existing.version} (${existing.path})`);
|
|
95
|
+
log(`ā RTK is already installed and verified: ${existing.version} (${existing.path})`);
|
|
60
96
|
return existing;
|
|
61
97
|
}
|
|
62
98
|
|
|
@@ -79,6 +115,23 @@ export async function ensureRtkInstalled(options = {}) {
|
|
|
79
115
|
}
|
|
80
116
|
|
|
81
117
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
118
|
+
|
|
119
|
+
// Fail-closed SHA-256 verification before writing archive to disk or extracting
|
|
120
|
+
const expectedHash = RTK_CHECKSUMS[assetInfo.asset];
|
|
121
|
+
if (!expectedHash) {
|
|
122
|
+
throw new Error(`Integrity error: No pinned SHA-256 checksum found for asset '${assetInfo.asset}'`);
|
|
123
|
+
}
|
|
124
|
+
const actualHash = crypto.createHash("sha256").update(buffer).digest("hex");
|
|
125
|
+
if (actualHash !== expectedHash) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`SHA-256 checksum verification failed for ${assetInfo.asset}!\n` +
|
|
128
|
+
` Expected: ${expectedHash}\n` +
|
|
129
|
+
` Actual: ${actualHash}\n` +
|
|
130
|
+
`Archive extraction aborted to protect system integrity.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
log(`ā Verified SHA-256 checksum: ${actualHash}`);
|
|
134
|
+
|
|
82
135
|
const archivePath = path.join(binDir, assetInfo.asset);
|
|
83
136
|
fs.writeFileSync(archivePath, buffer);
|
|
84
137
|
|
|
@@ -102,7 +155,7 @@ export async function ensureRtkInstalled(options = {}) {
|
|
|
102
155
|
try { fs.chmodSync(targetExe, 0o755); } catch {}
|
|
103
156
|
}
|
|
104
157
|
|
|
105
|
-
const verified = findExistingRtk();
|
|
158
|
+
const verified = findExistingRtk(options);
|
|
106
159
|
if (!verified) {
|
|
107
160
|
throw new Error(`RTK installation verification failed: could not run ${targetExe}`);
|
|
108
161
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Non-destructive release metadata/package preflight for Agy-Context-Saver.
|
|
4
|
+
* Run from the repository root: node scripts/release-preflight.mjs
|
|
5
|
+
* This script reads files only; it does not install the plugin or provision RTK.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const errors = [];
|
|
13
|
+
const warnings = [];
|
|
14
|
+
|
|
15
|
+
function readText(relativePath) {
|
|
16
|
+
const target = path.join(root, relativePath);
|
|
17
|
+
try {
|
|
18
|
+
return fs.readFileSync(target, 'utf8');
|
|
19
|
+
} catch {
|
|
20
|
+
errors.push(`Required file is missing or unreadable: ${relativePath}`);
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function exists(relativePath) {
|
|
26
|
+
return fs.existsSync(path.join(root, relativePath));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let pkg;
|
|
30
|
+
try {
|
|
31
|
+
pkg = JSON.parse(readText('package.json'));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
errors.push(`package.json is not valid JSON: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (pkg) {
|
|
37
|
+
if (pkg.name !== 'agy-context-saver') {
|
|
38
|
+
errors.push(`Expected package name "agy-context-saver"; found ${JSON.stringify(pkg.name)}.`);
|
|
39
|
+
}
|
|
40
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(pkg.version ?? '')) {
|
|
41
|
+
errors.push(`package.json version is not a valid semver release version: ${JSON.stringify(pkg.version)}.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const requiredMetadata = [
|
|
45
|
+
['repository.url', pkg.repository?.url],
|
|
46
|
+
['homepage', pkg.homepage],
|
|
47
|
+
['bugs.url', pkg.bugs?.url],
|
|
48
|
+
['engines.node', pkg.engines?.node],
|
|
49
|
+
];
|
|
50
|
+
for (const [label, value] of requiredMetadata) {
|
|
51
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
52
|
+
errors.push(`Add package.json metadata field "${label}" before publishing.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (pkg.engines?.node && !/^>=\s*22(?:\.0\.0)?$/.test(pkg.engines.node.trim())) {
|
|
56
|
+
warnings.push(`Current release policy expects engines.node to be ">=22"; found ${JSON.stringify(pkg.engines.node)}. Update this check if the support policy changes.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const requiredPackageFiles = [
|
|
60
|
+
'mcp/index.js',
|
|
61
|
+
'scripts/install-register.mjs',
|
|
62
|
+
'scripts/provision-rtk.mjs',
|
|
63
|
+
'plugin.json',
|
|
64
|
+
'hooks.json',
|
|
65
|
+
'mcp_config.json',
|
|
66
|
+
'README.md',
|
|
67
|
+
'CHANGELOG.md',
|
|
68
|
+
'LICENSE',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
for (const relativePath of requiredPackageFiles) {
|
|
72
|
+
if (!exists(relativePath)) errors.push(`Required runtime/release file is missing: ${relativePath}`);
|
|
73
|
+
const coveredByFiles = (pkg.files ?? []).some((entry) => {
|
|
74
|
+
const normalized = entry.replace(/\\/g, '/').replace(/\/$/, '');
|
|
75
|
+
return relativePath === normalized || relativePath.startsWith(`${normalized}/`);
|
|
76
|
+
});
|
|
77
|
+
if (!coveredByFiles) {
|
|
78
|
+
errors.push(`package.json "files" does not clearly include ${relativePath}.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const binEntries = Object.values(pkg.bin ?? {});
|
|
83
|
+
if (binEntries.length === 0) errors.push('package.json has no bin entry.');
|
|
84
|
+
for (const binPath of binEntries) {
|
|
85
|
+
if (!exists(binPath)) errors.push(`package.json bin target does not exist: ${binPath}`);
|
|
86
|
+
const coveredByFiles = (pkg.files ?? []).some((entry) => {
|
|
87
|
+
const normalized = entry.replace(/\\/g, '/').replace(/\/$/, '');
|
|
88
|
+
return binPath === normalized || binPath.startsWith(`${normalized}/`);
|
|
89
|
+
});
|
|
90
|
+
if (!coveredByFiles) errors.push(`package.json "files" does not include the bin target: ${binPath}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const testCommand = pkg.scripts?.test;
|
|
94
|
+
if (typeof testCommand !== 'string' || !testCommand.trim()) {
|
|
95
|
+
errors.push('package.json is missing the npm test script.');
|
|
96
|
+
} else {
|
|
97
|
+
const suitePaths = [...testCommand.matchAll(/(?:^|&&\s*)node\s+(tests\/[\w.-]+\.mjs)/g)].map((match) => match[1]);
|
|
98
|
+
if (suitePaths.length === 0) {
|
|
99
|
+
errors.push('Could not find test entrypoints in package.json scripts.test; update release-preflight.mjs for the new test runner format.');
|
|
100
|
+
}
|
|
101
|
+
for (const suitePath of suitePaths) {
|
|
102
|
+
if (!exists(suitePath)) errors.push(`npm test references a missing test file: ${suitePath}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const readme = readText('README.md');
|
|
106
|
+
const countMatch = readme.match(/Runs all\s+(\d+)\s+test suites?/i);
|
|
107
|
+
if (!countMatch) {
|
|
108
|
+
errors.push('README.md must state the exact suite count using a sentence such as "Runs all 8 test suites".');
|
|
109
|
+
} else if (Number(countMatch[1]) !== suitePaths.length) {
|
|
110
|
+
errors.push(`README.md says ${countMatch[1]} test suites, but package.json scripts.test runs ${suitePaths.length}.`);
|
|
111
|
+
}
|
|
112
|
+
for (const suitePath of suitePaths) {
|
|
113
|
+
if (!readme.includes(suitePath)) {
|
|
114
|
+
errors.push(`README.md does not list the test entrypoint that npm test runs: ${suitePath}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const relativePath of ['SECURITY.md', 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md']) {
|
|
120
|
+
if (!exists(relativePath)) errors.push(`Add ${relativePath} before the public release.`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const rtkProvisioner = readText('scripts/provision-rtk.mjs');
|
|
124
|
+
const rtkVersionMatch = rtkProvisioner.match(/RTK_VERSION\s*=\s*['"]([^'"]+)['"]/);
|
|
125
|
+
if (!rtkVersionMatch) {
|
|
126
|
+
errors.push('Could not locate a pinned RTK_VERSION in scripts/provision-rtk.mjs.');
|
|
127
|
+
} else {
|
|
128
|
+
console.log(`RTK provisioner pin found: ${rtkVersionMatch[1]}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!pkg.scripts?.setup || !pkg.scripts?.status || !pkg.scripts?.test) {
|
|
132
|
+
errors.push('Expected npm scripts setup, status, and test to be present.');
|
|
133
|
+
}
|
|
134
|
+
if (Object.keys(pkg.dependencies ?? {}).length > 0) {
|
|
135
|
+
warnings.push(`Runtime npm dependencies are present (${Object.keys(pkg.dependencies).join(', ')}); review whether each is required and trusted.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const warning of warnings) console.warn(`WARN: ${warning}`);
|
|
140
|
+
for (const error of errors) console.error(`FAIL: ${error}`);
|
|
141
|
+
|
|
142
|
+
if (errors.length) {
|
|
143
|
+
console.error(`\nRelease preflight failed with ${errors.length} error(s) and ${warnings.length} warning(s).`);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
} else {
|
|
146
|
+
console.log(`\nRelease preflight passed${warnings.length ? ` with ${warnings.length} warning(s)` : ''}.`);
|
|
147
|
+
}
|