From 42df6f753a099eccb4c3d4efe9fac63047eb6971 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Mon, 23 Mar 2026 16:54:10 -0700 Subject: [PATCH] fix: prevent uninstall from truncating RC files with missing end marker (#2927) If the end marker (# <<< spawn <<<) is missing from .bashrc/.zshrc, cleanRcFile dropped all content after the start marker. Now detects unclosed blocks and skips the file with a warning instead of writing a truncated version. Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: L <6723574+louisgv@users.noreply.github.com> --- .../src/__tests__/cmd-uninstall-cov.test.ts | 52 +++++++++++++++++++ packages/cli/src/commands/uninstall.ts | 8 +++ 2 files changed, 60 insertions(+) diff --git a/packages/cli/src/__tests__/cmd-uninstall-cov.test.ts b/packages/cli/src/__tests__/cmd-uninstall-cov.test.ts index 07558d34..ed9beb95 100644 --- a/packages/cli/src/__tests__/cmd-uninstall-cov.test.ts +++ b/packages/cli/src/__tests__/cmd-uninstall-cov.test.ts @@ -391,6 +391,58 @@ describe("cmdUninstall", () => { expect(clack.logSuccess).toHaveBeenCalledWith("Removed:"); }); + it("preserves RC file when end marker is missing (unclosed block)", async () => { + const binaryPath = join(home, ".local", "bin", "spawn"); + fs.mkdirSync(join(home, ".local", "bin"), { + recursive: true, + }); + fs.writeFileSync(binaryPath, "#!/bin/bash\necho spawn"); + + // Remove optional dirs + const spawnDir = join(home, ".spawn"); + const configDir = join(home, ".config", "spawn"); + if (fs.existsSync(spawnDir)) { + fs.rmSync(spawnDir, { + recursive: true, + force: true, + }); + } + if (fs.existsSync(configDir)) { + fs.rmSync(configDir, { + recursive: true, + force: true, + }); + } + + // Write an RC file with start marker but NO end marker + const rcPath = join(home, ".bashrc"); + const rcContent = [ + "# existing config", + "alias ll='ls -la'", + "", + RC_MARKER_START, + 'export PATH="$HOME/.local/bin:$PATH"', + "", + "# user aliases that would be lost", + "alias gs='git status'", + ].join("\n"); + fs.writeFileSync(rcPath, rcContent); + + clack.confirm.mockResolvedValue(true); + + await cmdUninstall(); + + // File should be unchanged — unclosed block means no write + const after = fs.readFileSync(rcPath, "utf-8"); + expect(after).toBe(rcContent); + expect(after).toContain("# user aliases that would be lost"); + expect(after).toContain("alias gs='git status'"); + + // Should have warned the user + const warnCalls = clack.logWarn.mock.calls.map((c: unknown[]) => String(c[0])); + expect(warnCalls.some((msg: string) => msg.includes("missing end marker"))).toBe(true); + }); + it("shows shell RC hint when RC files were cleaned", async () => { const binaryPath = join(home, ".local", "bin", "spawn"); fs.mkdirSync(join(home, ".local", "bin"), { diff --git a/packages/cli/src/commands/uninstall.ts b/packages/cli/src/commands/uninstall.ts index 66caa33b..36785015 100644 --- a/packages/cli/src/commands/uninstall.ts +++ b/packages/cli/src/commands/uninstall.ts @@ -71,6 +71,14 @@ function cleanRcFile(rcPath: string): boolean { cleaned.push(line); } + // Safety: if insideBlock is still true, the end marker is missing. + // Abort to avoid truncating the user's shell config. + if (insideBlock) { + p.log.warn(`Spawn block in ${rcPath} is missing end marker — skipping to avoid data loss.`); + p.log.warn(`Manually remove the line "${RC_MARKER_START}" and the spawn PATH export from ${rcPath}.`); + return false; + } + if (changed) { fs.writeFileSync(rcPath, cleaned.join("\n")); }