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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Release executables are currently unsigned. Windows SmartScreen may warn before
3. Verify the archive:

```powershell
$zip = ".\zcode-extensions-v0.3.8-windows-x64.zip"
$zip = ".\zcode-extensions-v0.3.9-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" }
Expand All @@ -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.8-windows-x64.zip -DestinationPath D:\
Expand-Archive .\zcode-extensions-v0.3.9-windows-x64.zip -DestinationPath D:\
Set-Location D:\zcode-extensions
```

Expand Down Expand Up @@ -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.8-windows-x64.zip -DestinationPath D:\ -Force
Expand-Archive .\zcode-extensions-v0.3.9-windows-x64.zip -DestinationPath D:\ -Force
Set-Location D:\zcode-extensions
.\bin\zdp.exe repair
.\bin\zdp.exe launch
Expand Down Expand Up @@ -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.8
bun run release:package -- --tag v0.3.9
```

See [Developing extensions](docs/extension-development.md) for the public API and [Hello Extension](examples/hello-extension) for a complete minimal project.
Expand Down
4 changes: 2 additions & 2 deletions docs/extension-development.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Developing ZCode Desktop Extensions

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.
This guide describes extension API version 1 as implemented by host/SDK 0.3.9. 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.

Expand All @@ -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.8
bun add -d @notmike101/zcode-extension-sdk@0.3.9
```

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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zcode-desktop-extensions",
"version": "0.3.8",
"version": "0.3.9",
"description": "An update-resistant extension host for the ZCode Electron desktop application.",
"private": true,
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-sdk-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const packageJson = JSON.parse(await readFile(path.join(sdk, "package.json"), "u
exports: Record<string, unknown>;
};

if (packageJson.name !== "@notmike101/zcode-extension-sdk" || packageJson.version !== "0.3.8") {
if (packageJson.name !== "@notmike101/zcode-extension-sdk" || packageJson.version !== "0.3.9") {
throw new Error("Unexpected SDK package identity");
}
for (const entry of [".", "./main", "./renderer", "./experimental", "./manifest.schema.json"]) {
Expand Down
2 changes: 1 addition & 1 deletion sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@notmike101/zcode-extension-sdk",
"version": "0.3.8",
"version": "0.3.9",
"description": "Public TypeScript SDK and authoring helpers for ZCode Desktop Extensions.",
"license": "MIT",
"type": "module",
Expand Down
46 changes: 44 additions & 2 deletions src/host/plugin-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {JsonLogger} from "../shared/logger.ts";
import type {TaskService} from "../protocol/task-service.ts";
import type {ExtensionZCodeService} from "../protocol/extension-service.ts";
import {containedExtensionPath, readExtensionManifest, rejectExtensionLinks} from "./extension-bundle.ts";
import {ExtensionUpdater, type AppliedUpdate} from "./extension-updater.ts";
import {ExtensionUpdater, moveOrCopyForUpdate, renameWithRetry, type AppliedUpdate} from "./extension-updater.ts";

type PluginModule = ExtensionModule;
type Disposable = ExtensionDisposable;
Expand Down Expand Up @@ -129,7 +129,7 @@ export class PluginManager {
const destination = path.join(this.#paths.plugins, manifest.id);
await cp(sourceRoot, staging, {recursive: true, force: false, errorOnExist: true, dereference: false});
await readExtensionManifest(staging);
await rename(staging, destination).catch(async (error) => {
await installStagedExtension(staging, destination, this.#paths.trash).catch(async (error) => {
await rm(staging, {recursive: true, force: true});
throw error;
});
Expand Down Expand Up @@ -432,3 +432,45 @@ function clearRequireCache(root: string): void {
if (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) delete require.cache[key];
}
}

export async function installStagedExtension(
staging: string,
destination: string,
trashRoot: string,
operation: typeof rename = rename,
attempts?: number,
delayMs?: number,
): Promise<string | undefined> {
const destinationExists = await stat(destination).then(() => true).catch(() => false);
if (!destinationExists) {
await renameWithRetry(staging, destination, operation, attempts, delayMs);
return undefined;
}

await mkdir(trashRoot, {recursive: true});
const displaced = path.join(
trashRoot,
`${path.basename(destination)}-replaced-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}`,
);
await moveOrCopyForUpdate(destination, displaced, false, operation, attempts, delayMs);
const originalWasCopied = await stat(destination).then(() => true).catch(() => false);
try {
await moveOrCopyForUpdate(staging, destination, true, operation, attempts, delayMs);
await rm(staging, {recursive: true, force: true});
return displaced;
} catch (installError) {
try {
if (originalWasCopied) {
await cp(displaced, destination, {recursive: true, force: true});
} else {
await renameWithRetry(displaced, destination, operation, attempts, delayMs);
}
} catch (restoreError) {
throw new AggregateError(
[installError, restoreError],
`Extension install failed and the previous directory could not be restored from ${displaced}`,
);
}
throw installError;
}
}
2 changes: 1 addition & 1 deletion src/shared/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from "node:path";

export const HOST_NAME = "ZCode Desktop Extensions";
export const HOST_VERSION = "0.3.8";
export const HOST_VERSION = "0.3.9";
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;
Expand Down
19 changes: 16 additions & 3 deletions src/shared/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,22 @@ export class JsonLogger {
}
}

function serializeError(value: unknown): unknown {
function serializeError(value: unknown, seen = new WeakSet<object>()): unknown {
if (!value || typeof value !== "object") return value;
if (seen.has(value)) return "[Circular]";
seen.add(value);
if (value instanceof Error) {
return {name: value.name, message: value.message, stack: value.stack};
const serialized: Record<string, unknown> = {
name: value.name,
message: value.message,
stack: value.stack,
};
for (const key of Object.keys(value)) {
serialized[key] = serializeError((value as unknown as Record<string, unknown>)[key], seen);
}
if (value.cause !== undefined) serialized.cause = serializeError(value.cause, seen);
return serialized;
}
return value;
if (Array.isArray(value)) return value.map((item) => serializeError(item, seen));
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, serializeError(item, seen)]));
}
11 changes: 7 additions & 4 deletions tests/host-updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {afterEach, describe, expect, test} from "bun:test";
import {mkdir, mkdtemp, readFile, rm, stat, writeFile} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import semver from "semver";
import {HostUpdater, type ReleaseManifest} from "../src/host/host-updater.ts";
import {JsonLogger} from "../src/shared/logger.ts";
import {HOST_VERSION} from "../src/shared/constants.ts";

const roots: string[] = [];
const servers: Bun.Server<unknown>[] = [];
Expand All @@ -27,22 +29,23 @@ describe("host updater", () => {

test("verifies and stages a packaged update before launching the detached helper", async () => {
const root = await tempRoot("zdp-host-package-");
const nextVersion = semver.inc(HOST_VERSION, "patch")!;
await mkdir(path.join(root, "bin"), {recursive: true});
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.8", [{path: "bin/zdp.exe", bytes: Buffer.from("old helper")}]);
await writeManifest(root, HOST_VERSION, [{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.9", [{path: "bin/zdp.exe", bytes: incoming}]);
await writeManifest(archiveStage, nextVersion, [{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.9", bytes.length);
const server = serveRelease(bytes, digest, nextVersion, 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();
Expand All @@ -54,7 +57,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.9"});
expect(transaction).toMatchObject({phase: "ready", targetVersion: nextVersion});
expect(await readFile(path.join(root, "data", "keep.txt"), "utf8")).toBe("preserved");
updater.dispose();
});
Expand Down
47 changes: 47 additions & 0 deletions tests/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {afterEach, describe, expect, test} from "bun:test";
import {mkdtemp, readFile, rm} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {JsonLogger} from "../src/shared/logger.ts";

const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, {recursive: true, force: true})));
});

describe("JSON logging", () => {
test("serializes nested errors with their messages and filesystem details", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "zdp-logger-"));
directories.push(directory);
const file = path.join(directory, "host.jsonl");
const error = Object.assign(new Error("incompatible extension"), {
code: "EPERM",
path: "old",
dest: "new",
});

await new JsonLogger(file, "test").error("install failed", {root: "example", error});

const entry = JSON.parse(await readFile(file, "utf8"));
expect(entry.data.error).toMatchObject({
name: "Error",
message: "incompatible extension",
code: "EPERM",
path: "old",
dest: "new",
});
});

test("does not fail when diagnostic data contains a cycle", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "zdp-logger-"));
directories.push(directory);
const file = path.join(directory, "host.jsonl");
const data: {self?: unknown} = {};
data.self = data;

await new JsonLogger(file, "test").error("cyclic data", data);

const entry = JSON.parse(await readFile(file, "utf8"));
expect(entry.data.self).toBe("[Circular]");
});
});
78 changes: 78 additions & 0 deletions tests/plugin-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {afterEach, describe, expect, test} from "bun:test";
import {mkdir, mkdtemp, readFile, rename, rm, writeFile} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {installStagedExtension} from "../src/host/plugin-manager.ts";

