/* RFC-0790: Convention-based discovery IO helpers. Replaces registry-based readRegistry/writeRegistry with per-system system-config.yaml and system-state.yaml in cache clones. RFC-0574: mirror path resolution helpers (preserved, signature updated for SystemConfig). RFC-0751: findServiceEntry helper (preserved, reads from services/registry.yaml). Do not implement command logic — that lives in the individual command files. RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1284: default system state gains accessPinSuspended: null — the durable suspension marker read/written via the non-smart cache-clone store (Q-6) REVIEW-CODE-2026-10-16-08 N6: writeSystemState threads the caller's WorkspaceIO through its internal git add/commit/push instead of falling back to the ambient adapter, and reports push failures through an injected warn channel. RFC-1307: review-08 N1-N7 — ref-scoped rollback, pre-existing content preservation, svg resolution, explicit io REVIEW-CODE-2026-10-16-09 N11: writeSystemState pushes via array-args execOrThrow — the remote-derived branch name never reaches a shell-interpolated command string. ADR-0040, RFC-0354, RFC-0574, RFC-0751, RFC-0790, RFC-0794, RFC-0966, RFC-0967, RFC-0981, RFC-1097, RFC-1149, RFC-1159 */ import path from "node:path"; import { createDefaultIO, execOrThrow, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import { gitExec } from "../werkstatt/git-exec.ts"; import { cacheCloneCommit } from "../mission/mission-git-commit.ts"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { systemConfigSchema, systemStateSchema, servicesRegistrySchema, type SystemConfig, type SystemState, type ServicesRegistry, type MirrorEntry, type ServiceEntry, } from "@warpgogol/werkstatt-engine/schemas"; import { atomicWriteFile } from "../werkstatt/atomic.ts"; import type { SignedSitePassport } from "./passport.ts"; // --- RFC-0790: Convention-based path resolution --- const SYSTEMS_CACHE_DIR = path.join("..", "systems-cache"); const SERVICES_REGISTRY_PATH = path.join("services", "registry.yaml"); /** * Resolves the expected cache clone path for a system by convention. * * @returns The computed cache clone path. Always returns a string — the * directory may not exist on disk. Callers MUST check with * `existsSync` before relying on the path. */ export function resolveCacheClonePath(workspaceRoot: string, systemId: string): string { return path.resolve(workspaceRoot, SYSTEMS_CACHE_DIR, systemId); } /** * Resolves the expected workpiece path for a mission by convention. * * @returns The computed workpiece path. Always returns a string — the * directory may not exist on disk. Callers MUST check with * `existsSync` before relying on the path. */ export function resolveWorkpiecePath(workspaceRoot: string, missionId: string): string { return path.join(workspaceRoot, "missions", missionId, "workpiece"); } // --- RFC-0790: Per-system config/state IO (cache clone — OUTSIDE mission) --- export async function readSystemConfig( workspaceRoot: string, systemId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const cacheClone = resolveCacheClonePath(workspaceRoot, systemId); const filePath = path.join(cacheClone, "system-config.yaml"); const raw = await io.readFile(filePath); const parsed = parseYaml(raw); return systemConfigSchema.parse(parsed); } export async function readSystemConfigFromWorkpiece( workpieceDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const filePath = path.join(workpieceDir, "system-config.yaml"); const raw = await io.readFile(filePath); const parsed = parseYaml(raw); return systemConfigSchema.parse(parsed); } export async function writeSystemConfig( workspaceRoot: string, systemId: string, config: SystemConfig, io: WorkspaceIO = createDefaultIO().io, ): Promise { const cacheClone = resolveCacheClonePath(workspaceRoot, systemId); const filePath = path.join(cacheClone, "system-config.yaml"); await io.mkdir(path.dirname(filePath)); const yaml = stringifyYaml(config); await atomicWriteFile(filePath, yaml + "\n", io); } export async function readSystemState( workspaceRoot: string, systemId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const cacheClone = resolveCacheClonePath(workspaceRoot, systemId); const filePath = path.join(cacheClone, "system-state.yaml"); if (!(await io.exists(filePath))) { return { schemaVersion: "1.0.0", systemId, currentMission: null, lastRelease: null, lastPropagated: {}, accessPin: null, accessPinSuspended: null, passportRequired: false, ownershipRequired: false, }; } const raw = await io.readFile(filePath); const parsed = parseYaml(raw); return systemStateSchema.parse(parsed); } export async function readSystemStateFromWorkpiece( workpieceDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const filePath = path.join(workpieceDir, "system-state.yaml"); if (!(await io.exists(filePath))) { throw new Error( `[readSystemStateFromWorkpiece] system-state.yaml not found in workpiece ${workpieceDir}`, ); } const raw = await io.readFile(filePath); const parsed = parseYaml(raw); return systemStateSchema.parse(parsed); } export async function writeSystemStateToWorkpiece( workpieceDir: string, state: SystemState, io: WorkspaceIO = createDefaultIO().io, ): Promise { const filePath = path.join(workpieceDir, "system-state.yaml"); await io.mkdir(path.dirname(filePath)); const yaml = stringifyYaml(state); await atomicWriteFile(filePath, yaml + "\n", io); } export async function writeSystemState( workspaceRoot: string, systemId: string, state: SystemState, io: WorkspaceIO = createDefaultIO().io, onWarn?: (message: string) => void, ): Promise { const cacheClone = resolveCacheClonePath(workspaceRoot, systemId); const filePath = path.join(cacheClone, "system-state.yaml"); await io.mkdir(path.dirname(filePath)); const yaml = stringifyYaml(state); await atomicWriteFile(filePath, yaml + "\n", io); // Auto-commit to cache clone git if (await io.exists(path.join(cacheClone, ".git"))) { try { await execOrThrow(io, "git", ["add", "system-state.yaml"], { cwd: cacheClone }); await cacheCloneCommit(cacheClone, `system-state: update ${systemId}`, undefined, io); } catch { // Git commit may fail if nothing changed — non-fatal } // RFC-0794: Push to bare repo so syncCacheClone's git reset --hard origin/main // does not discard the commit. Without this push, mission.materialize loses // the system-state.yaml update written by mission.open. // RFC-0981: Cache clones are in detached HEAD state — rev-parse --abbrev-ref // HEAD returns "HEAD", causing "git push origin HEAD" to fail. Resolve the // branch name via symbolic-ref and use a fully-qualified refspec. try { let branch = "main"; try { const ref = await gitExec( cacheClone, "symbolic-ref --short refs/remotes/origin/HEAD", { allowNonZero: true }, io, ); branch = ref.replace("origin/", "") || "main"; } catch { // No remote HEAD set — default to main } // RFC-0986: Use --force-with-lease — cache clone is the source of truth. // Fixes non-fast-forward errors when bare repo diverged after last fetch. // Array-args exec — the remote-derived branch name must never reach a // shell-interpolated command string (REVIEW-CODE-2026-10-16-09 N11). await execOrThrow( io, "git", ["push", "--force-with-lease", "origin", `HEAD:refs/heads/${branch}`], { cwd: cacheClone }, ); } catch (err) { // Push may fail if no bare repo is configured or branch diverged — non-fatal const msg = err instanceof Error ? err.message : String(err); (onWarn ?? console.warn)(`[writeSystemState] git push failed for ${systemId}: ${msg}`); } } } // --- RFC-0790: Discovery (scan ../systems-cache/) --- export interface DiscoveryResult { systems: SystemConfig[]; errors: Array<{ id: string; error: string }>; } export async function discoverSystems( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const cacheRoot = path.resolve(workspaceRoot, SYSTEMS_CACHE_DIR); const systems: SystemConfig[] = []; const errors: Array<{ id: string; error: string }> = []; if (!(await io.exists(cacheRoot))) { return { systems, errors }; } const entries = await io.readdir(cacheRoot); for (const entry of entries) { if (!entry.isDirectory) continue; const systemId = entry.name; if (systemId.startsWith(".")) continue; try { const config = await readSystemConfigSmart(workspaceRoot, systemId, io); systems.push(config); } catch (err) { errors.push({ id: systemId, error: err instanceof Error ? err.message : String(err), }); } } return { systems, errors }; } // --- RFC-0790: Smart IO (workpiece-aware, falls back to cache clone) --- // During an active mission, system-config.yaml and system-state.yaml live in the // workpiece. These functions check for an active mission workpiece and read/write // there. When no mission is active, they fall back to the cache clone. export async function resolveActiveWorkpieceDir( workspaceRoot: string, systemId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { // Read state from cache clone to find currentMission const cacheClone = resolveCacheClonePath(workspaceRoot, systemId); const statePath = path.join(cacheClone, "system-state.yaml"); if (!(await io.exists(statePath))) return null; try { const raw = await io.readFile(statePath); const parsed = parseYaml(raw); const state = systemStateSchema.parse(parsed); if (!state.currentMission) return null; const workpieceDir = resolveWorkpiecePath(workspaceRoot, state.currentMission); if (!(await io.exists(workpieceDir))) return null; // Verify system-config.yaml exists in workpiece (materialized) if (!(await io.exists(path.join(workpieceDir, "system-config.yaml")))) return null; return workpieceDir; } catch { return null; } } export async function readSystemConfigSmart( workspaceRoot: string, systemId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const workpieceDir = await resolveActiveWorkpieceDir(workspaceRoot, systemId, io); if (workpieceDir) { return readSystemConfigFromWorkpiece(workpieceDir, io); } return readSystemConfig(workspaceRoot, systemId, io); } export async function readSystemStateSmart( workspaceRoot: string, systemId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const workpieceDir = await resolveActiveWorkpieceDir(workspaceRoot, systemId, io); if (workpieceDir) { const filePath = path.join(workpieceDir, "system-state.yaml"); if (await io.exists(filePath)) { const raw = await io.readFile(filePath); const parsed = parseYaml(raw); return systemStateSchema.parse(parsed); } // Fallback: read from cache clone if not yet in workpiece } return readSystemState(workspaceRoot, systemId, io); } export async function writeSystemStateSmart( workspaceRoot: string, systemId: string, state: SystemState, io: WorkspaceIO = createDefaultIO().io, ): Promise { const workpieceDir = await resolveActiveWorkpieceDir(workspaceRoot, systemId, io); if (workpieceDir) { await writeSystemStateToWorkpiece(workpieceDir, state, io); return; } await writeSystemState(workspaceRoot, systemId, state, io); } export async function writeSystemConfigSmart( workspaceRoot: string, systemId: string, config: SystemConfig, io: WorkspaceIO = createDefaultIO().io, ): Promise { const workpieceDir = await resolveActiveWorkpieceDir(workspaceRoot, systemId, io); if (workpieceDir) { const filePath = path.join(workpieceDir, "system-config.yaml"); await io.mkdir(path.dirname(filePath)); const yaml = stringifyYaml(config); await atomicWriteFile(filePath, yaml + "\n", io); return; } await writeSystemConfig(workspaceRoot, systemId, config, io); } // --- RFC-0790: Services registry (remains in monorepo) --- export async function readServicesRegistry( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const filePath = path.join(workspaceRoot, SERVICES_REGISTRY_PATH); const raw = await io.readFile(filePath); const parsed = parseYaml(raw); return servicesRegistrySchema.parse(parsed); } export function findServiceEntry(registry: ServicesRegistry, id: string): ServiceEntry | undefined { return registry.services.find((s) => s.id === id); } export async function writeServicesRegistry( workspaceRoot: string, registry: ServicesRegistry, io: WorkspaceIO = createDefaultIO().io, ): Promise { const filePath = path.join(workspaceRoot, SERVICES_REGISTRY_PATH); const yaml = stringifyYaml(registry) + "\n"; await io.writeFile(filePath, yaml); } // --- Existing helpers (preserved) --- export async function hasAppsCollision( workspaceRoot: string, id: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const appsDir = path.join(workspaceRoot, "apps", id); return io.exists(appsDir); } // --- RFC-0574: Mirror path resolution (signature updated for SystemConfig) --- export type MirrorProtocol = "file" | "ssh" | "https" | "ftp" | "s3" | "rsync"; const GIT_PROTOCOLS: MirrorProtocol[] = ["file", "ssh", "https"]; export function inferMirrorProtocol(mirrorPath: string): MirrorProtocol { if (mirrorPath.startsWith("git@") || mirrorPath.startsWith("ssh://")) return "ssh"; if (mirrorPath.startsWith("https://") || mirrorPath.startsWith("http://")) return "https"; if (mirrorPath.startsWith("ftp://") || mirrorPath.startsWith("sftp://")) return "ftp"; if (mirrorPath.startsWith("s3://")) return "s3"; if (mirrorPath.startsWith("rsync://")) return "rsync"; return "file"; } export function isGitAccessible(mirrorPath: string): boolean { return GIT_PROTOCOLS.includes(inferMirrorProtocol(mirrorPath)); } /** * Resolves a mirror path relative to the workspace root. * * @returns The resolved mirror path. Always returns a string — the * path may not exist on disk. Callers MUST check with * `existsSync` before relying on the path. For non-local * protocols (ssh, https, ftp, s3, rsync) the returned string * is the raw mirror path as-is. */ export function resolveMirrorPath(workspaceRoot: string, mirrorPath: string): string { if (mirrorPath.startsWith("file://")) { return path.resolve(workspaceRoot, mirrorPath.slice("file://".length)); } if (mirrorPath.startsWith("./") || mirrorPath.startsWith("../") || mirrorPath.startsWith("/")) { return path.resolve(workspaceRoot, mirrorPath); } return mirrorPath; } export interface MirrorResolution { cachePath: string; gitMirrors: MirrorEntry[]; backupMirrors: MirrorEntry[]; } export function resolveMirrors(workspaceRoot: string, config: SystemConfig): MirrorResolution { const cacheMirror = config.mirrors[0]; const cachePath = resolveMirrorPath(workspaceRoot, cacheMirror.path); const gitMirrors: MirrorEntry[] = []; const backupMirrors: MirrorEntry[] = []; for (let i = 1; i < config.mirrors.length; i++) { const m = config.mirrors[i]; if (m.storageType === "bundle") { backupMirrors.push(m); } else if (isGitAccessible(m.path)) { gitMirrors.push(m); } } return { cachePath, gitMirrors, backupMirrors }; } // --- RFC-0966: Passport IO helpers --- export function resolvePassportPath(workspaceRoot: string, systemId: string): string { const cachePath = resolveCacheClonePath(workspaceRoot, systemId); return path.join(cachePath, "passport.json"); } export async function readPassport( workspaceRoot: string, systemId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const filePath = resolvePassportPath(workspaceRoot, systemId); if (!(await io.exists(filePath))) return null; const raw = await io.readFile(filePath); return JSON.parse(raw) as SignedSitePassport; } export async function writePassport( workspaceRoot: string, systemId: string, doc: SignedSitePassport, io: WorkspaceIO = createDefaultIO().io, ): Promise { const filePath = resolvePassportPath(workspaceRoot, systemId); const content = JSON.stringify(doc, null, 2) + "\n"; await atomicWriteFile(filePath, content, io); return filePath; }