diff --git a/README.md b/README.md index 854b485..e11d3b2 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Release executables are currently unsigned. Windows SmartScreen may warn before 3. Verify the archive: ```powershell - $zip = ".\zcode-extensions-v0.3.7-windows-x64.zip" + $zip = ".\zcode-extensions-v0.3.8-windows-x64.zip" $expected = (Get-Content "$zip.sha256").Split()[0].ToLowerInvariant() $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $expected) { throw "Checksum mismatch" } @@ -52,7 +52,7 @@ Release executables are currently unsigned. Windows SmartScreen may warn before 4. Extract the archive to a permanent location. The ZIP contains a stable `zcode-extensions` directory: ```powershell - Expand-Archive .\zcode-extensions-v0.3.7-windows-x64.zip -DestinationPath D:\ + Expand-Archive .\zcode-extensions-v0.3.8-windows-x64.zip -DestinationPath D:\ Set-Location D:\zcode-extensions ``` @@ -112,7 +112,7 @@ Packaged installs check the stable host feed at startup, every six hours, and wi Source/development checkouts show the release notification but do not overwrite themselves. For those installs—or as a recovery path—close ZCode, download the new release, and extract it over the same parent directory: ```powershell -Expand-Archive .\zcode-extensions-v0.3.7-windows-x64.zip -DestinationPath D:\ -Force +Expand-Archive .\zcode-extensions-v0.3.8-windows-x64.zip -DestinationPath D:\ -Force Set-Location D:\zcode-extensions .\bin\zdp.exe repair .\bin\zdp.exe launch @@ -183,7 +183,7 @@ bun run build:example bun run build bun run build:sdk bun run pack:sdk -bun run release:package -- --tag v0.3.7 +bun run release:package -- --tag v0.3.8 ``` See [Developing extensions](docs/extension-development.md) for the public API and [Hello Extension](examples/hello-extension) for a complete minimal project. diff --git a/docs/extension-development.md b/docs/extension-development.md index 1c5c3ee..a8dc395 100644 --- a/docs/extension-development.md +++ b/docs/extension-development.md @@ -1,6 +1,6 @@ # Developing ZCode Desktop Extensions -This guide describes extension API version 1 as implemented by host/SDK 0.3.7. The public package is [`@notmike101/zcode-extension-sdk`](https://www.npmjs.com/package/@notmike101/zcode-extension-sdk), the source contract is [`sdk/index.ts`](../sdk/index.ts), and [Hello Extension](../examples/hello-extension) remains a complete legacy-lifecycle example. +This guide describes extension API version 1 as implemented by host/SDK 0.3.8. The public package is [`@notmike101/zcode-extension-sdk`](https://www.npmjs.com/package/@notmike101/zcode-extension-sdk), the source contract is [`sdk/index.ts`](../sdk/index.ts), and [Hello Extension](../examples/hello-extension) remains a complete legacy-lifecycle example. Extensions are trusted local code. A main entrypoint runs in ZCode's Electron main process with Node.js access, while an optional renderer entrypoint runs inside the ZCode renderer. Declared capabilities control access through the SDK and are shown during installation; they do not sandbox trusted Node or renderer code. @@ -9,7 +9,7 @@ Extensions are trusted local code. A main entrypoint runs in ZCode's Electron ma For a separate Bun or TypeScript project: ```powershell -bun add -d @notmike101/zcode-extension-sdk@0.3.7 +bun add -d @notmike101/zcode-extension-sdk@0.3.8 ``` Import main-only types and helpers from `@notmike101/zcode-extension-sdk/main`, browser-safe renderer types and helpers from `/renderer`, and unstable raw-channel types from `/experimental`. The root export is also browser-safe. A JSON Schema is available at `/manifest.schema.json`, and `validateExtensionManifest` or `assertExtensionManifest` can validate manifests at runtime. diff --git a/package.json b/package.json index 42dbb91..440daef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-desktop-extensions", - "version": "0.3.7", + "version": "0.3.8", "description": "An update-resistant extension host for the ZCode Electron desktop application.", "private": true, "license": "MIT", diff --git a/scripts/check-sdk-package.ts b/scripts/check-sdk-package.ts index c701f7f..6f9ca1e 100644 --- a/scripts/check-sdk-package.ts +++ b/scripts/check-sdk-package.ts @@ -11,7 +11,7 @@ const packageJson = JSON.parse(await readFile(path.join(sdk, "package.json"), "u exports: Record; }; -if (packageJson.name !== "@notmike101/zcode-extension-sdk" || packageJson.version !== "0.3.7") { +if (packageJson.name !== "@notmike101/zcode-extension-sdk" || packageJson.version !== "0.3.8") { throw new Error("Unexpected SDK package identity"); } for (const entry of [".", "./main", "./renderer", "./experimental", "./manifest.schema.json"]) { diff --git a/sdk/package.json b/sdk/package.json index b70da0b..43505d3 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@notmike101/zcode-extension-sdk", - "version": "0.3.7", + "version": "0.3.8", "description": "Public TypeScript SDK and authoring helpers for ZCode Desktop Extensions.", "license": "MIT", "type": "module", diff --git a/src/host/extension-updater.ts b/src/host/extension-updater.ts index 7898182..2dfcd2a 100644 --- a/src/host/extension-updater.ts +++ b/src/host/extension-updater.ts @@ -1,6 +1,6 @@ import {createHash, randomUUID} from "node:crypto"; import {createWriteStream} from "node:fs"; -import {mkdir, readFile, readdir, rename, rm, stat, writeFile} from "node:fs/promises"; +import {cp, mkdir, readFile, readdir, rename, rm, stat, writeFile} from "node:fs/promises"; import path from "node:path"; import {pipeline} from "node:stream/promises"; import semver from "semver"; @@ -303,9 +303,9 @@ export class ExtensionUpdater { if (pending.phase === "applying") { if (await exists(destination)) { if (!pending.rollbackPath) throw new Error(`Missing rollback path for ${pending.pluginId}`); - await renameWithRetry(destination, pending.rollbackPath); + await moveOrCopyForUpdate(destination, pending.rollbackPath); } - if (await exists(pending.stagingRoot)) await renameWithRetry(pending.stagingRoot, destination); + if (await exists(pending.stagingRoot)) await moveOrCopyForUpdate(pending.stagingRoot, destination, true); const installed = await readExtensionManifest(destination); if (installed.id !== pending.pluginId || installed.version !== pending.version) { throw new Error(`Applied bundle identity mismatch for ${pending.pluginId}`); @@ -324,8 +324,7 @@ export class ExtensionUpdater { this.#errors.set(pending.pluginId, errorText(error)); if (pending.rollbackPath && await exists(pending.rollbackPath)) { const destination = path.join(this.#paths.plugins, pending.pluginId); - if (await exists(destination)) await rm(destination, {recursive: true, force: true}); - await renameWithRetry(pending.rollbackPath, destination); + await cp(pending.rollbackPath, destination, {recursive: true, force: true}); } delete this.#state.pending[pending.pluginId]; await this.#persistState(); @@ -649,3 +648,23 @@ function isTransientRenameError(value: unknown): boolean { function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +export async function moveOrCopyForUpdate( + source: string, + destination: string, + mergeDestination = false, + operation: typeof rename = rename, + attempts = RENAME_RETRY_ATTEMPTS, + delayMs = RENAME_RETRY_DELAY_MS, +): Promise { + try { + await renameWithRetry(source, destination, operation, attempts, delayMs); + } catch (error) { + if (!isTransientRenameError(error)) throw error; + await cp(source, destination, { + recursive: true, + force: mergeDestination, + errorOnExist: !mergeDestination, + }); + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 72e4d9b..1a2d627 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,7 +1,7 @@ import path from "node:path"; export const HOST_NAME = "ZCode Desktop Extensions"; -export const HOST_VERSION = "0.3.7"; +export const HOST_VERSION = "0.3.8"; export const HOST_UPDATE_URL = "https://github.com/notmike101/zcode-extensions/releases/latest/download/host-update.json"; export const API_VERSION = 1; export const INSTALL_STATE_VERSION = 1; diff --git a/tests/extension-updater.test.ts b/tests/extension-updater.test.ts index e1bbbb7..d775e19 100644 --- a/tests/extension-updater.test.ts +++ b/tests/extension-updater.test.ts @@ -3,7 +3,7 @@ import {afterEach, describe, expect, test} from "bun:test"; import {mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import {ExtensionUpdater, renameWithRetry, validateArchiveEntryPath} from "../src/host/extension-updater.ts"; +import {ExtensionUpdater, moveOrCopyForUpdate, renameWithRetry, validateArchiveEntryPath} from "../src/host/extension-updater.ts"; import {readExtensionManifest} from "../src/host/extension-bundle.ts"; import {JsonLogger} from "../src/shared/logger.ts"; import type {PluginManifest} from "../src/shared/schemas.ts"; @@ -35,6 +35,26 @@ describe("extension updater", () => { expect(attempts).toBe(1); }); + test("copies a verified update over a persistently locked extension directory", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "zdp-updater-copy-fallback-")); + roots.push(root); + const installed = path.join(root, "installed"); + const staged = path.join(root, "staged"); + const rollback = path.join(root, "rollback"); + await writeBundle(installed, "0.1.5", "old"); + await writeBundle(staged, "0.1.6", "new"); + const lockedRename = async () => { + throw Object.assign(new Error("directory remains locked"), {code: "EPERM"}); + }; + + await moveOrCopyForUpdate(installed, rollback, false, lockedRename, 1, 0); + await moveOrCopyForUpdate(staged, installed, true, lockedRename, 1, 0); + + expect((await readExtensionManifest(rollback)).version).toBe("0.1.5"); + expect((await readExtensionManifest(installed)).version).toBe("0.1.6"); + expect(await readFile(path.join(installed, "dist", "main.cjs"), "utf8")).toContain("new"); + }); + test("verifies, queues, and applies an extension update on the next launch", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "zdp-updater-")); roots.push(root); diff --git a/tests/fixtures/serve-renderer-harness.ts b/tests/fixtures/serve-renderer-harness.ts index b38922d..d82a190 100644 --- a/tests/fixtures/serve-renderer-harness.ts +++ b/tests/fixtures/serve-renderer-harness.ts @@ -15,7 +15,7 @@ const server = Bun.serve({ }); } if (url.pathname === "/renderer/index.js") { - return new Response(await readFile(path.join(root, "runtime", "versions", "0.3.7", "renderer", "index.js")), { + return new Response(await readFile(path.join(root, "runtime", "versions", "0.3.8", "renderer", "index.js")), { headers: {"content-type": "text/javascript; charset=utf-8"}, }); } diff --git a/tests/host-updater.test.ts b/tests/host-updater.test.ts index 2ec728a..d1bc57a 100644 --- a/tests/host-updater.test.ts +++ b/tests/host-updater.test.ts @@ -31,18 +31,18 @@ describe("host updater", () => { await mkdir(path.join(root, "data"), {recursive: true}); await writeFile(path.join(root, "bin", "zdp.exe"), "old helper"); await writeFile(path.join(root, "data", "keep.txt"), "preserved"); - await writeManifest(root, "0.3.7", [{path: "bin/zdp.exe", bytes: Buffer.from("old helper")}]); + await writeManifest(root, "0.3.8", [{path: "bin/zdp.exe", bytes: Buffer.from("old helper")}]); const archiveStage = path.join(root, "archive-stage", "zcode-extensions"); const incoming = Buffer.from("new helper"); await mkdir(path.join(archiveStage, "bin"), {recursive: true}); await writeFile(path.join(archiveStage, "bin", "zdp.exe"), incoming); - await writeManifest(archiveStage, "0.3.8", [{path: "bin/zdp.exe", bytes: incoming}]); + await writeManifest(archiveStage, "0.3.9", [{path: "bin/zdp.exe", bytes: incoming}]); const archive = path.join(root, "host.zip"); await compress(archiveStage, archive); const bytes = Buffer.from(await Bun.file(archive).arrayBuffer()); const digest = hash(bytes); - const server = serveRelease(bytes, digest, "0.3.8", bytes.length); + const server = serveRelease(bytes, digest, "0.3.9", bytes.length); let launched: {executable: string; args: string[]} | undefined; const updater = createUpdater(root, `${server.url}host-update.json`, (executable, args) => { launched = {executable, args}; }); await updater.initialize(); @@ -54,7 +54,7 @@ describe("host updater", () => { expect(launched?.args).toContain("4242"); expect((await stat(launched!.executable)).isFile()).toBe(true); const transaction = JSON.parse(await readFile(path.join(root, "data", "host-update.json"), "utf8")) as {phase: string; targetVersion: string}; - expect(transaction).toMatchObject({phase: "ready", targetVersion: "0.3.8"}); + expect(transaction).toMatchObject({phase: "ready", targetVersion: "0.3.9"}); expect(await readFile(path.join(root, "data", "keep.txt"), "utf8")).toBe("preserved"); updater.dispose(); });