feat(daemon): PluginVendorService + daemon startup wire (slice 3.2 Phase 2)

Per IMPL-PRD §2.5 + DESIGN.md §5.5 + orch direction 2026-05-10. Makes
the 10 TDD-red tests in commit 63f7268 pass.

PluginVendorService (new packages/daemon/src/domain/plugin-vendor-service.ts):
- ensureVendored(name): copy vendored asset tree from
  packages/daemon/assets/plugins/<name>/ to ~/.openrig/plugins/<name>/.
  Idempotent — hash-skip when file content already matches per file.
  No-op when vendored asset doesn't exist.
- attemptAutoFetch(name): try to fetch latest from
  github.com/mvschwarz/openrig-plugins/releases/latest/download/<name>.tar.gz.
  Tolerates 404 + network errors + 5s timeout silently per orch direction
  (vendored is ALWAYS the fallback). Logs outcome for operator
  observability.
- ensureLatest(name): orchestrates vendored-first then fetch-attempt.
- Fully unit-testable — all fs ops + httpClient injectable.

Tarball extraction NOT implemented at v0 (success-path returns log;
when slice 3.6 lands marketplace-consumption, that's where extract logic
lives). Acceptable per IMPL-PRD §2.5 because repo is currently empty
+ founder authorization deferred publish; 404 is the expected normal-
state response.

Daemon startup wire (packages/daemon/src/startup.ts):
- After Codex feature flag setup, instantiate PluginVendorService with
  real fs + AbortController-bounded fetch (5s timeout) + console logger
- Call vendorService.ensureLatest('openrig-core') best-effort wrapped
  in try/catch (best-effort like reference docs vendoring; daemon does
  not fail to start on plugin vendoring problems)

Verification:
- plugin-vendor-service.test.ts: 10/10 pass
- Full daemon suite: 3206 pass; 1 fail (pre-existing rsi-v2 only)
- daemon tsc clean

This commit closes slice 3.2. ACK + walk packets in next commit; then
slice 3.4 BLOCKED on redo-driver-2's slice 3.3 (plugin-discovery-service
+ GET routes) per orch correction 2026-05-10.

Per IMPL-PRD §2 plugin-primitive Phase 3a slice 3.2 + per banked
feedback_tdd_scope (TDD red→green discipline).
This commit is contained in:
mvschwarz committed 2026-05-10 04:52:35 -07:00
1 parent 63f7268702
commit 67cafeca97
2 files changed
+194

No files matched your search

