/* Shared utilities for RFC-0566 deploy commands — artifact paths, hashing, symlink management. Do not implement command handlers — those live in separate files. Do not define types — those live in types.ts. RFC-0566: initial deploy utilities — artifact paths, hashing, symlink management. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. */ import path from "node:path"; import { collectFiles } from "@warpgogol/werkstatt-shared/node/fs"; import { byteHash } from "@warpgogol/werkstatt-engine/fingerprint"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import type { ArtifactFile, ArtifactManifest } from "./types.ts"; export const PLATFORM_ARTIFACTS_DIR = ".werkstatt/artifacts/platform"; export const CURRENT_SYMLINK = "current"; export const PREVIOUS_SYMLINK = "previous"; export function platformArtifactsBase(workspaceRoot: string): string { return path.join(workspaceRoot, PLATFORM_ARTIFACTS_DIR); } export function artifactDir(workspaceRoot: string, hash: string): string { return path.join(platformArtifactsBase(workspaceRoot), hash); } export function currentSymlinkPath(workspaceRoot: string): string { return path.join(platformArtifactsBase(workspaceRoot), CURRENT_SYMLINK); } export function previousSymlinkPath(workspaceRoot: string): string { return path.join(platformArtifactsBase(workspaceRoot), PREVIOUS_SYMLINK); } export function manifestPath(workspaceRoot: string, hash: string): string { return path.join(artifactDir(workspaceRoot, hash), "manifest.json"); } export function distPath(workspaceRoot: string, hash: string): string { return path.join(artifactDir(workspaceRoot, hash), "dist"); } export async function hashArtifactDir( dir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise<{ treeHash: string; files: ArtifactFile[]; totalSize: number; }> { const files: ArtifactFile[] = []; let totalSize = 0; if (!(await io.exists(dir))) { return { treeHash: "sha256:empty", files: [], totalSize: 0 }; } const allFiles = await collectFiles(dir); for (const fullPath of allFiles) { const relPath = path.relative(dir, fullPath).replace(/\\/g, "/"); const data = await io.readFileBytes(fullPath); const hash = byteHash(data); files.push({ path: relPath, hash, size: data.length }); totalSize += data.length; } files.sort((a, b) => a.path.localeCompare(b.path)); const combinedInput = files.map((f) => `${f.path}\n${f.hash}`).join("\n"); const treeHash = byteHash(combinedInput); return { treeHash, files, totalSize }; } export async function readManifest( workspaceRoot: string, hash: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const mp = manifestPath(workspaceRoot, hash); if (!(await io.exists(mp))) { throw new Error(`[deploy] manifest not found for artifact ${hash}: ${mp}`); } const raw = await io.readFile(mp); return JSON.parse(raw) as ArtifactManifest; } export async function writeManifest( workspaceRoot: string, hash: string, manifest: ArtifactManifest, io: WorkspaceIO = createDefaultIO().io, ): Promise { const mp = manifestPath(workspaceRoot, hash); await io.mkdir(path.dirname(mp)); await io.writeFile(mp, JSON.stringify(manifest, null, 2) + "\n"); } export async function readSymlinkTarget( symlinkPath: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (!(await io.exists(symlinkPath))) return null; try { const result = await io.exec("readlink", [symlinkPath]); return result.exitCode === 0 ? result.stdout.trim() : null; } catch { return null; } } export async function readSymlinkBasename( symlinkPath: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const target = await readSymlinkTarget(symlinkPath, io); if (!target) return null; return path.basename(target); } export async function atomicSymlinkSwap( symlinkPath: string, targetDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const tmpPath = `${symlinkPath}.tmp-${process.pid}-${Date.now()}`; const lnResult = await io.exec("ln", ["-s", targetDir, tmpPath]); if (lnResult.exitCode !== 0) { throw new Error(`ln -s exited ${lnResult.exitCode}: ${lnResult.stderr.slice(0, 200)}`); } try { await io.rename(tmpPath, symlinkPath); } catch (err) { try { await io.rm(tmpPath); } catch { // ignore } throw err; } } export async function listArtifactHashes( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const base = platformArtifactsBase(workspaceRoot); if (!(await io.exists(base))) return []; const entries = await io.readdir(base); const hashes: string[] = []; for (const entry of entries) { if (entry.isDirectory && entry.name !== CURRENT_SYMLINK && entry.name !== PREVIOUS_SYMLINK) { hashes.push(entry.name); } } return hashes.sort(); }