Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.12",
"version": "1.1.0",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
156 changes: 156 additions & 0 deletions packages/cli/src/__tests__/local-backup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getBackupRoot, listBackups, restoreBackups, snapshotBeforeWrite, snapshotPaths } from "../local/backup.js";

const HOME = process.env.HOME ?? "";

function reset(): void {
rmSync(getBackupRoot(), {
recursive: true,
force: true,
});
}

describe("local backup primitives", () => {
beforeEach(() => {
reset();
});

afterEach(() => {
reset();
});

it("snapshots an existing file before overwrite and restores its content", () => {
const target = join(HOME, ".claude", "settings.json");
mkdirSync(join(HOME, ".claude"), {
recursive: true,
});
writeFileSync(target, '{"theme":"light"}');

snapshotBeforeWrite(target, "claude");

// Simulate spawn overwriting the file
writeFileSync(target, '{"theme":"dark","spawn":true}');

const summary = restoreBackups();

expect(summary.restored).toContain(target);
expect(summary.removed).toEqual([]);
expect(summary.failed).toEqual([]);
expect(readFileSync(target, "utf-8")).toBe('{"theme":"light"}');
});

it("removes a file spawn created when no original existed", () => {
const target = join(HOME, ".codex", "config.toml");
expect(existsSync(target)).toBe(false);

snapshotBeforeWrite(target, "codex");

// Simulate spawn creating the file
mkdirSync(join(HOME, ".codex"), {
recursive: true,
});
writeFileSync(target, "model = openrouter/auto");

const summary = restoreBackups();

expect(summary.removed).toContain(target);
expect(summary.restored).toEqual([]);
expect(existsSync(target)).toBe(false);
});

it("is idempotent: a second snapshot does not clobber the first", () => {
const target = join(HOME, ".claude.json");
writeFileSync(target, "ORIGINAL");

snapshotBeforeWrite(target, "claude");
writeFileSync(target, "INTERMEDIATE");
snapshotBeforeWrite(target, "claude");
writeFileSync(target, "FINAL");

const summary = restoreBackups();

expect(readFileSync(target, "utf-8")).toBe("ORIGINAL");
expect(summary.restored).toEqual([
target,
]);
});

it("filters by agent when an agent is passed to restoreBackups", () => {
const claudeFile = join(HOME, ".claude", "settings.json");
const codexFile = join(HOME, ".codex", "config.toml");
mkdirSync(join(HOME, ".claude"), {
recursive: true,
});
mkdirSync(join(HOME, ".codex"), {
recursive: true,
});
writeFileSync(claudeFile, "claude-original");
writeFileSync(codexFile, "codex-original");

snapshotBeforeWrite(claudeFile, "claude");
snapshotBeforeWrite(codexFile, "codex");

writeFileSync(claudeFile, "claude-modified");
writeFileSync(codexFile, "codex-modified");

const summary = restoreBackups("claude");

expect(readFileSync(claudeFile, "utf-8")).toBe("claude-original");
expect(readFileSync(codexFile, "utf-8")).toBe("codex-modified");
expect(summary.remaining).toBe(1);

const remaining = listBackups();
expect(remaining).toHaveLength(1);
expect(remaining[0].agent).toBe("codex");
});

it("snapshotPaths walks a list and skips paths it has already snapshotted", () => {
const a = join(HOME, ".bashrc");
const b = join(HOME, ".zshrc");
writeFileSync(a, "a-original");
writeFileSync(b, "b-original");

snapshotPaths(
[
a,
b,
a,
],
"claude",
);

expect(listBackups()).toHaveLength(2);

writeFileSync(a, "a-modified");
writeFileSync(b, "b-modified");

restoreBackups();

expect(readFileSync(a, "utf-8")).toBe("a-original");
expect(readFileSync(b, "utf-8")).toBe("b-original");
});

it("treats a corrupt manifest as empty (no crash)", () => {
mkdirSync(getBackupRoot(), {
recursive: true,
});
writeFileSync(join(getBackupRoot(), "manifest.json"), "{not valid json");

expect(listBackups()).toEqual([]);
expect(() => restoreBackups()).not.toThrow();
});

it("removes the backup root once the manifest is empty", () => {
const target = join(HOME, ".claude.json");
writeFileSync(target, "ORIG");
snapshotBeforeWrite(target, "claude");

expect(existsSync(getBackupRoot())).toBe(true);

restoreBackups();

expect(existsSync(getBackupRoot())).toBe(false);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function getHelpUsageSection(): string {
spawn history export Dump history as JSON to stdout
spawn feedback "message" Send feedback to the Spawn team
spawn uninstall Uninstall spawn CLI and optionally remove data
spawn local-restore [agent] Restore local agent configs to pre-spawn state
spawn update Check for CLI updates
spawn version Show version (or --version, -v)
spawn help Show this help message (or --help, -h)`;
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export {
cmdListClear,
formatRelativeTime,
} from "./list.js";
// local-restore.ts — cmdLocalRestore (revert local agent config writes)
export { cmdLocalRestore } from "./local-restore.js";
// pick.ts — cmdPick
export { cmdPick } from "./pick.js";
// pull-history.ts — cmdPullHistory (recursive child history pull)
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/src/commands/local-restore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { listBackups, restoreBackups } from "../local/backup.js";

/**
* Restore agent config files spawn wrote to the user's machine in `local`
* mode back to their pre-spawn state. Files spawn created from scratch are
* removed; pre-existing files are overwritten with their original contents.
*
* @param agent — optional agent key to filter the restore (e.g. "claude").
*/
export async function cmdLocalRestore(agent?: string): Promise<void> {
p.intro(pc.bold("Restore local agent configs"));

const entries = listBackups();
if (entries.length === 0) {
p.log.info("Nothing to restore — spawn has no local config snapshots on this machine.");
p.outro("Done");
return;
}

const filtered = agent ? entries.filter((e) => e.agent === agent) : entries;
if (filtered.length === 0) {
p.log.info(`No local snapshots found for agent ${pc.bold(agent ?? "")}.`);
const tracked = [
...new Set(entries.map((e) => e.agent)),
].sort();
if (tracked.length > 0) {
p.log.info(`Tracked agents: ${tracked.join(", ")}`);
}
p.outro("Done");
return;
}

p.log.step("The following will be reverted:");
for (const e of filtered) {
const verb = e.existed ? "restore" : "remove";
p.log.info(` ${verb}: ${e.destPath} ${pc.dim(`(${e.agent})`)}`);
}

const confirmed = await p.confirm({
message: agent
? `Restore ${filtered.length} file(s) for ${pc.bold(agent)}?`
: `Restore ${filtered.length} file(s) across all tracked agents?`,
initialValue: false,
});
if (p.isCancel(confirmed) || !confirmed) {
p.outro("Cancelled");
return;
}

const summary = restoreBackups(agent);

if (summary.restored.length > 0) {
p.log.success(`Restored ${summary.restored.length} file(s) to pre-spawn state.`);
}
if (summary.removed.length > 0) {
p.log.success(`Removed ${summary.removed.length} file(s) spawn created.`);
}
if (summary.failed.length > 0) {
p.log.warn(`${summary.failed.length} file(s) could not be restored:`);
for (const dest of summary.failed) {
p.log.warn(` ${dest}`);
}
}
if (summary.restored.length === 0 && summary.removed.length === 0 && summary.failed.length === 0) {
p.log.info("Nothing to do — files already match their pre-spawn state.");
}

p.outro("spawn local configs reverted");
}
44 changes: 43 additions & 1 deletion packages/cli/src/commands/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { listBackups, restoreBackups } from "../local/backup.js";
import {
getCacheDir,
getSpawnDir,
Expand Down Expand Up @@ -110,8 +111,10 @@ export async function cmdUninstall(): Promise<void> {
const cacheExists = fs.existsSync(cacheDir);
const spawnDirExists = fs.existsSync(spawnDir);
const configDirExists = fs.existsSync(configDir);
const localBackups = listBackups();
const hasLocalBackups = localBackups.length > 0;

if (!binaryExists && !symlinkExists && !cacheExists && !spawnDirExists && !configDirExists) {
if (!binaryExists && !symlinkExists && !cacheExists && !spawnDirExists && !configDirExists && !hasLocalBackups) {
p.log.info("Nothing to uninstall — spawn does not appear to be installed.");
p.outro("Done");
return;
Expand All @@ -123,6 +126,16 @@ export async function cmdUninstall(): Promise<void> {
label: string;
hint: string;
}[] = [];
if (hasLocalBackups) {
const tracked = [
...new Set(localBackups.map((e) => e.agent)),
].sort();
options.push({
value: "restore-local",
label: "Restore local agent configs to pre-spawn state",
hint: `${localBackups.length} file(s) across ${tracked.join(", ") || "agents"}`,
});
}
if (spawnDirExists) {
options.push({
value: "history",
Expand All @@ -140,12 +153,19 @@ export async function cmdUninstall(): Promise<void> {

let removeHistory = false;
let removeConfig = false;
let restoreLocal = false;

if (options.length > 0) {
const initialValues = hasLocalBackups
? [
"restore-local",
]
: [];
const selected = await p.multiselect({
message: "Also remove data? (space to toggle, enter to continue)",
options,
required: false,
initialValues,
});
if (p.isCancel(selected)) {
p.outro("Cancelled");
Expand All @@ -154,6 +174,7 @@ export async function cmdUninstall(): Promise<void> {
const selections = selected;
removeHistory = selections.includes("history");
removeConfig = selections.includes("config");
restoreLocal = selections.includes("restore-local");
}

// Summary of what will be removed
Expand All @@ -168,6 +189,9 @@ export async function cmdUninstall(): Promise<void> {
p.log.info(` Cache: ${cacheDir}`);
}
p.log.info(" Shell RC: spawn PATH entries");
if (restoreLocal) {
p.log.info(` Restore: ${localBackups.length} local agent config file(s)`);
}
if (removeHistory) {
p.log.info(` History: ${spawnDir}`);
}
Expand Down Expand Up @@ -216,6 +240,24 @@ export async function cmdUninstall(): Promise<void> {
removed.push(`Cache: ${cacheDir}`);
}

// Optional: restore local agent configs (must run before config dir removal,
// because the backup manifest lives under ~/.config/spawn/local-backups).
if (restoreLocal) {
const summary = restoreBackups();
if (summary.restored.length > 0) {
removed.push(`Restored: ${summary.restored.length} local config file(s)`);
}
if (summary.removed.length > 0) {
removed.push(`Reverted: ${summary.removed.length} spawn-created file(s)`);
}
if (summary.failed.length > 0) {
p.log.warn(`Could not revert ${summary.failed.length} file(s):`);
for (const dest of summary.failed) {
p.log.warn(` ${dest}`);
}
}
}

// Shell RC files
const cleanedFiles: string[] = [];
for (const rcFile of RC_FILES) {
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
cmdLink,
cmdList,
cmdListClear,
cmdLocalRestore,
cmdMatrix,
cmdPick,
cmdPullHistory,
Expand Down Expand Up @@ -805,6 +806,15 @@ async function dispatchCommand(
await cmdLink(filteredArgs);
return;
}
if (cmd === "local-restore" || cmd === "restore") {
if (hasTrailingHelpFlag(filteredArgs)) {
cmdHelp();
return;
}
const agentArg = filteredArgs[1] && !filteredArgs[1].startsWith("-") ? filteredArgs[1] : undefined;
await cmdLocalRestore(agentArg);
return;
}
if (VERB_ALIASES.has(cmd)) {
await dispatchVerbAlias(cmd, filteredArgs, prompt, dryRun, debug, headless, outputFormat);
return;
Expand Down
Loading
Loading