From 0b7637fa4ad52b3c7b97fc7ba13ce0463aee7e5d Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Singh Date: Fri, 7 Aug 2026 23:21:33 +0530 Subject: [PATCH] fix(cli): prevent config loss when existing config cannot be parsed --- src/cli.ts | 98 +++++++++++++++++++++++++++++++++++---------------- test/unit.mjs | 77 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 32 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f61ca7e..1ce9b88 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -60,26 +60,63 @@ const LEGACY_SUPERMEMORY_SCRIPTS = [ const SCRIPT_DIR = getScriptDir(); const DIST_HOOKS_DIR = join(SCRIPT_DIR, "hooks"); +class ConfigParseError extends Error { + constructor(filePath: string, parser: string, cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + super( + [ + `Failed to parse ${filePath}.`, + "", + `The existing configuration contains invalid ${parser}.`, + message, + "", + "No changes were made.", + "Please fix the syntax error and rerun the command.", + ].join("\n"), + ); + this.name = "ConfigParseError"; + } +} + function ensureCodexDir() { mkdirSync(CODEX_DIR, { recursive: true }); mkdirSync(SUPERMEMORY_HOOKS_DIR, { recursive: true }); } +function readConfigToml(): Record { + if (!existsSync(CODEX_CONFIG_TOML)) return {}; + + try { + const content = readFileSync(CODEX_CONFIG_TOML, "utf-8"); + return TOML.parse(content) as Record; + } catch (error) { + throw new ConfigParseError(CODEX_CONFIG_TOML, "TOML", error); + } +} + +function readHooksJson(): HookEvents { + if (!existsSync(CODEX_HOOKS_JSON)) return {}; + + try { + const content = readFileSync(CODEX_HOOKS_JSON, "utf-8"); + return normalizeHookEvents(JSON.parse(content)); + } catch (error) { + throw new ConfigParseError(CODEX_HOOKS_JSON, "JSON", error); + } +} + +function validateWritableCodexConfig(): void { + readConfigToml(); + readHooksJson(); +} + function mergeConfigToml(enable: boolean) { if (!enable && !existsSync(CODEX_CONFIG_TOML)) { // Nothing to disable — file doesn't exist yet. return; } - let config: Record = {}; - if (existsSync(CODEX_CONFIG_TOML)) { - try { - const content = readFileSync(CODEX_CONFIG_TOML, "utf-8"); - config = TOML.parse(content) as Record; - } catch { - // start fresh - } - } + const config = readConfigToml(); // Toggle the codex_hooks feature flag. if (!config.features) config.features = {}; @@ -192,15 +229,7 @@ function mergeHooksJson(add: boolean) { return; } - let hooks: HookEvents = {}; - if (existsSync(CODEX_HOOKS_JSON)) { - try { - const content = readFileSync(CODEX_HOOKS_JSON, "utf-8"); - hooks = normalizeHookEvents(JSON.parse(content)); - } catch { - // start fresh - } - } + const hooks = readHooksJson(); if (add) { const recallCmd = `node ${RECALL_SCRIPT}`; @@ -256,6 +285,7 @@ function mergeHooksJson(add: boolean) { function install() { console.log("Installing codex-supermemory...\n"); + validateWritableCodexConfig(); ensureCodexDir(); const hadExistingConfig = existsSync(CONFIG_FILE); @@ -334,6 +364,7 @@ Optional: Enable debug logging: function uninstall() { console.log("Uninstalling codex-supermemory...\n"); + validateWritableCodexConfig(); mergeHooksJson(false); console.log(`✓ Removed hooks from ${CODEX_HOOKS_JSON}`); @@ -416,17 +447,22 @@ function status() { } const command = process.argv[2]; -switch (command) { - case "install": - install(); - break; - case "uninstall": - uninstall(); - break; - case "status": - status(); - break; - default: - console.log("Usage: codex-supermemory "); - process.exit(1); +try { + switch (command) { + case "install": + install(); + break; + case "uninstall": + uninstall(); + break; + case "status": + status(); + break; + default: + console.log("Usage: codex-supermemory "); + process.exit(1); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); } diff --git a/test/unit.mjs b/test/unit.mjs index 3d4a3e2..97653e0 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -6,7 +6,7 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync, statSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -39,6 +39,21 @@ function runCli(cliBin, cmd, tmpDir) { }); } +function snapshotFile(path) { + return { + content: readFileSync(path, "utf-8"), + size: statSync(path).size, + mtimeMs: statSync(path).mtimeMs, + }; +} + +function assertFileUnchanged(path, snapshot) { + const current = statSync(path); + assert.equal(readFileSync(path, "utf-8"), snapshot.content); + assert.equal(current.size, snapshot.size); + assert.equal(current.mtimeMs, snapshot.mtimeMs); +} + function readToml(path) { return TOML.parse(readFileSync(path, "utf-8")); } @@ -528,6 +543,66 @@ describe("integration: install/uninstall", () => { const config = readToml(configPath); assert.ok(!config.features, "features table should not exist after uninstall"); }); + + test("install aborts and preserves config.toml when TOML parsing fails", (t) => { + const { tmpDir, configPath } = setupCodexHome(t); + const invalidConfig = 'model = "gpt-5"\n[features\ncodex_hooks = true\n'; + writeFileSync(configPath, invalidConfig); + const before = snapshotFile(configPath); + + const result = runCli(cliBin, "install", tmpDir); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Failed to parse/); + assert.match(result.stderr, /config\.toml/); + assert.match(result.stderr, /No changes were made/); + assertFileUnchanged(configPath, before); + }); + + test("uninstall aborts and preserves config.toml when TOML parsing fails", (t) => { + const { tmpDir, configPath } = setupCodexHome(t); + const invalidConfig = 'model = "gpt-5"\n[features\ncodex_hooks = true\n'; + writeFileSync(configPath, invalidConfig); + const before = snapshotFile(configPath); + + const result = runCli(cliBin, "uninstall", tmpDir); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Failed to parse/); + assert.match(result.stderr, /config\.toml/); + assert.match(result.stderr, /No changes were made/); + assertFileUnchanged(configPath, before); + }); + + test("install aborts and preserves hooks.json when JSON parsing fails", (t) => { + const { tmpDir, codexDir } = setupCodexHome(t); + const hooksPath = join(codexDir, "hooks.json"); + writeFileSync(hooksPath, '{ "hooks": { "Stop": [ }'); + const before = snapshotFile(hooksPath); + + const result = runCli(cliBin, "install", tmpDir); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Failed to parse/); + assert.match(result.stderr, /hooks\.json/); + assert.match(result.stderr, /No changes were made/); + assertFileUnchanged(hooksPath, before); + }); + + test("uninstall aborts and preserves hooks.json when JSON parsing fails", (t) => { + const { tmpDir, codexDir } = setupCodexHome(t); + const hooksPath = join(codexDir, "hooks.json"); + writeFileSync(hooksPath, '{ "hooks": { "Stop": [ }'); + const before = snapshotFile(hooksPath); + + const result = runCli(cliBin, "uninstall", tmpDir); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Failed to parse/); + assert.match(result.stderr, /hooks\.json/); + assert.match(result.stderr, /No changes were made/); + assertFileUnchanged(hooksPath, before); + }); });