From e0f8c70d9721b1ee7964ac00ed0551b1cd7c8367 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Fri, 7 Aug 2026 18:33:03 -0700 Subject: [PATCH 1/2] fix(cli): abort install/uninstall when existing config cannot be parsed Parse and merge into the user's Codex config instead of silently rewriting from empty on TOML/JSON errors, and apply the same fail-closed write path for supermemory.json. Co-authored-by: Cursor --- src/cli.ts | 96 ++++++++++++++++++++++++++++++++++----------------- src/config.ts | 28 ++++++++++++++- test/unit.mjs | 86 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 32 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f61ca7e..c34bea5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -60,26 +60,61 @@ const LEGACY_SUPERMEMORY_SCRIPTS = [ const SCRIPT_DIR = getScriptDir(); const DIST_HOOKS_DIR = join(SCRIPT_DIR, "hooks"); +function configParseError(filePath: string, parser: string, cause: unknown): Error { + const detail = cause instanceof Error ? cause.message : String(cause); + return new Error( + [ + `Failed to parse ${filePath}.`, + "", + `The existing configuration contains invalid ${parser}.`, + detail, + "", + "No changes were made.", + "Please fix the syntax error and rerun the command.", + ].join("\n"), + ); +} + function ensureCodexDir() { mkdirSync(CODEX_DIR, { recursive: true }); mkdirSync(SUPERMEMORY_HOOKS_DIR, { recursive: true }); } +/** Parse existing Codex config files, or throw before any install/uninstall writes. */ +function assertCodexConfigReadable(): void { + readConfigToml(); + readHooksJson(); +} + +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 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 configParseError(CODEX_HOOKS_JSON, "JSON", error); + } +} + 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 +227,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 +283,7 @@ function mergeHooksJson(add: boolean) { function install() { console.log("Installing codex-supermemory...\n"); + assertCodexConfigReadable(); ensureCodexDir(); const hadExistingConfig = existsSync(CONFIG_FILE); @@ -334,6 +362,7 @@ Optional: Enable debug logging: function uninstall() { console.log("Uninstalling codex-supermemory...\n"); + assertCodexConfigReadable(); mergeHooksJson(false); console.log(`✓ Removed hooks from ${CODEX_HOOKS_JSON}`); @@ -416,17 +445,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/src/config.ts b/src/config.ts index b0b6c85..4a05b36 100644 --- a/src/config.ts +++ b/src/config.ts @@ -70,12 +70,38 @@ function loadRawConfig(): { config: CodexSupermemoryConfig; existed: boolean } { const content = readFileSync(CONFIG_FILE, "utf-8"); return { config: JSON.parse(content) as CodexSupermemoryConfig, existed: true }; } catch { + // Soft-fail for runtime reads (hooks/skills); writers must use loadRawConfigForWrite. return { config: {}, existed: true }; } } return { config: {}, existed: false }; } +/** Parse supermemory.json for write paths — never treat invalid JSON as empty. */ +function loadRawConfigForWrite(): { config: CodexSupermemoryConfig; existed: boolean } { + if (!existsSync(CONFIG_FILE)) { + return { config: {}, existed: false }; + } + + try { + const content = readFileSync(CONFIG_FILE, "utf-8"); + return { config: JSON.parse(content) as CodexSupermemoryConfig, existed: true }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + [ + `Failed to parse ${CONFIG_FILE}.`, + "", + "The existing configuration contains invalid JSON.", + detail, + "", + "No changes were made.", + "Please fix the syntax error and rerun the command.", + ].join("\n"), + ); + } +} + const { config: fileConfig, existed: configExisted } = loadRawConfig(); function resolveCaptureEveryNTurns(config: CodexSupermemoryConfig): number { @@ -227,7 +253,7 @@ export function validateContainerTag(tag: string): string | null { /** Persist explicit recall/capture defaults for fresh installs or legacy upgrades. */ export function writeInstallDefaults(isExistingInstall: boolean): void { - const current = loadRawConfig().config; + const current = loadRawConfigForWrite().config; const next: CodexSupermemoryConfig = { ...current }; if (isExistingInstall) { diff --git a/test/unit.mjs b/test/unit.mjs index 3d4a3e2..b41f838 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -528,6 +528,92 @@ 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 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/); + assert.equal(readFileSync(configPath, "utf-8"), invalidConfig); + }); + + 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 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/); + assert.equal(readFileSync(configPath, "utf-8"), invalidConfig); + }); + + test("install aborts and preserves hooks.json when JSON parsing fails", (t) => { + const { tmpDir, codexDir } = setupCodexHome(t); + const hooksPath = join(codexDir, "hooks.json"); + const invalidHooks = '{ "hooks": { "Stop": [ }'; + writeFileSync(hooksPath, invalidHooks); + + 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/); + assert.equal(readFileSync(hooksPath, "utf-8"), invalidHooks); + }); + + test("uninstall aborts and preserves hooks.json when JSON parsing fails", (t) => { + const { tmpDir, codexDir } = setupCodexHome(t); + const hooksPath = join(codexDir, "hooks.json"); + const invalidHooks = '{ "hooks": { "Stop": [ }'; + writeFileSync(hooksPath, invalidHooks); + + 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/); + assert.equal(readFileSync(hooksPath, "utf-8"), invalidHooks); + }); + + test("install aborts and preserves supermemory.json when JSON parsing fails", (t) => { + const { tmpDir, codexDir } = setupCodexHome(t); + const supermemoryPath = join(codexDir, "supermemory.json"); + const invalidConfig = '{ "apiKey": "sm_test", '; + writeFileSync(supermemoryPath, invalidConfig); + + const result = runCli(cliBin, "install", tmpDir); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Failed to parse/); + assert.match(result.stderr, /supermemory\.json/); + assert.match(result.stderr, /No changes were made/); + assert.equal(readFileSync(supermemoryPath, "utf-8"), invalidConfig); + }); + + test("install merges into existing valid config.toml", (t) => { + const { tmpDir, configPath } = setupCodexHome(t); + writeFileSync(configPath, 'model = "gpt-5"\n\n[features]\nweb_search = true\n'); + + const result = runCli(cliBin, "install", tmpDir); + + assert.equal(result.status, 0, `install should exit 0: ${result.stderr}`); + const config = readToml(configPath); + assert.equal(config.model, "gpt-5"); + assert.equal(config.features.web_search, true); + assert.equal(config.features.codex_hooks, true); + }); }); From 5b00097c9b91af6262dee4dd959ca52d1eb32c28 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Fri, 7 Aug 2026 18:48:27 -0700 Subject: [PATCH 2/2] ci: publish to npm automatically on merges to main Replace tag-triggered publishes with a main-branch workflow that bumps the patch version, publishes, and commits the bump back. Co-authored-by: Cursor --- .github/workflows/publish.yml | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 79665bf..80bafdf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,24 +2,43 @@ name: Publish Package on: push: - tags: - - 'v*' + branches: [main] permissions: - id-token: write # Required for OIDC - contents: read + id-token: write # Required for npm OIDC trusted publishing + contents: write # Commit the version bump back to main jobs: publish: + # Skip the follow-up push from this workflow's own release commit. + if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + # Push the bump commit back to main (detached HEAD → main). + fetch-depth: 0 - uses: actions/setup-node@v6 with: - node-version: '24' - registry-url: 'https://registry.npmjs.org' + node-version: "24" + registry-url: "https://registry.npmjs.org" + - run: npm ci + + - name: Bump patch version + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + npm version patch --no-git-tag-version + VERSION="$(node -p "require('./package.json').version")" + git add package.json package-lock.json + git commit -m "chore(release): ${VERSION}" + + # Build after the bump so the embedded PLUGIN_VERSION matches the publish. - run: npm run build --if-present - run: npm test - run: npm publish + + - name: Push version bump + run: git push origin HEAD:main