Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ See [docs/SURFACE_REFERENCE.md](docs/SURFACE_REFERENCE.md) for the source-accura
| `npx opendevbrowser --with-config` | Also create opendevbrowser.jsonc |
| `npx opendevbrowser --full` | Full install (config + extension assets) |
| `npm install -g opendevbrowser` | Install persistent global CLI |
| `npx opendevbrowser --update` | Repair cached plugin pins |
| `npx opendevbrowser --update` | Repair OpenCode package caches and plugin pins |
| `npx opendevbrowser --uninstall` | Remove from config |
| `npx opendevbrowser --version` | Show version |

Expand Down Expand Up @@ -806,7 +806,7 @@ OpenDevBrowser is **secure by default** with defense-in-depth protections:
## Updating

```bash
# Repair OpenCode's cached package and manifest pin, then restart OpenCode
# Repair OpenCode's cached packages, manifest pin, and lockfile, then restart OpenCode
npx opendevbrowser --update
```

Expand Down
5 changes: 3 additions & 2 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,9 @@ npx opendevbrowser --update
npx opendevbrowser -u
```

This removes cached files from `~/.cache/opencode/node_modules/opendevbrowser/`, removes stale `opendevbrowser`
dependency pins from `~/.cache/opencode/package.json`, and deletes the OpenCode cache lockfile when present. OpenCode
This removes cached files from `~/.cache/opencode/node_modules/opendevbrowser/` and
`~/.cache/opencode/packages/opendevbrowser@latest/`, removes stale `opendevbrowser`
dependency pins from `~/.cache/opencode/package.json`, and deletes `~/.cache/opencode/package-lock.json` when present. OpenCode
will download the latest version on next run.

### Uninstall
Expand Down
6 changes: 5 additions & 1 deletion src/cli/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const PLUGIN_NAME = "opendevbrowser";
const CACHE_MANIFEST = "package.json";
const CACHE_LOCKFILE = "package-lock.json";
const CACHE_UPDATE_LOCK = ".opendevbrowser-update.lock";
const OPENCODE_PACKAGE_ALIAS = `${PLUGIN_NAME}@latest`;
const CACHE_LOCK_STALE_MS = 30 * 60 * 1000;
const DEPENDENCY_SECTIONS = [
"dependencies",
Expand Down Expand Up @@ -308,6 +309,7 @@ export function runUpdate(): UpdateResult {
const cacheDir = getCacheDir();
const nodeModulesDir = path.join(cacheDir, "node_modules");
const pluginCacheDir = path.join(nodeModulesDir, PLUGIN_NAME);
const pluginPackageCacheDir = path.join(cacheDir, "packages", OPENCODE_PACKAGE_ALIAS);
const lockfilePath = path.join(cacheDir, CACHE_LOCKFILE);

try {
Expand All @@ -322,14 +324,16 @@ export function runUpdate(): UpdateResult {
preflightCacheMutationPaths(cacheDir, [
path.join(cacheDir, CACHE_MANIFEST),
pluginCacheDir,
pluginPackageCacheDir,
lockfilePath,
path.join(cacheDir, CACHE_UPDATE_LOCK)
]);
const cleared = withCacheMutationLock(cacheDir, () => {
const manifestPinRemoved = removeManifestPin(cacheDir);
const packageRemoved = removePathIfExists(pluginCacheDir, cacheDir);
const aliasedPackageRemoved = removePathIfExists(pluginPackageCacheDir, cacheDir);
const lockfileRemoved = removePathIfExists(lockfilePath, cacheDir);
return packageRemoved || manifestPinRemoved || lockfileRemoved;
return packageRemoved || aliasedPackageRemoved || manifestPinRemoved || lockfileRemoved;
});

if (!cleared) {
Expand Down
50 changes: 50 additions & 0 deletions tests/cli-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ describe("runUpdate", () => {
});
mkdirSync(makePath("node_modules", "opendevbrowser"), { recursive: true });
mkdirSync(makePath("node_modules", "oh-my-opencode"), { recursive: true });
mkdirSync(makePath("packages", "opendevbrowser@latest"), { recursive: true });
mkdirSync(makePath("packages", "oh-my-opencode@latest"), { recursive: true });
writeFileSync(makePath("package-lock.json"), "{\"lockfileVersion\":3}\n", "utf8");

const result = runUpdate();
Expand All @@ -69,6 +71,8 @@ describe("runUpdate", () => {
});
expect(existsSync(makePath("node_modules", "opendevbrowser"))).toBe(false);
expect(existsSync(makePath("node_modules", "oh-my-opencode"))).toBe(true);
expect(existsSync(makePath("packages", "opendevbrowser@latest"))).toBe(false);
expect(existsSync(makePath("packages", "oh-my-opencode@latest"))).toBe(true);
expect(existsSync(makePath("package-lock.json"))).toBe(false);
expect(readManifest()).toEqual({
dependencies: {
Expand Down Expand Up @@ -107,9 +111,25 @@ describe("runUpdate", () => {
expect(existsSync(makePath("package-lock.json"))).toBe(false);
});

it("repairs OpenCode package alias cache state", () => {
mkdirSync(makePath("packages", "opendevbrowser@latest"), { recursive: true });
mkdirSync(makePath("packages", "yaml-language-server"), { recursive: true });

const result = runUpdate();

expect(result).toEqual({
success: true,
message: "Cache repaired. OpenCode will install the latest version on next run.",
cleared: true
});
expect(existsSync(makePath("packages", "opendevbrowser@latest"))).toBe(false);
expect(existsSync(makePath("packages", "yaml-language-server"))).toBe(true);
});

it("refuses to mutate cache state while another update lock is held", () => {
writeManifest({ dependencies: { opendevbrowser: "0.0.24" } });
mkdirSync(makePath("node_modules", "opendevbrowser"), { recursive: true });
mkdirSync(makePath("packages", "opendevbrowser@latest"), { recursive: true });
writeFileSync(makePath("package-lock.json"), "{\"lockfileVersion\":3}\n", "utf8");
writeFileSync(
makePath(".opendevbrowser-update.lock"),
Expand All @@ -124,6 +144,7 @@ describe("runUpdate", () => {
expect(result.message).toContain("another update is already running");
expect(readManifest()).toEqual({ dependencies: { opendevbrowser: "0.0.24" } });
expect(existsSync(makePath("node_modules", "opendevbrowser"))).toBe(true);
expect(existsSync(makePath("packages", "opendevbrowser@latest"))).toBe(true);
expect(existsSync(makePath("package-lock.json"))).toBe(true);
});

Expand Down Expand Up @@ -252,13 +273,15 @@ describe("runUpdate", () => {

it("does not delete package cache before validating a malformed manifest", () => {
mkdirSync(makePath("node_modules", "opendevbrowser"), { recursive: true });
mkdirSync(makePath("packages", "opendevbrowser@latest"), { recursive: true });
writeFileSync(makePath("package.json"), "{bad-json}", "utf8");

const result = runUpdate();

expect(result.success).toBe(false);
expect(result.cleared).toBe(false);
expect(existsSync(makePath("node_modules", "opendevbrowser"))).toBe(true);
expect(existsSync(makePath("packages", "opendevbrowser@latest"))).toBe(true);
});

it("refuses to rewrite symlinked cache manifests", () => {
Expand Down Expand Up @@ -298,6 +321,33 @@ describe("runUpdate", () => {
expect(result.message).toContain("refusing to modify symlinked cache path");
});

it("refuses to delete through a symlinked packages parent", () => {
const outsidePackages = join(cacheDir, "..", "outside-packages");
mkdirSync(outsidePackages, { recursive: true });
writeFileSync(join(outsidePackages, "sentinel.txt"), "keep\n", "utf8");
symlinkSync(outsidePackages, makePath("packages"));

const result = runUpdate();

expect(result.success).toBe(false);
expect(result.message).toContain("refusing to modify symlinked cache path");
expect(readFileSync(join(outsidePackages, "sentinel.txt"), "utf8")).toBe("keep\n");
});

it("refuses to delete a symlinked package alias cache", () => {
const outsideAlias = join(cacheDir, "..", "outside-opendevbrowser-alias");
mkdirSync(makePath("packages"), { recursive: true });
mkdirSync(outsideAlias, { recursive: true });
writeFileSync(join(outsideAlias, "sentinel.txt"), "keep\n", "utf8");
symlinkSync(outsideAlias, makePath("packages", "opendevbrowser@latest"));

const result = runUpdate();

expect(result.success).toBe(false);
expect(result.message).toContain("refusing to modify symlinked cache path");
expect(readFileSync(join(outsideAlias, "sentinel.txt"), "utf8")).toBe("keep\n");
});

it("preflights symlinked cache paths before rewriting stale manifest pins", () => {
const outsideModules = join(cacheDir, "..", "outside-node-modules-with-manifest");
mkdirSync(outsideModules, { recursive: true });
Expand Down
Loading