import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { describeConfig, editHostingBindings, editSitesConfig, } from "./config-edit.ts"; import { findSitesBundle } from "./core/bundle.ts"; import { loadSitesConfig, type SitesConfig } from "./core/config.ts"; import { getGitState } from "./core/git.ts"; import { readHostingConfig } from "./core/hosting.ts"; import { runSitesInit } from "./core/init.ts"; import { boundText, formatBytes } from "./core/output.ts"; import { runSitesPackage } from "./core/package.ts"; import { runSitesCheck } from "./core/validate.ts"; import { clearSitesFooter } from "./events.ts"; import { buildMenuStatus, openSitesEditMenu, openSitesMenu, renderMenuStatus, type SitesEditApi, type SitesMenuActions, } from "./menu.ts"; import { planRelease, type ReleasePlan, type ReleaseStep, runConnectorStep, summarizeReleasePlan, } from "./release.ts"; export const RELEASE_ENTRY_TYPE = "sites-release"; const WHITESPACE_RE = /\s+/; const SUBCOMMANDS = [ "init", "check", "package", "diagnose", "release", "status", "log", "edit", "config", "menu", "help", ] as const; type Subcommand = (typeof SUBCOMMANDS)[number]; interface ReleaseEntry { archive: string; sha: string; status: string; timestamp: string; } interface SessionEntryLike { customType?: string; data?: unknown; type?: string; } /** Read release-log entries (custom entries with type sites-release) from the session. */ export function readReleaseEntries(ctx: { sessionManager: { getEntries: () => SessionEntryLike[]; }; }): ReleaseEntry[] { const entries: ReleaseEntry[] = []; for (const entry of ctx.sessionManager.getEntries()) { if (entry.type !== "custom" || entry.customType !== RELEASE_ENTRY_TYPE) { continue; } const data = entry.data as { archive?: string; sha?: string; status?: string; timestamp?: string; }; if ( typeof data.archive === "string" && typeof data.sha === "string" && typeof data.status === "string" && typeof data.timestamp === "string" ) { entries.push({ archive: data.archive, sha: data.sha, status: data.status, timestamp: data.timestamp, }); } } return entries; } /** * Render the persisted release log as text (newest last, oldest first * reading order). Pure — used by /sites log and unit tests. */ export function renderReleaseLog(entries: ReleaseEntry[]): string { if (entries.length === 0) { return "release log: no entries"; } return entries .map( (entry, index) => `${index + 1}. ${entry.status} ${entry.sha.slice(0, 7)} @ ${entry.timestamp} [${entry.archive}]` ) .join("\n"); } function runLog(ctx: ExtensionCommandContext): Promise { return Promise.resolve(renderReleaseLog(readReleaseEntries(ctx))); } /** Register a TUI entry renderer for release-log entries (transcript cards). */ export function registerReleaseEntryRenderer(pi: ExtensionAPI): void { pi.registerEntryRenderer(RELEASE_ENTRY_TYPE, (entry, { expanded }, theme) => { const data = entry.data as { archive?: string; notes?: string; sha?: string; status?: string; timestamp?: string; }; const line = `release ${data.status ?? "?"} ${(data.sha ?? "").slice(0, 7)} @ ${data.timestamp ?? "?"}`; let text = theme.fg("accent", line); if (expanded) { text += `\n${theme.fg("dim", `archive: ${data.archive ?? "?"}`)}`; if (data.notes !== undefined) { text += `\n${theme.fg("dim", `notes: ${data.notes}`)}`; } } return new Text(text, 0, 0); }); } function bundleNotFoundMessage(): string { return ( "Sites plugin bundle not found.\n" + "Install the Codex Sites plugin or set PI_SITES_BUNDLE to the bundle root\n" + "(e.g. $HOME/.codex/plugins/cache/openai-bundled/sites/)." ); } /** Bundle discovery honoring the .pi/sites.json bundle.path override. */ function bundleFor( ctx: ExtensionCommandContext ): ReturnType { return findSitesBundle(loadSitesConfig(ctx.cwd).bundle.path); } async function runInit( args: string, ctx: ExtensionCommandContext ): Promise { const bundle = bundleFor(ctx); if (bundle === null) { return bundleNotFoundMessage(); } const target = args.trim() === "" ? ctx.cwd : args.trim(); const result = await runSitesInit(target, bundle); const lines = [ result.ok ? "ok" : "failed", `target: ${result.target}`, result.message, ]; if (result.layout !== null) { lines.push(`layout: ${result.layout.join(", ")}`); } return boundText(lines.filter((line) => line !== "").join("\n")); } async function runCheck(ctx: ExtensionCommandContext): Promise { const result = await runSitesCheck(ctx.cwd); return boundText( [ `${result.items.length} items: ${result.summary.pass} pass, ${result.summary.fail} fail, ${result.summary.manual} manual, ${result.summary.skip} skip`, ...result.items.map( (item) => `${item.status} ${item.id} — ${item.label} [${item.evidence}]` ), ].join("\n") ); } async function runPackage(ctx: ExtensionCommandContext): Promise { const bundle = bundleFor(ctx); if (bundle === null) { return bundleNotFoundMessage(); } const archive = join(tmpdir(), `sites-package-${Date.now()}.tar.gz`); const result = await runSitesPackage(ctx.cwd, archive, bundle); const lines = [result.ok ? "ok" : "failed", `archive: ${result.archivePath}`]; if (result.sizeBytes !== null) { lines.push( `size: ${formatBytes(result.sizeBytes)} (${result.sizeBytes} bytes)` ); } lines.push(...result.errors); return boundText(lines.filter((line) => line !== "").join("\n")); } function hostingLine( projectId: string | undefined, d1: string | null | undefined, r2: string | null | undefined ): string { const parts: string[] = []; if (projectId === undefined) { parts.push("no project_id"); } else { parts.push(`proj ${projectId.slice(0, 6)}…`); } if (d1) { parts.push(`d1=${d1}`); } if (r2) { parts.push(`r2=${r2}`); } return parts.join(" · "); } function releaseLogLine(releases: ReleaseEntry[]): string { if (releases.length === 0) { return "release log: no entries"; } const latest = releases.at(-1); const plural = releases.length === 1 ? "entry" : "entries"; return `release log: ${releases.length} ${plural}, latest ${latest?.sha.slice(0, 8)} @ ${latest?.timestamp}`; } async function runDiagnose(ctx: ExtensionCommandContext): Promise { const git = await getGitState(ctx.cwd); const gitLine = git.hasRepo ? `git: ${git.branch ?? "?"} @ ${git.headSha ?? "no commit"}${git.dirty ? " (dirty)" : ""}` : `git: ${git.error ?? "not a git repository"}`; const hosting = readHostingConfig(ctx.cwd); const hostingJsonLine = hosting === null ? "hosting.json: missing or unparseable (not a Sites project?)" : `hosting.json: ${hostingLine(hosting.projectId, hosting.d1, hosting.r2)}`; const distServer = join(ctx.cwd, "dist", "server", "index.js"); const artifactLine = existsSync(distServer) ? "build artifact: dist/server/index.js present" : "build artifact: missing (run npm run build)"; const bundle = bundleFor(ctx); const bundleLine = bundle === null ? "bundle: missing" : `bundle: ${bundle.version} at ${bundle.path}`; const config = loadSitesConfig(ctx.cwd); const connectorText = config.connector.command === null ? "disabled" : config.connector.command.join(" "); return boundText( [ "pi-sites diagnosis", gitLine, hostingJsonLine, artifactLine, bundleLine, releaseLogLine(readReleaseEntries(ctx)), `config: promotion.enabled=${config.promotion.enabled}, connector.command=${connectorText}`, ].join("\n") ); } const CONTROL_PLANE_STEP_IDS = [ "save_version", "deploy_private", "access_env_domains", "logs", ] as const; type ControlPlaneStepId = (typeof CONTROL_PLANE_STEP_IDS)[number]; function stepStatusMark(step: ReleaseStep): string { if (step.status === "done") { return "x"; } return " "; } function connectorNote(stepId: string, connectorEnabled: boolean): string { if (stepId === "public_release") { return " (manual only — never automated)"; } if (connectorEnabled) { return " (connector automation available)"; } return " (manual — set connector.command in .pi/sites.json to enable codex exec automation)"; } function appendReleaseEntry( pi: ExtensionAPI, entry: { archive: string; notes: string; sha: string; status: string } ): void { pi.appendEntry(RELEASE_ENTRY_TYPE, { ...entry, timestamp: new Date().toISOString(), }); } async function runConnectorStepInteractive( pi: ExtensionAPI, ctx: ExtensionCommandContext, stepId: ControlPlaneStepId, stepLabel: string, plan: ReleasePlan, config: ReturnType, packageArchive: string | null, lines: string[] ): Promise { const result = await runConnectorStep(stepId, ctx.cwd, config, { archive: packageArchive, sha: plan.sha, }); if (!result.ok) { ctx.ui.notify( `Connector step failed: ${result.error ?? "unknown error"}`, "error" ); lines.push(`connector ${stepId}: ${result.error ?? "failed"}`); if (result.output !== "") { lines.push(result.output); } return; } ctx.ui.notify(`Connector step done: ${stepLabel}`, "info"); if (plan.sha === null || packageArchive === null) { return; } if (stepId === "save_version") { appendReleaseEntry(pi, { archive: packageArchive, notes: "version saved via connector", sha: plan.sha, status: "saved", }); } else if (stepId === "deploy_private") { appendReleaseEntry(pi, { archive: packageArchive, notes: "private deployment via connector", sha: plan.sha, status: "deployed-private", }); } } async function runInteractiveStep( pi: ExtensionAPI, ctx: ExtensionCommandContext, plan: ReleasePlan, config: ReturnType, checkOk: boolean, packageOk: boolean, packageArchive: string | null, lines: string[], index: number ): Promise { const stepId = CONTROL_PLANE_STEP_IDS[index]; if (stepId === undefined) { return; } const step = plan.steps.find((s) => s.id === stepId); if (step === undefined || step.status === "done") { await runInteractiveStep( pi, ctx, plan, config, checkOk, packageOk, packageArchive, lines, index + 1 ); return; } if (!(checkOk && packageOk)) { return; } const connectorEnabled = config.connector.command !== null; const choices: string[] = []; if (connectorEnabled) { choices.push("Run via connector (codex exec)"); } choices.push("I'll do this in ChatGPT web/desktop", "Skip"); const choice = await ctx.ui.select(step.label, choices); if (choice === undefined || choice === "Skip") { await runInteractiveStep( pi, ctx, plan, config, checkOk, packageOk, packageArchive, lines, index + 1 ); return; } if (choice === "I'll do this in ChatGPT web/desktop") { ctx.ui.notify(`Marked done in web/desktop: ${step.label}`, "info"); await runInteractiveStep( pi, ctx, plan, config, checkOk, packageOk, packageArchive, lines, index + 1 ); return; } if (connectorEnabled && choice === "Run via connector (codex exec)") { await runConnectorStepInteractive( pi, ctx, stepId, step.label, plan, config, packageArchive, lines ); } await runInteractiveStep( pi, ctx, plan, config, checkOk, packageOk, packageArchive, lines, index + 1 ); } async function runInteractiveControlPlaneSteps( pi: ExtensionAPI, ctx: ExtensionCommandContext, plan: ReleasePlan, config: ReturnType, checkOk: boolean, packageOk: boolean, packageArchive: string | null, lines: string[] ): Promise { if (!ctx.hasUI) { return; } await runInteractiveStep( pi, ctx, plan, config, checkOk, packageOk, packageArchive, lines, 0 ); } /** * Run the /sites release desk: exact commit SHA, dirty-tree refusal, local * gates (check + package), guided private-first control-plane steps, and a * persisted release log entry via appendEntry. */ async function runRelease( pi: ExtensionAPI, ctx: ExtensionCommandContext ): Promise { const lines: string[] = []; const git = await getGitState(ctx.cwd); const check = await runSitesCheck(ctx.cwd); const bundle = bundleFor(ctx); const archive = join(tmpdir(), `sites-package-${Date.now()}.tar.gz`); const packageResult = bundle === null ? { archivePath: archive, errors: [bundleNotFoundMessage()], ok: false } : await runSitesPackage(ctx.cwd, archive, bundle); const plan = await planRelease(git, { archivePath: packageResult.ok ? packageResult.archivePath : null, checkOk: check.ok, }); lines.push(summarizeReleasePlan(plan)); if (plan.dirtyRefused) { lines.push( "Release refused: the working tree is dirty (or not a git repo). " + "Commit or stash first; releases must come from an exact source commit." ); return boundText(lines.join("\n")); } if (plan.sha !== null && packageResult.ok) { appendReleaseEntry(pi, { archive: packageResult.archivePath, notes: "planned via /sites release", sha: plan.sha, status: "planned", }); lines.push(`release log: entry appended (sha ${plan.sha.slice(0, 12)})`); } if (!check.ok) { lines.push( `sites_check is not green (${check.summary.fail} fail) — resolve the failing items above before control-plane steps.` ); } if (!packageResult.ok) { lines.push( "sites_package did not produce an archive — fix before saving a version." ); } const config = loadSitesConfig(ctx.cwd); const connectorEnabled = config.connector.command !== null; lines.push(""); lines.push("Control-plane steps (private-first):"); for (const stepId of [...CONTROL_PLANE_STEP_IDS, "public_release"] as const) { const step = plan.steps.find((s) => s.id === stepId); if (step === undefined) { continue; } lines.push( `- [${stepStatusMark(step)}] ${step.label}${connectorNote(stepId, connectorEnabled)}` ); lines.push(` ${step.detail}`); } await runInteractiveControlPlaneSteps( pi, ctx, plan, config, check.ok, packageResult.ok, packageResult.ok ? packageResult.archivePath : null, lines ); lines.push(""); lines.push( "Public release is a deliberate policy change — perform it manually in ChatGPT web/desktop." ); return boundText(lines.join("\n")); } async function runStatus(ctx: ExtensionCommandContext): Promise { const releases = readReleaseEntries(ctx); const status = await buildMenuStatus( ctx.cwd, releases.map((data) => ({ customType: RELEASE_ENTRY_TYPE, data })) ); return renderMenuStatus(status); } // --- edit API (config / hosting bindings / release notes) ------------------- function latestReleaseLine(ctx: ExtensionCommandContext): string { const latest = readReleaseEntries(ctx).at(-1); if (latest === undefined) { return "release log: no entries"; } return `release log: ${latest.status} ${latest.sha.slice(0, 7)} @ ${latest.timestamp}`; } function buildEditApi( pi: ExtensionAPI, ctx: ExtensionCommandContext ): SitesEditApi { const configSummary = (): string => describeConfig(loadSitesConfig(ctx.cwd)); const hostingSummary = (): string => { const hosting = readHostingConfig(ctx.cwd); if (hosting === null) { return "hosting.json: missing or unparseable"; } return `hosting.json: ${hostingLine(hosting.projectId, hosting.d1, hosting.r2)}`; }; const applyConfig = async ( patch: Parameters[1] ): Promise => { const result = await editSitesConfig(ctx.cwd, patch); if (!result.ok) { return `config edit failed: ${result.error ?? "unknown error"}`; } return `config: ${describeConfig(result.config)}`; }; const applyBindings = async ( patch: Parameters[1] ): Promise => { const result = await editHostingBindings(ctx.cwd, patch); if (!result.ok) { return `hosting.json edit rejected: ${result.errors.join("; ")}`; } return `hosting.json: ${hostingSummary()}`; }; return { config: { clearBundlePath: () => applyConfig({ bundle: { path: null } }), clearConnectorCommand: () => applyConfig({ connector: { command: null } }), describe: configSummary, setBundlePath: (value) => applyConfig({ bundle: { path: value } }), setConnectorCommand: (value) => applyConfig({ connector: { command: value.trim() === "" ? null : value.trim().split(WHITESPACE_RE), }, }), togglePromotion: () => { const next = !loadSitesConfig(ctx.cwd).promotion.enabled; return applyConfig({ promotion: { enabled: next } }); }, }, hosting: { clearBinding: (key) => applyBindings({ [key]: null }), describe: hostingSummary, setBinding: (key, value) => applyBindings({ [key]: value }), }, release: { addNote: (note) => { const latest = readReleaseEntries(ctx).at(-1); if (latest === undefined) { return Promise.resolve( "release log: no entries — run /sites release first" ); } pi.appendEntry(RELEASE_ENTRY_TYPE, { ...latest, notes: note, timestamp: new Date().toISOString(), }); return Promise.resolve( `release log: note added to ${latest.sha.slice(0, 7)}` ); }, describe: () => latestReleaseLine(ctx), }, }; } /** /sites edit — open the edit menu (TUI) or headless instructions. */ async function runEdit( pi: ExtensionAPI, ctx: ExtensionCommandContext ): Promise { const api = buildEditApi(pi, ctx); const status = { configLine: api.config.describe(), hostingLine: api.hosting.describe(), releaseLine: api.release.describe(), }; const result = await openSitesEditMenu(ctx, api, status); if (result === null) { return "edit menu closed"; } return result; } // --- /sites config get|set (headless editing) -------------------------------- const CONFIG_KEYS = [ "promotion.enabled", "connector.command", "bundle.path", ] as const; type ConfigKey = (typeof CONFIG_KEYS)[number]; function configValueLine(config: SitesConfig, key: ConfigKey): string { switch (key) { case "promotion.enabled": return `${key}=${config.promotion.enabled}`; case "connector.command": return `${key}=${config.connector.command === null ? "null" : config.connector.command.join(" ")}`; case "bundle.path": return `${key}=${config.bundle.path ?? "null"}`; default: return `${key}=?`; } } /** Parse `/sites config ...` args into a structured intent. */ export function parseConfigArgs(args: string): { action: "get" | "set" | "help"; key: ConfigKey | null; value: string | null; } { const parts = args .trim() .split(WHITESPACE_RE) .filter((part) => part !== ""); const [action, key, ...rest] = parts; if (action !== "get" && action !== "set") { return { action: "help", key: null, value: null }; } if (key === undefined || !(CONFIG_KEYS as readonly string[]).includes(key)) { return { action: "help", key: null, value: null }; } return { action, key: key as ConfigKey, value: action === "set" ? (rest.join(" ") ?? "") : null, }; } /** Apply a parsed /sites config set intent; returns the result line. */ export async function applyConfigSet( dir: string, key: ConfigKey, value: string ): Promise<{ error: string | null; line: string | null }> { const patch = buildConfigPatch(key, value); if (patch === null) { return { error: `invalid value for ${key}: expected true|false`, line: null, }; } const result = await editSitesConfig(dir, patch); if (!result.ok) { return { error: result.error ?? "unknown error", line: null }; } return { error: null, line: configValueLine(result.config, key) }; } function buildConfigPatch( key: ConfigKey, value: string ): Parameters[1] | null { if (key === "promotion.enabled") { if (value === "true") { return { promotion: { enabled: true } }; } if (value === "false") { return { promotion: { enabled: false } }; } return null; } if (key === "connector.command") { if (value === "null" || value === "") { return { connector: { command: null } }; } return { connector: { command: value.split(WHITESPACE_RE) } }; } if (value === "null" || value === "") { return { bundle: { path: null } }; } return { bundle: { path: value } }; } async function runConfig( args: string, ctx: ExtensionCommandContext ): Promise { const intent = parseConfigArgs(args); const config = loadSitesConfig(ctx.cwd); if (intent.action === "help") { return [ "usage: /sites config get|set [value]", "keys: promotion.enabled (true|false), connector.command (words or null), bundle.path (path or null)", "current:", ...CONFIG_KEYS.map((key) => ` ${configValueLine(config, key)}`), ].join("\n"); } if (intent.key === null) { return "usage: /sites config get|set [value]"; } if (intent.action === "get") { return configValueLine(config, intent.key); } const applied = await applyConfigSet(ctx.cwd, intent.key, intent.value ?? ""); if (applied.error !== null) { return `config set failed: ${applied.error}`; } return `config: ${applied.line}`; } function printHelp(): string { return [ "pi-sites — ChatGPT Sites workflow", "", "Usage: /sites ", " init scaffold a new Sites project (target dir as argument)", " check run the release-readiness checklist", " package produce the deployment archive", " diagnose gather local diagnosis facts", " release open the release desk (exact commit, private-first)", " status show the current project status", " log show the persisted release log", " edit change or edit settings (config, bindings, release notes)", " config get/set config keys headlessly (promotion.enabled, connector.command, bundle.path)", " menu open the interactive Sites menu (TUI)", " help this help", "", "Config: .pi/sites.json (promotion.enabled, connector.command, bundle.path)", ].join("\n"); } function parseSubcommand(args: string): Subcommand | null { const token = args.trim().split(WHITESPACE_RE)[0] ?? ""; if (token === "") { return null; } return (SUBCOMMANDS as readonly string[]).includes(token) ? (token as Subcommand) : null; } type CommandRunner = ( pi: ExtensionAPI, ctx: ExtensionCommandContext, rest: string ) => Promise; async function dispatchSitesCommand( pi: ExtensionAPI, ctx: ExtensionCommandContext, sub: Subcommand | null, args: string, runners: Record ): Promise { if (sub === null) { if (ctx.hasUI) { await openSitesMenu(ctx, makeActionsFor(pi, ctx)); return; } emitHeadless(printHelp()); return; } if (sub === "help") { emitHeadless(printHelp()); return; } const runner = runners[sub]; if (runner === undefined) { emitHeadless(printHelp()); return; } const rest = args.trim().split(WHITESPACE_RE).slice(1).join(" "); const output = await runner(pi, ctx, rest); if (output === "") { return; } if (ctx.hasUI) { ctx.ui.notify(capOutput(output), "info"); } else { emitHeadless(output); } } /** Bound notify payloads so a toast never floods the UI. */ function capOutput(text: string): string { const MAX_NOTIFY_CHARS = 1200; return text.length <= MAX_NOTIFY_CHARS ? text : `${text.slice(0, MAX_NOTIFY_CHARS)}…`; } /** Register the /sites command family and the interactive menu. */ export function registerSitesCommands(pi: ExtensionAPI): void { const runners: Record = { check: (_pi, ctx) => runCheck(ctx), config: (_pi, ctx, rest) => runConfig(rest, ctx), diagnose: (_pi, ctx) => runDiagnose(ctx), edit: (piArg, ctx) => runEdit(piArg, ctx), init: (_pi, ctx, rest) => runInit(rest, ctx), log: (_pi, ctx) => runLog(ctx), menu: async (_pi, ctx) => { if (ctx.hasUI) { await openSitesMenu(ctx, makeActionsFor(pi, ctx)); return ""; } return runStatus(ctx); }, package: (_pi, ctx) => runPackage(ctx), release: (piArg, ctx) => runRelease(piArg, ctx), status: (_pi, ctx) => runStatus(ctx), }; pi.registerCommand("sites", { description: "ChatGPT Sites workflow: init, check, package, diagnose, release desk, status, menu", getArgumentCompletions(prefix: string) { const items = SUBCOMMANDS.map((value) => ({ label: value, value })); const filtered = items.filter((i) => i.value.startsWith(prefix)); return filtered.length > 0 ? filtered : null; }, async handler(args, ctx) { const sub = parseSubcommand(args); try { await dispatchSitesCommand(pi, ctx, sub, args, runners); } catch (error) { const message = error instanceof Error ? error.message : String(error); ctx.ui.notify(`/sites ${sub ?? ""} failed: ${message}`, "error"); emitHeadless(`/sites ${sub ?? ""} failed: ${message}`); } finally { try { clearSitesFooter(ctx); } catch { // footer cleanup must never break the command } } }, }); } function makeActionsFor( pi: ExtensionAPI, ctx: ExtensionCommandContext ): SitesMenuActions { return { check: () => runCheck(ctx), diagnose: () => runDiagnose(ctx), edit: () => runEdit(pi, ctx), init: () => runInit("", ctx), package: () => runPackage(ctx), release: () => runRelease(pi, ctx), status: () => runStatus(ctx), }; } /** Headless degradation: print-mode output goes to stdout. */ function emitHeadless(text: string): void { process.stdout.write(`${text}\n`); }