@@ -0,0 +1,146 @@
// PluginVendorService — vendoring + auto-fetch for plugin trees.
//
// Per plugin-primitive Phase 3a slice 3.2 (IMPL-PRD §2.5 + DESIGN.md §5.5).
//
// Responsibilities:
// 1. ensureVendored(name): copy from packages/daemon/assets/plugins/<name>/
// to ~/.openrig/plugins/<name>/ on first launch (similar to how
// ~/.openrig/reference/ docs are copied today). Idempotent: hash-skip
// when content already matches.
// 2. attemptAutoFetch(name): try to fetch latest from
// github.com/mvschwarz/openrig-plugins. Tolerates 404, network errors,
// and timeouts silently per orch direction 2026-05-10 (vendored is
// always the fallback). Logs outcome for operator observability.
// 3. ensureLatest(name): orchestrates ensureVendored first, then
// attemptAutoFetch. The vendored copy is ALWAYS available even if
// the fetch fails for any reason.
//
// Design notes:
// - All fs ops + httpClient are injectable (testable without real
// filesystem or network).
// - 5s network timeout per IMPL-PRD §2.5.
// - The repo at github.com/mvschwarz/openrig-plugins is currently empty
// per founder authorization 2026-05-10 (LICENSE only); 404 is the
// expected normal-state response until a separate publish authorization.
import nodePath from "node:path";
import { createHash } from "node:crypto";
export interface PluginVendorFs {
readFile(path: string): string;
writeFile(path: string, content: string): void;
exists(path: string): boolean;
mkdirp(path: string): void;
listFiles(dir: string): string[];
rmrf?(path: string): void;
}
export interface HttpClientResponse {
ok: boolean;
status: number;
/** Body parsed by caller — not exercised in v0 since fetch failures are
* the expected normal-state. Future tarball-extraction would consume this. */
body?: unknown;
}
export type HttpClient = (url: string, opts?: { timeoutMs?: number }) => Promise<HttpClientResponse>;
export interface PluginVendorServiceDeps {
vendoredAssetsDir: string;
userPluginsDir: string;
fs: PluginVendorFs;
httpClient: HttpClient;
logger?: (...args: unknown[]) => void;
}
const DEFAULT_TIMEOUT_MS = 5000;
const REPO_BASE = "https://github.com/mvschwarz/openrig-plugins";
export class PluginVendorService {
private vendoredAssetsDir: string;
private userPluginsDir: string;
private fs: PluginVendorFs;
private httpClient: HttpClient;
private logger: (...args: unknown[]) => void;
constructor(deps: PluginVendorServiceDeps) {
this.vendoredAssetsDir = deps.vendoredAssetsDir;
this.userPluginsDir = deps.userPluginsDir;
this.fs = deps.fs;
this.httpClient = deps.httpClient;
this.logger = deps.logger ?? (() => {});
}
/**
* Copy the vendored asset tree at <vendoredAssetsDir>/<pluginName>/ to the
* user plugin dir <userPluginsDir>/<pluginName>/ on first launch.
* Idempotent: hash-skip when source + dest content matches per file.
* No-op when the vendored asset doesn't exist (e.g. plugin not bundled).
*/
async ensureVendored(pluginName: string): Promise<void> {
const sourceDir = nodePath.join(this.vendoredAssetsDir, pluginName);
const targetDir = nodePath.join(this.userPluginsDir, pluginName);
if (!this.fs.exists(sourceDir)) {
this.logger(`[plugin-vendor] no vendored asset for "${pluginName}" at ${sourceDir}; skipping`);
return;
}
this.fs.mkdirp(targetDir);
const files = this.fs.listFiles(sourceDir);
for (const relPath of files) {
const srcPath = nodePath.join(sourceDir, relPath);
const destPath = nodePath.join(targetDir, relPath);
const content = this.fs.readFile(srcPath);
// Hash-skip: only write if content differs (idempotent re-runs)
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
continue;
}
this.fs.mkdirp(nodePath.dirname(destPath));
this.fs.writeFile(destPath, content);
}
}
/**
* Attempt to fetch the latest plugin tree from
* github.com/mvschwarz/openrig-plugins. Tolerates 404, network errors,
* and timeouts silently — vendored copy is ALWAYS the fallback.
* Logs outcome for operator observability.
*/
async attemptAutoFetch(pluginName: string): Promise<void> {
const url = `${REPO_BASE}/releases/latest/download/${pluginName}.tar.gz`;
try {
const response = await this.httpClient(url, { timeoutMs: DEFAULT_TIMEOUT_MS });
if (!response.ok) {
if (response.status === 404) {
this.logger(`[plugin-vendor] fetch ${pluginName} returned 404 (repo empty or release not published yet); falling back to vendored`);
} else {
this.logger(`[plugin-vendor] fetch ${pluginName} returned status ${response.status}; falling back to vendored`);
}
return;
}
// v0: tarball extraction NOT implemented — when slice 3.6 lands marketplace-
// consumption, this is where fetch-then-extract logic lives. For now,
// success path just logs.
this.logger(`[plugin-vendor] fetch ${pluginName} succeeded (${response.status}); v0 vendored copy still authoritative`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.logger(`[plugin-vendor] fetch ${pluginName} failed: ${msg}; falling back to vendored`);
}
}
/**
* Orchestrate vendored-first then fetch-attempt.
* The vendored copy lands FIRST so it's always the fallback even if
* the fetch path fails for any reason.
*/
async ensureLatest(pluginName: string): Promise<void> {
await this.ensureVendored(pluginName);
await this.attemptAutoFetch(pluginName);
}
}
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
+48
View File
@@ -411,6 +411,54 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
console.error(`[openrig] codex feature flag setup warning: ${(err as Error).message}`);
}
// plugin-primitive Phase 3a slice 3.2 — vendor openrig-core plugin to
// ~/.openrig/plugins/openrig-core/ on first launch. Auto-fetch from
// github.com/mvschwarz/openrig-plugins is best-effort + 404-tolerant
// (repo currently empty per founder authorization 2026-05-10; vendored
// copy is the source of truth at v0).
try {
const { PluginVendorService } = 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"),
exists: (p: string) => fs.existsSync(p),
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
listFiles: (dir: string) => {
const r: string[] = [];
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;
},
};
const httpClient = async (url: string, opts?: { timeoutMs?: number }) => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), opts?.timeoutMs ?? 5000);
try {
const resp = await fetch(url, { signal: ctrl.signal });
return { ok: resp.ok, status: resp.status };
} finally {
clearTimeout(timer);
}
};
const vendorService = new PluginVendorService({
vendoredAssetsDir,
userPluginsDir,
fs: realFs,
httpClient,
logger: (...args) => console.log("[openrig]", ...args),
});
await vendorService.ensureLatest("openrig-core");
} catch (err) {
console.error(`[openrig] plugin vendor setup warning: ${(err as Error).message}`);
}
// PL-014 Item 6: hoist ContextPackLibraryService construction so the
// PodRigInstantiator can resolve `kind: context_pack` startup_files
// entries at materialize time. Same instance is returned to deps