mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
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:
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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user