mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29277b8e7c |
No files matched your search
@@ -21,12 +21,17 @@
|
||||
// per founder authorization 2026-05-10 (LICENSE only); 404 is the
|
||||
// expected normal-state response until a separate publish authorization.
|
||||
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export interface PluginVendorFs {
|
||||
readFile(path: string): string;
|
||||
writeFile(path: string, content: string): void;
|
||||
/** A file's raw bytes. With `writeBytes`, a plugin's files are copied and compared byte for byte, so a binary file
|
||||
* (an icon) arrives intact. Optional: without them files are read and written as text. */
|
||||
readBytes?(path: string): Uint8Array;
|
||||
writeBytes?(path: string, content: Uint8Array): void;
|
||||
/** Copy raw bytes, failing if the recovery path already exists. */
|
||||
copyFileExclusive(source: string, target: string): void;
|
||||
exists(path: string): boolean;
|
||||
@@ -160,7 +165,7 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(this.fs.readFile(srcPath))
|
||||
hashContent(this.read(destPath)) === hashContent(this.read(srcPath))
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
@@ -170,7 +175,7 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, FileContent]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
|
||||
this.fs.mkdirp(targetDir);
|
||||
|
||||
@@ -179,23 +184,34 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
|
||||
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
|
||||
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
|
||||
if (this.fs.exists(destPath) && hashContent(this.read(destPath)) === hashContent(content)) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.write(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin file as the fs reads it exactly: bytes when it can, else text. */
|
||||
private read(path: string): FileContent {
|
||||
return this.fs.readBytes ? this.fs.readBytes(path) : this.fs.readFile(path);
|
||||
}
|
||||
|
||||
private write(path: string, content: FileContent): void {
|
||||
if (typeof content === "string") this.fs.writeFile(path, content);
|
||||
else if (this.fs.writeBytes) this.fs.writeBytes(path, content);
|
||||
else this.fs.writeFile(path, Buffer.from(content).toString("utf-8"));
|
||||
}
|
||||
|
||||
/** Preserve every replaced file before touching a target, including its version authority. */
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, FileContent]>): boolean {
|
||||
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
|
||||
try {
|
||||
for (const [rel, content] of new Map(updates)) {
|
||||
const dest = nodePath.join(targetDir, rel);
|
||||
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
|
||||
if (!this.fs.exists(dest) || hashContent(this.read(dest)) === hashContent(content)) continue;
|
||||
const backup = nodePath.join(backupDir, rel);
|
||||
this.fs.mkdirp(nodePath.dirname(backup));
|
||||
this.fs.copyFileExclusive(dest, backup);
|
||||
@@ -276,7 +292,7 @@ export class PluginVendorService {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, FileContent]> = files.map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
|
||||
this.fs.mkdirp(targetDir);
|
||||
for (const [relPath, content] of updates) {
|
||||
@@ -284,13 +300,13 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(content)
|
||||
hashContent(this.read(destPath)) === hashContent(content)
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.write(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
this.fs.writeFile(versionMarker, `${sourceVersion}\n`);
|
||||
@@ -339,6 +355,37 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
function hashContent(content: string): string {
|
||||
/** A plugin file's content: its bytes, or its text from an fs that only reads text. */
|
||||
type FileContent = string | Uint8Array;
|
||||
|
||||
/** The same digest for a text and for its UTF-8 bytes, so a text compare and a byte compare agree. */
|
||||
function hashContent(content: FileContent): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/** The daemon's own filesystem for vendoring: files are copied and compared as bytes. */
|
||||
export function nodePluginVendorFs(): PluginVendorFs {
|
||||
return {
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
|
||||
readBytes: (p) => fs.readFileSync(p),
|
||||
writeBytes: (p, c) => fs.writeFileSync(p, c),
|
||||
copyFileExclusive: (src, dest) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
mkdirp: (p) => { fs.mkdirSync(p, { recursive: true }); },
|
||||
listFiles: (dir) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
const w = (d: string, pre: string) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
};
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p) => fs.statSync(p).mode,
|
||||
chmod: (p, m) => fs.chmodSync(p, m),
|
||||
};
|
||||
}
|
||||
@@ -1049,30 +1049,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// (repo currently empty as of 2026-05-10; vendored
|
||||
// copy is the source of truth at v0).
|
||||
try {
|
||||
const { PluginVendorService } = await import("./domain/plugin-vendor-service.js");
|
||||
const { PluginVendorService, nodePluginVendorFs } = await import("./domain/plugin-vendor-service.js");
|
||||
const vendoredAssetsDir = nodePath.resolve(import.meta.dirname, "../assets/plugins");
|
||||
const userPluginsDir = getDefaultOpenRigPath("plugins");
|
||||
const realFs = {
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
|
||||
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p: string) => fs.existsSync(p),
|
||||
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
|
||||
listFiles: (dir: string) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
function w(d: string, pre: string) {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
}
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p: string) => fs.statSync(p).mode,
|
||||
chmod: (p: string, m: number) => fs.chmodSync(p, m),
|
||||
};
|
||||
const httpClient = async (url: string, opts?: { timeoutMs?: number }) => {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), opts?.timeoutMs ?? 5000);
|
||||
@@ -1086,7 +1065,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
const vendorService = new PluginVendorService({
|
||||
vendoredAssetsDir,
|
||||
userPluginsDir,
|
||||
fs: realFs,
|
||||
fs: nodePluginVendorFs(),
|
||||
httpClient,
|
||||
logger: (...args) => console.log("[openrig]", ...args),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Vendoring copies a bundled plugin into ~/.openrig/plugins byte for byte, binary files included: a plugin's icon.png
|
||||
// must arrive exactly as it ships, and a text file must arrive as it always did.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { nodePluginVendorFs, PluginVendorService } from "../src/domain/plugin-vendor-service.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
/** A PNG header and bytes that aren't valid UTF-8, so a text round trip would change them. */
|
||||
const ICON = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from([0xff, 0xfe, 0x80, 0x00, 0xc3, 0x28, 0xa0, 0xa1])]);
|
||||
const sha = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
||||
const httpClient = async () => ({ ok: false, status: 404 });
|
||||
|
||||
function scratch(): { assets: string; plugins: string } {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-vendor-binary-"));
|
||||
dirs.push(root);
|
||||
return { assets: nodePath.join(root, "assets"), plugins: nodePath.join(root, "home", "plugins") };
|
||||
}
|
||||
|
||||
function shipPlugin(assets: string, version: string, icon = ICON): void {
|
||||
const plugin = nodePath.join(assets, "openrig");
|
||||
fs.mkdirSync(nodePath.join(plugin, ".claude-plugin"), { recursive: true });
|
||||
fs.mkdirSync(nodePath.join(plugin, "skills", "demo"), { recursive: true });
|
||||
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "openrig", version, description: "Team — work" }));
|
||||
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "icon.png"), icon);
|
||||
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "SKILL.md"), "# Demo — a skill\n");
|
||||
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "badge.png"), icon);
|
||||
}
|
||||
|
||||
const service = (assets: string, plugins: string) =>
|
||||
new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: plugins, fs: nodePluginVendorFs(), httpClient });
|
||||
|
||||
describe("vendoring a plugin with a binary file", () => {
|
||||
it("installs the icon byte for byte, and its text files as before", async () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
const installed = fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png"));
|
||||
expect(sha(installed)).toBe(sha(ICON));
|
||||
expect(fs.readFileSync(nodePath.join(plugins, "openrig", "skills", "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
|
||||
});
|
||||
|
||||
it("leaves an identical install alone at the same version, and refreshes a newer one exactly, keeping the old bytes", async () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
const backups = nodePath.join(nodePath.dirname(plugins), "backups");
|
||||
expect(fs.existsSync(backups)).toBe(false);
|
||||
|
||||
const newer = Buffer.concat([ICON, Buffer.from([0xfd, 0xbe])]);
|
||||
fs.rmSync(assets, { recursive: true });
|
||||
shipPlugin(assets, "0.2.0", newer);
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
expect(sha(fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png")))).toBe(sha(newer));
|
||||
const kept = fs.readdirSync(nodePath.join(backups, "managed-refresh"))
|
||||
.map((id) => nodePath.join(backups, "managed-refresh", id, ".claude-plugin", "icon.png"))
|
||||
.filter((path) => fs.existsSync(path));
|
||||
expect(kept.map((path) => sha(fs.readFileSync(path)))).toEqual([sha(ICON)]);
|
||||
});
|
||||
|
||||
it("projects a skill's binary file into a global skill root byte for byte", () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
const root = nodePath.join(nodePath.dirname(plugins), "global-skills");
|
||||
service(assets, plugins).ensureSkillDirGlobally(nodePath.join(assets, "openrig", "skills", "demo"), "demo", "0.1.0", [root]);
|
||||
expect(sha(fs.readFileSync(nodePath.join(root, "demo", "badge.png")))).toBe(sha(ICON));
|
||||
expect(fs.readFileSync(nodePath.join(root, "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user