CodeNomad/scripts/test-plugin-packages-native.mjs
Pascal André b4be76c978
feat(plugins): native package check and update (6/8) (#794)
## Behavior
Package-backed plugins in Status → Plugins get one compact action beside
their name. Activating it opens a menu naming the exact native
operation:
- **Check updates for {target}**: `PackageSearch` icon.
- **Update {target}**: `Download` icon, shown once the target is
outdated.

The tooltip states that an update changes the daemon-wide shared package
cache. Busy actions pulse but keep their own icon; `RefreshCw` stays
reserved for the list refresh. Global/Project activation switches are
unchanged. Local, builtin and SDK sources never offer package actions.

A package that fails before exporting a plugin ID stays visible as a
target-only row, with check/update and failure details in its tooltip.
No activation switch or fabricated ID is created, and the row is
replaced by the ordinary plugin row once native loading succeeds.

Pending admission is keyed by instance and target and survives
hiding/remounting. Failures toast only for the live owner. Every
completion invalidates the display snapshot, and nothing is replayed
automatically.

## Native boundary
Only `POST /api/plugin/check` and `POST /api/plugin/update` are added to
the instance allowlist. They go through the existing directory-ownership
and worktree-deletion admission, so a foreign location is refused
whether it is in the query or the body. Native OpenCode validates
targets against the current Location inventory and serializes updates
per target. There is no `location.reload` and no daemon restart.

## Validation
- Server and UI typechecks pass.
- `instance-proxy.test.ts`: 56/56, including owned check/update and
foreign query/body locations rejected before forwarding.
- Browser: `plugin-controls` and `plugin-packages` pass, 10/10
scenarios. Captures inspected for LTR, RTL, narrow and
failed-package-only layouts.
- `scripts/test-plugin-packages-native.mjs` passes on an isolated copy
of OpenCode 2.0.29 with a loopback npm registry. It covers 1.0.0 → 1.1.0
update detection, unknown-target rejection and native live reload, using
a private home/database/npm config/cache only.

## History
PRs 1–5 of this series were squash-merged into `dev`. This branch now
merges `dev` and carries only its own plugin-package changes plus review
fixes (25 files).

Size notes: the existing `http-server.ts`, `instance-proxy.test.ts` and
locale settings files exceed the size guidance.
2026-10-15 02:54:19 +02:00

60 lines
3.4 KiB
JavaScript

import assert from "node:assert/strict"
import { createServer } from "node:http"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { spawnSync } from "node:child_process"
import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { withProductRuntime } from "./native-product-fixture.mjs"
let version = "1.0.0", base
const archives = new Map()
const registry = createServer((req, res) => {
if (req.url.startsWith("/fixture-plugin/-/")) {
const archive = archives.get(req.url.split("/").at(-1))
res.writeHead(archive ? 200 : 404, { "Content-Type": "application/octet-stream" }); res.end(archive); return
}
if (req.url.split("?")[0] !== "/fixture-plugin") { res.writeHead(404); res.end(); return }
const versions = Object.fromEntries(["1.0.0", "1.1.0"].map(v => [v, { name: "fixture-plugin", version: v,
dist: { tarball: `${base}/fixture-plugin/-/${v}.tgz` } }]))
res.setHeader("Content-Type", "application/json")
res.end(JSON.stringify({ name: "fixture-plugin", "dist-tags": { latest: version }, versions }))
})
await new Promise(resolve => registry.listen(0, "127.0.0.1", resolve))
base = `http://127.0.0.1:${registry.address().port}`
try {
await withProductRuntime(process.argv[2], async ({ root }) => {
await writeFile(path.join(root, ".npmrc"), `registry=${base}\naudit=false\nfund=false\n`)
for (const v of ["1.0.0", "1.1.0"]) {
const directory = path.join(root, v), pkg = path.join(directory, "package")
await mkdir(pkg, { recursive: true })
await writeFile(path.join(pkg, "package.json"), JSON.stringify({ name: "fixture-plugin", version: v, type: "module", exports: { ".": "./index.js" } }))
await writeFile(path.join(pkg, "index.js"), 'export default { id: "fixture.package", setup() {} }\n')
const tarball = path.join(root, `${v}.tgz`)
const tar = spawnSync("tar", ["-czf", tarball, "-C", directory, "package"], { encoding: "utf8" })
assert.equal(tar.status, 0, tar.stderr)
archives.set(`${v}.tgz`, await readFile(tarball))
}
return { plugins: ["fixture-plugin@latest"] }
}, async ({ client, root }) => {
const location = { directory: root }
await client.location.get({ location })
const waitFor = async predicate => {
const deadline = Date.now() + 60_000
let info
do {
info = (await client.plugin.list({ location })).data.find(item => item.source.type === "package" && item.source.target === "fixture-plugin@latest")
if (info && predicate(info)) return info
await delay(100)
} while (Date.now() < deadline)
assert.fail(`Native package state did not settle: ${JSON.stringify(info)}`)
}
await waitFor(info => info.state.status === "active" && info.source.version === "1.0.0")
version = "1.1.0"
const checked = await client.plugin.check({ location, target: "fixture-plugin@latest" })
assert.equal(checked.data.find(item => item.source.type === "package").source.outdated, true)
await assert.rejects(client.plugin.update({ location, targets: ["not-in-inventory"] }))
await client.plugin.update({ location, targets: ["fixture-plugin@latest"] })
await waitFor(info => info.state.status === "active" && info.source.version === "1.1.0" && !info.source.updating)
console.log("PASS isolated registry package check/update, unknown-target rejection and native live reload")
})
} finally { registry.closeAllConnections(); await new Promise(resolve => registry.close(resolve)) }