const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, {recursive: true, force: true})));
});

describe("extension installation", () => {
test("replaces an untracked destination while preserving it in trash", async () => {
const root = await temporaryRoot();
const staging = path.join(root, "staging");
const destination = path.join(root, "plugins", "example");
const trash = path.join(root, "trash");
await mkdir(staging, {recursive: true});
await mkdir(destination, {recursive: true});
await writeFile(path.join(staging, "version.txt"), "new");
await writeFile(path.join(destination, "version.txt"), "old");

const displaced = await installStagedExtension(staging, destination, trash);

expect(displaced).toBeString();
expect(await readFile(path.join(destination, "version.txt"), "utf8")).toBe("new");
expect(await readFile(path.join(displaced!, "version.txt"), "utf8")).toBe("old");
});

test("restores an untracked destination when installing the replacement fails", async () => {
const root = await temporaryRoot();
const staging = path.join(root, "staging");
const destination = path.join(root, "plugins", "example");
const trash = path.join(root, "trash");
await mkdir(staging, {recursive: true});
await mkdir(destination, {recursive: true});
await writeFile(path.join(staging, "version.txt"), "new");
await writeFile(path.join(destination, "version.txt"), "old");
const failingRename: typeof rename = async (source, target) => {
if (source === staging && target === destination) {
throw Object.assign(new Error("simulated install failure"), {code: "EIO"});
}
await rename(source, target);
};

await expect(installStagedExtension(staging, destination, trash, failingRename)).rejects.toThrow(
"simulated install failure",
);

expect(await readFile(path.join(destination, "version.txt"), "utf8")).toBe("old");
expect(await readFile(path.join(staging, "version.txt"), "utf8")).toBe("new");
});

test("copies over a locked untracked destination and keeps a backup", async () => {
const root = await temporaryRoot();
const staging = path.join(root, "staging");
const destination = path.join(root, "plugins", "example");
const trash = path.join(root, "trash");
await mkdir(staging, {recursive: true});
await mkdir(destination, {recursive: true});
await writeFile(path.join(staging, "version.txt"), "new");
await writeFile(path.join(destination, "version.txt"), "old");
const lockedRename: typeof rename = async () => {
throw Object.assign(new Error("directory is locked"), {code: "EPERM"});
};

const displaced = await installStagedExtension(staging, destination, trash, lockedRename, 1, 0);

expect(await readFile(path.join(destination, "version.txt"), "utf8")).toBe("new");
expect(await readFile(path.join(displaced!, "version.txt"), "utf8")).toBe("old");
});
});

async function temporaryRoot(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "zdp-plugin-manager-"));
directories.push(directory);
return directory;
}