diff --git a/AGENTS.md b/AGENTS.md index 784b37cc47b2..e70351ce6fce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,19 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - One concern per PR. If the description says "also", split it. - When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. +## Public repository boundary + +Keep deployment-specific infrastructure out of this public repository. Do not +commit real machine names, private endpoints, host roles, network topology, +private ports, operator schedules, or paths that identify a maintainer's +environment. Public automation must expose environment-neutral tools and +document their contracts with generic examples. Put the concrete wiring and +deployment policy in the private infrastructure repository that owns it. + +Before publishing a LastCode change, inspect the diff, PR body, issue text, and +review replies for infrastructure details. Test fixtures must use unmistakably +generic names such as `workstation.example`, `managed-server`, or `build-host`. + ## Plans and work artifacts - Do not commit implementation plans, research notes, or agent scratch files. Keep temporary working material outside the worktree. `.plans/` is gitignored only as a safety net for legacy tooling. diff --git a/apps/web/src/components/settings/LastCodeSettings.logic.test.ts b/apps/web/src/components/settings/LastCodeSettings.logic.test.ts index 73ac759988f2..a29fde87c658 100644 --- a/apps/web/src/components/settings/LastCodeSettings.logic.test.ts +++ b/apps/web/src/components/settings/LastCodeSettings.logic.test.ts @@ -34,7 +34,7 @@ describe("deriveLastCodeEnvironmentSettingEntries", () => { entry( new PrimaryConnectionTarget({ environmentId: primary, - label: "Airy", + label: "Workstation", httpBaseUrl: "http://localhost", wsBaseUrl: "ws://localhost/ws", }), @@ -65,7 +65,7 @@ describe("deriveLastCodeEnvironmentSettingEntries", () => { expect( deriveLastCodeEnvironmentSettingEntries({ entries, primaryEnvironmentId: primary }), ).toEqual([ - { environmentId: primary, kind: "local", label: "Airy (local)" }, + { environmentId: primary, kind: "local", label: "Workstation (local)" }, { environmentId: production, kind: "remote", label: "Production" }, { environmentId: buildbox, kind: "remote", label: "Buildbox" }, { environmentId: ssh, kind: "remote", label: "SSH Lab" }, diff --git a/apps/web/src/environmentIcons.test.tsx b/apps/web/src/environmentIcons.test.tsx index c02110cc458d..569e66edce68 100644 --- a/apps/web/src/environmentIcons.test.tsx +++ b/apps/web/src/environmentIcons.test.tsx @@ -46,7 +46,7 @@ describe("environment icon preferences", () => { isPrimary: true, isDesktopLocal: false, showLocalEnvironmentIcon: false, - environmentLabel: "Airy", + environmentLabel: "Workstation", }), ).toEqual({ kind: "monitor", showRowIcon: false, hoverLabel: null }); expect( @@ -54,9 +54,9 @@ describe("environment icon preferences", () => { isPrimary: true, isDesktopLocal: false, showLocalEnvironmentIcon: true, - environmentLabel: "Airy", + environmentLabel: "Workstation", }), - ).toEqual({ kind: "monitor", showRowIcon: true, hoverLabel: "Airy (local)" }); + ).toEqual({ kind: "monitor", showRowIcon: true, hoverLabel: "Workstation (local)" }); expect( legacyThreadEnvironmentPresentation({ isPrimary: false, @@ -74,7 +74,7 @@ describe("environment icon preferences", () => { }); it("formats the resolved local label with a safe fallback", () => { - expect(formatLocalEnvironmentLabel("Airy")).toBe("Airy (local)"); + expect(formatLocalEnvironmentLabel("Workstation")).toBe("Workstation (local)"); expect(formatLocalEnvironmentLabel(" ")).toBe("Local (local)"); }); }); @@ -87,13 +87,13 @@ describe("project environment icons", () => { it("hides a local-only icon by default and shows it after opt-in", () => { const base = { - members: [member(local, "Airy")], + members: [member(local, "Workstation")], primaryEnvironmentId: local, desktopLocalEnvironmentIds: new Set(), }; expect(projectEnvironmentIconEntries({ ...base, showLocalEnvironmentIcon: false })).toEqual([]); expect(projectEnvironmentIconEntries({ ...base, showLocalEnvironmentIcon: true })).toEqual([ - { environmentId: local, kind: "monitor", label: "Airy (local)" }, + { environmentId: local, kind: "monitor", label: "Workstation (local)" }, ]); }); @@ -102,7 +102,7 @@ describe("project environment icons", () => { projectEnvironmentIconEntries({ members: [ member(buildbox, "Buildbox"), - member(local, "Airy"), + member(local, "Workstation"), member(buildbox, "Buildbox"), member(production, "Production"), member(wsl, "Ubuntu"), @@ -112,7 +112,7 @@ describe("project environment icons", () => { showLocalEnvironmentIcon: false, }), ).toEqual([ - { environmentId: local, kind: "monitor", label: "Airy (local)" }, + { environmentId: local, kind: "monitor", label: "Workstation (local)" }, { environmentId: buildbox, kind: "server", label: "Buildbox" }, { environmentId: production, kind: "server", label: "Production" }, { environmentId: wsl, kind: "container", label: "Ubuntu" }, diff --git a/docs/lastcode/release.md b/docs/lastcode/release.md index 45022258c946..0e9455bd4859 100644 --- a/docs/lastcode/release.md +++ b/docs/lastcode/release.md @@ -211,7 +211,7 @@ stopping LastCode: ```bash pnpm lastcode:intel-stage stage -pnpm lastcode:intel-stage stage --maximum-version-host airy +pnpm lastcode:intel-stage stage --maximum-version-host version-source.example pnpm lastcode:intel-stage status ``` @@ -225,81 +225,61 @@ checks and supersession; a mismatch, competing invocation, or interrupted check leaves the prior pending selection intact. Staging never closes admission, stops the app, or starts installation. -`--maximum-version-host airy` reads `/Applications/LastCode.app` on the named +`--maximum-version-host version-source.example` reads +`/Applications/LastCode.app` on the named SSH host and stages only releases at or below that installed nightly. If SSH is unavailable or the remote version is not a LastCode nightly, staging stops -before changing the current pending selection. This is the simple version -ceiling used to keep htulo from moving ahead of airy. +before changing the current pending selection. The SSH read is non-interactive and requires key-based access; it never opens a password prompt. -### Daily htulo updates - -Htulo runs the packaged LastCode server as a login LaunchAgent on the fixed -loopback port `3773`. It uses the normal LastCode home and does not launch an -Electron window, renderer, or GPU process: - -```bash -pnpm lastcode:headless-service install -pnpm lastcode:headless-service status -pnpm lastcode:headless-service stop -pnpm lastcode:headless-service start -``` - -The first `install` writes and loads the LaunchAgent, then quits a running -LastCode desktop once so the headless server can take ownership of the normal -LastCode home. Launchd keeps the new service armed while the desktop exits. -Later service starts and daily updates do not launch or quit the desktop. - -The service executes the server entry inside `/Applications/LastCode.app`, so -the running server is always the version installed on htulo. Do not launch the -desktop app against `~/.lastcode` while this service owns that home. - -Htulo's external updater is a second login LaunchAgent: - -```bash -pnpm lastcode:daily-update install -pnpm lastcode:daily-update status -pnpm lastcode:daily-update run-now +### Deployment primitives + +This repository provides narrow components that private or organization-owned +infrastructure can compose: + +- `lastcode:intel-stage` validates and prepares a published Intel build without + activating it; +- `lastcode:headless-service` runs the packaged server in a dedicated macOS x64 + environment; +- `lastcode:install` performs the guarded application swap and rollback; and +- `lastcode:managed-checkout` aligns an explicitly automation-owned checkout + with a configured remote branch. + +The repository deliberately does not define a real deployment topology, +machine roles, update schedule, service ordering, or concrete environment +paths. Infrastructure code owns those decisions, including when to pause work, +how to select a version ceiling, when to activate a staged app, and which +checkout is reserved for automation. + +The managed-checkout tool accepts an absolute JSON configuration: + +```json +{ + "backupRefPrefix": "refs/example/managed-checkout-backups", + "branch": "release", + "gitCommonDirectory": "/srv/example/repository.git", + "remote": "origin", + "remoteBranch": "release", + "worktree": "/srv/example/managed-checkout" +} ``` -At 04:00 each day it prepares and validates the newest eligible Intel app before -interrupting work. If an update is ready, it uses htulo's local -`lastcode-thread` command to ask every working thread to pause, waits for the -explicit `PAUSED FOR LASTCODE UPDATE` replies, checks once for a newly started -thread, stops the headless server, swaps the app, starts the same service on -port `3773`, verifies the exact installed x64 build plus its packaged server -through the public environment descriptor, and tells the paused threads to -resume. If the new service does not become ready, the installer restores the -previous app and restarts its server. -A missing pause reply leaves the prepared update in place for the next run and -does not stop the server. Threads that may already have paused are always -queued for a resume; undelivered resumes are kept in -`~/.lastcode/daily-update/pending-resumes.json` and retried before the next -daily update check. - -The first update from a LastCode version older than the thread command is a -manual bootstrap after the user has paused work: - -```bash -pnpm lastcode:daily-update run --bootstrap -``` - -`--bootstrap` is never present in the scheduled LaunchAgent. Remove the schedule -with `pnpm lastcode:daily-update uninstall`. - -For occasional desktop UI QA on htulo, stop any prior QA instance and run one -isolated desktop process. Its state and port are deliberately separate from the -production server: +Run it with: ```bash -T3CODE_HOME="$HOME/.lastcode-qa" \ -T3CODE_PORT=4773 \ - /Applications/LastCode.app/Contents/MacOS/LastCode +pnpm lastcode:managed-checkout sync --config /absolute/path/checkout.json ``` -Quit that desktop when QA is complete. Airy and browser clients continue to use -the production htulo environment on port `3773` throughout. +The tool verifies the configured repository identity, selected branch, clean +tracked and untracked state, inactive Git operation, initialized-submodule +changes, and collisions with ignored content. It fetches only the configured +remote branch, saves the old tip under the configured backup-ref prefix, and +moves the branch with compare-and-swap semantics before updating the tree. The +caller must give it exclusive ownership of the checkout for the duration of the +operation; Git cannot lock arbitrary concurrent filesystem writes. If the ref +moves but tree verification fails, the tool retains the target ref and reports +the backup ref for explicit recovery instead of pretending it rolled back. State defaults to `~/.lastcode/intel-updates`. `pending.json` is the narrow, credential-free handoff contract for later drain and activation work. It names diff --git a/package.json b/package.json index 98a7c391b01e..fbbd8c7f29ce 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "lastcode:install": "mise exec node@24.13.1 -- node scripts/lastcode-install.mjs", "lastcode:intel-build": "mise exec node@24.13.1 -- node scripts/lastcode-build-intel-package.ts", "lastcode:intel-stage": "mise exec node@24.13.1 -- node scripts/lastcode-intel-stage.mjs", - "lastcode:daily-update": "mise exec node@24.13.1 -- node scripts/lastcode-daily-update.mjs", + "lastcode:managed-checkout": "mise exec node@24.13.1 -- node scripts/lastcode-managed-checkout.mjs", "lastcode:headless-service": "mise exec node@24.13.1 -- node scripts/lastcode-headless-service.mjs", "lastcode:setup": "mise exec node@24.13.1 -- node scripts/lastcode-setup.mjs", "lastcode:checkpoint:service": "mise exec node@24.13.1 -- node scripts/lastcode-nightly-service.ts", diff --git a/scripts/lastcode-daily-intel-package.test.ts b/scripts/lastcode-daily-intel-package.test.ts index 86b1c29bc934..9e22597f2b6f 100644 --- a/scripts/lastcode-daily-intel-package.test.ts +++ b/scripts/lastcode-daily-intel-package.test.ts @@ -24,7 +24,6 @@ describe("lastcode-daily-intel-package", () => { expect(workflow).toContain("group: lastcode-daily-intel-package"); expect(workflow).toContain("cancel-in-progress: false"); expect(workflow).toContain("node scripts/lastcode-daily-intel-package.ts"); - expect(workflow).not.toMatch(/airy|htulo/u); }); it("selects the newest strict installable and peels annotated tags", () => { diff --git a/scripts/lastcode-daily-update.mjs b/scripts/lastcode-daily-update.mjs deleted file mode 100644 index f598c69940b3..000000000000 --- a/scripts/lastcode-daily-update.mjs +++ /dev/null @@ -1,405 +0,0 @@ -#!/usr/bin/env node - -// LastCode-only daily updater for the trusted airy + htulo setup. -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeTimersPromises from "node:timers/promises"; -import * as NodeURL from "node:url"; -import * as NodeUtil from "node:util"; - -import { - cleanupPreparedInstall, - prepareDmgInstall, - replacePreparedApp, -} from "./lastcode-install.mjs"; -import { - readInstalledLastCodeVersion, - restartHeadlessService, - stopHeadlessService, -} from "./lastcode-headless-service.mjs"; -import { stageIntelUpdate } from "./lastcode-intel-stage.mjs"; - -const execFile = NodeUtil.promisify(NodeChildProcess.execFile); -const LABEL = "codes.lastobelus.lastcode-daily-update"; -const PAUSE_ACKNOWLEDGEMENT = "PAUSED FOR LASTCODE UPDATE"; -const PAUSE_MESSAGE = - `Pause safely for the prepared LastCode update. Finish your current operation, ` + - `reply with a line containing exactly '${PAUSE_ACKNOWLEDGEMENT}', then stop this turn.`; -const RESUME_MESSAGE = - "LastCode has been updated and restarted. Resume from your paused checkpoint."; -const COPIED_MODULES = [ - "lastcode-daily-update.mjs", - "lastcode-headless-service.mjs", - "lastcode-install.mjs", - "lastcode-intel-release.mjs", - "lastcode-intel-stage.mjs", - "lastcode-lock.mjs", -]; - -function fail(message) { - throw new Error(message); -} - -function xml(value) { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function run(command, args, options = {}) { - const result = NodeChildProcess.spawnSync(command, args, { - encoding: "utf8", - stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", - }); - if (result.error) throw result.error; - if (result.status !== 0 && !options.allowFailure) { - throw new Error( - result.stderr?.trim() || `${command} ${args.join(" ")} failed with ${result.status}.`, - ); - } - return result; -} - -function parseJson(raw, label) { - try { - return JSON.parse(raw); - } catch (error) { - fail(`Could not parse ${label}: ${error instanceof Error ? error.message : String(error)}`); - } -} - -export function parseDailyUpdateOptions(argv) { - const command = argv[0]; - if (!command || !["install", "run", "run-now", "status", "uninstall"].includes(command)) { - fail("Usage: lastcode:daily-update [--bootstrap]"); - } - const bootstrap = argv.slice(1).includes("--bootstrap"); - if (argv.slice(1).some((arg) => arg !== "--bootstrap") || (bootstrap && command !== "run")) { - fail("--bootstrap is accepted only by the run command."); - } - return { bootstrap, command }; -} - -export function renderDailyUpdatePlist({ logDirectory, modulePath, nodePath }) { - return ` - - - - Label - ${LABEL} - ProgramArguments - - ${xml(nodePath)} - ${xml(modulePath)} - run - - EnvironmentVariables - - PATH - /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin - - StartCalendarInterval - - Hour - 4 - Minute - 0 - - ProcessType - Background - StandardOutPath - ${xml(NodePath.join(logDirectory, "daily-update.stdout.log"))} - StandardErrorPath - ${xml(NodePath.join(logDirectory, "daily-update.stderr.log"))} - - -`; -} - -function workingThreads(list) { - if (list?.kind !== "list" || !Array.isArray(list.threads)) fail("Thread list is invalid."); - return list.threads.filter((thread) => thread.lifecycle === "working"); -} - -function hasPauseAcknowledgement(result) { - return ( - result?.kind === "completed" && - result.response.split(/\r?\n/u).some((line) => line.trim() === PAUSE_ACKNOWLEDGEMENT) - ); -} - -async function pauseBatch(threads, paused, dependencies) { - for (const thread of threads) paused.set(thread.threadId, thread); - dependencies.savePendingResumes([...paused.values()]); - const results = await Promise.allSettled( - threads.map(async (thread) => { - const result = await dependencies.sendAndWait(thread.threadId, PAUSE_MESSAGE); - if (!hasPauseAcknowledgement(result)) { - if (result?.kind === "completed") paused.delete(thread.threadId); - fail(`Thread '${thread.title}' did not confirm that it paused.`); - } - }), - ); - dependencies.savePendingResumes([...paused.values()]); - const failure = results.find((result) => result.status === "rejected"); - if (failure) throw failure.reason; -} - -async function resumePaused(paused, dependencies) { - const results = await Promise.allSettled( - [...paused.values()].map(async (thread) => { - try { - await dependencies.resumeThread(thread.threadId, RESUME_MESSAGE); - } catch (error) { - if (!dependencies.isMissingThreadError(error, thread.threadId)) throw error; - } - paused.delete(thread.threadId); - }), - ); - dependencies.savePendingResumes([...paused.values()]); - const failure = results.find((result) => result.status === "rejected"); - if (failure) throw failure.reason; -} - -export async function runDailyUpdate(options = {}, dependencies) { - const paused = new Map( - dependencies.loadPendingResumes().map((thread) => [thread.threadId, thread]), - ); - if (paused.size > 0) await resumePaused(paused, dependencies); - - const staged = await dependencies.stageUpdate({ maximumVersionHost: "airy" }); - if (!staged.pending) return { status: "up-to-date" }; - - const prepared = await dependencies.prepareInstall(staged.pending.dmgPath, { - expectedSha256: staged.pending.dmgSha256, - expectedVersion: staged.pending.version, - }); - try { - if (!options.bootstrap) { - const first = workingThreads(await dependencies.listThreads()); - await pauseBatch(first, paused, dependencies); - - const newcomers = workingThreads(await dependencies.listThreads()).filter( - (thread) => !paused.has(thread.threadId), - ); - await pauseBatch(newcomers, paused, dependencies); - } - - let updateError; - try { - try { - await dependencies.stopService(); - } catch (error) { - try { - await dependencies.restartService(); - } catch (restartError) { - const stopDetail = error instanceof Error ? error.message : String(error); - throw new Error(`Could not restore the server after shutdown failed: ${stopDetail}`, { - cause: restartError, - }); - } - throw error; - } - await dependencies.replaceApp(prepared, staged.pending.version); - } catch (error) { - updateError = error; - } - - if (updateError) throw updateError; - return { status: "updated", version: staged.pending.version }; - } finally { - try { - if (paused.size > 0) await resumePaused(paused, dependencies); - } finally { - dependencies.cleanupInstall(prepared); - } - } -} - -export async function replacePreparedHeadlessApp(prepared, expectedVersion, dependencies = {}) { - const replaceApp = dependencies.replaceApp ?? replacePreparedApp; - const restartService = dependencies.restartService ?? restartHeadlessService; - const readVersion = dependencies.readVersion ?? readInstalledLastCodeVersion; - const hadInstalledApp = NodeFS.existsSync(prepared.targetPath); - return replaceApp(prepared, { - launchApp: async (appPath) => { - const installedVersion = readVersion({ appPath }); - const launchVersion = - prepared.oldAppMoved || !hadInstalledApp ? expectedVersion : installedVersion; - return restartService({ appPath, expectedVersion: launchVersion }); - }, - }); -} - -async function threadCommand(threadTool, args) { - const { stdout } = await execFile(threadTool, [...args, "--json"], { - encoding: "utf8", - maxBuffer: 1024 * 1024, - }); - return parseJson(stdout, `lastcode-thread ${args[0]} output`); -} - -async function resumeThread(threadTool, threadId, message) { - let lastError; - for (let attempt = 0; attempt < 15; attempt += 1) { - try { - return await threadCommand(threadTool, ["send", threadId, "--message", message]); - } catch (error) { - lastError = error; - if (attempt < 14) await NodeTimersPromises.setTimeout(2_000); - } - } - throw lastError; -} - -export function isMissingThreadError(error, threadId) { - return ( - error !== null && - typeof error === "object" && - "stderr" in error && - typeof error.stderr === "string" && - error.stderr.includes(`LastCode thread '${threadId}' was not found.`) - ); -} - -function defaultDependencies(home) { - const threadTool = NodePath.join(home, ".lastcode", "userdata", "bin", "lastcode-thread"); - const pendingResumesPath = NodePath.join( - home, - ".lastcode", - "daily-update", - "pending-resumes.json", - ); - return { - cleanupInstall: cleanupPreparedInstall, - isMissingThreadError, - listThreads: () => threadCommand(threadTool, ["list"]), - loadPendingResumes: () => { - try { - const pending = parseJson( - NodeFS.readFileSync(pendingResumesPath, "utf8"), - "pending resume queue", - ); - if (!Array.isArray(pending)) fail("Pending resume queue is invalid."); - return pending; - } catch (error) { - if (error && typeof error === "object" && error.code === "ENOENT") return []; - throw error; - } - }, - prepareInstall: prepareDmgInstall, - replaceApp: (prepared, expectedVersion) => - replacePreparedHeadlessApp(prepared, expectedVersion, { - readVersion: (serviceOptions) => readInstalledLastCodeVersion({ ...serviceOptions, home }), - restartService: (serviceOptions) => restartHeadlessService({ ...serviceOptions, home }), - }), - resumeThread: (threadId, message) => resumeThread(threadTool, threadId, message), - savePendingResumes: (pending) => { - NodeFS.mkdirSync(NodePath.dirname(pendingResumesPath), { recursive: true, mode: 0o700 }); - const temporaryPath = `${pendingResumesPath}.tmp`; - NodeFS.writeFileSync(temporaryPath, `${JSON.stringify(pending)}\n`, { mode: 0o600 }); - NodeFS.renameSync(temporaryPath, pendingResumesPath); - }, - sendAndWait: (threadId, message) => - threadCommand(threadTool, [ - "send", - threadId, - "--message", - message, - "--wait", - "--timeout", - "10 minutes", - ]), - stageUpdate: (stageOptions) => stageIntelUpdate(stageOptions), - restartService: () => { - const expectedVersion = readInstalledLastCodeVersion({ home }); - return restartHeadlessService({ expectedVersion, home }); - }, - stopService: () => stopHeadlessService({ home }), - }; -} - -function servicePaths(home) { - const rootDirectory = NodePath.join(home, ".lastcode", "daily-update"); - const moduleDirectory = NodePath.join(rootDirectory, "bin"); - return { - logDirectory: NodePath.join(rootDirectory, "logs"), - moduleDirectory, - modulePath: NodePath.join(moduleDirectory, "lastcode-daily-update.mjs"), - plistPath: NodePath.join(home, "Library", "LaunchAgents", `${LABEL}.plist`), - }; -} - -export function installDailyUpdateService(options = {}) { - const home = options.home ?? NodeOS.homedir(); - const sourceDirectory = - options.sourceDirectory ?? NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); - const nodePath = options.nodePath ?? process.execPath; - const paths = servicePaths(home); - NodeFS.mkdirSync(paths.moduleDirectory, { recursive: true, mode: 0o700 }); - NodeFS.mkdirSync(paths.logDirectory, { recursive: true, mode: 0o700 }); - NodeFS.mkdirSync(NodePath.dirname(paths.plistPath), { recursive: true }); - for (const name of COPIED_MODULES) { - NodeFS.copyFileSync( - NodePath.join(sourceDirectory, name), - NodePath.join(paths.moduleDirectory, name), - ); - } - NodeFS.writeFileSync( - paths.plistPath, - renderDailyUpdatePlist({ - logDirectory: paths.logDirectory, - modulePath: paths.modulePath, - nodePath, - }), - ); - const uid = process.getuid?.(); - if (!Number.isSafeInteger(uid)) fail("Could not determine the current user ID."); - const domain = `gui/${uid}`; - const service = `${domain}/${LABEL}`; - const runCommand = options.runCommand ?? run; - runCommand("plutil", ["-lint", paths.plistPath]); - runCommand("launchctl", ["bootout", service], { allowFailure: true }); - runCommand("launchctl", ["bootstrap", domain, paths.plistPath]); - return { ...paths, service }; -} - -async function main(argv) { - const options = parseDailyUpdateOptions(argv); - const home = NodeOS.homedir(); - const uid = process.getuid?.(); - if (!Number.isSafeInteger(uid)) fail("Could not determine the current user ID."); - const service = `gui/${uid}/${LABEL}`; - if (options.command === "run") { - const result = await runDailyUpdate(options, defaultDependencies(home)); - console.log(JSON.stringify(result)); - return; - } - if (options.command === "install") { - const installed = installDailyUpdateService({ home }); - console.log(`Installed ${installed.service}; it runs daily at 04:00.`); - return; - } - if (options.command === "uninstall") { - run("launchctl", ["bootout", service], { allowFailure: true }); - const paths = servicePaths(home); - NodeFS.rmSync(paths.plistPath, { force: true }); - NodeFS.rmSync(paths.moduleDirectory, { force: true, recursive: true }); - return; - } - run("launchctl", [options.command === "status" ? "print" : "kickstart", service]); -} - -if (import.meta.main) { - main(process.argv.slice(2)).catch((error) => { - console.error( - `[lastcode:daily-update] ${error instanceof Error ? error.message : String(error)}`, - ); - process.exitCode = 1; - }); -} diff --git a/scripts/lastcode-daily-update.test.mjs b/scripts/lastcode-daily-update.test.mjs deleted file mode 100644 index de7b48f2c050..000000000000 --- a/scripts/lastcode-daily-update.test.mjs +++ /dev/null @@ -1,309 +0,0 @@ -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { - installDailyUpdateService, - isMissingThreadError, - parseDailyUpdateOptions, - replacePreparedHeadlessApp, - renderDailyUpdatePlist, - runDailyUpdate, -} from "./lastcode-daily-update.mjs"; - -const temporaryDirectories = []; - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - NodeFS.rmSync(directory, { force: true, recursive: true }); - } -}); - -function temporaryDirectory() { - const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-daily-update-")); - temporaryDirectories.push(directory); - return directory; -} - -function fixture(overrides = {}) { - const calls = []; - let pendingResumes = []; - const working = (threadId, title = threadId) => ({ lifecycle: "working", threadId, title }); - return { - calls, - dependencies: { - cleanupInstall: () => calls.push("cleanup"), - isMissingThreadError: () => false, - listThreads: async () => ({ kind: "list", threads: [] }), - loadPendingResumes: () => pendingResumes, - prepareInstall: async () => { - calls.push("prepare"); - return { prepared: true }; - }, - replaceApp: async () => calls.push("replace"), - restartService: async () => calls.push("restart"), - resumeThread: async (threadId) => calls.push(`resume:${threadId}`), - savePendingResumes: (pending) => { - pendingResumes = pending; - }, - sendAndWait: async (threadId) => { - calls.push(`pause:${threadId}`); - return { kind: "completed", response: `Ready\nPAUSED FOR LASTCODE UPDATE` }; - }, - stageUpdate: async () => { - calls.push("stage"); - return { - pending: { - dmgPath: "/tmp/LastCode.dmg", - dmgSha256: "a".repeat(64), - version: "1.2.3-nightly.20260824.1", - }, - }; - }, - stopService: () => calls.push("stop"), - ...overrides, - }, - working, - }; -} - -describe("LastCode daily updater", () => { - it("prepares before pausing, checks once for newcomers, and swaps once", async () => { - const test = fixture(); - let listing = 0; - test.dependencies.listThreads = async () => { - test.calls.push(`list:${listing}`); - listing += 1; - return { - kind: "list", - threads: - listing === 1 - ? [test.working("one"), { lifecycle: "active", threadId: "idle", title: "idle" }] - : [test.working("one"), test.working("two")], - }; - }; - - await expect(runDailyUpdate({}, test.dependencies)).resolves.toEqual({ - status: "updated", - version: "1.2.3-nightly.20260824.1", - }); - expect(test.calls).toEqual([ - "stage", - "prepare", - "list:0", - "pause:one", - "list:1", - "pause:two", - "stop", - "replace", - "resume:one", - "resume:two", - "cleanup", - ]); - }); - - it("does not stop the service when a working thread does not confirm it paused", async () => { - let send = 0; - const test = fixture({ - listThreads: async () => ({ - kind: "list", - threads: [ - { lifecycle: "working", threadId: "one", title: "one" }, - { lifecycle: "working", threadId: "two", title: "two" }, - ], - }), - sendAndWait: async () => { - send += 1; - return { - kind: "completed", - response: send === 1 ? "PAUSED FOR LASTCODE UPDATE" : "Not ready", - }; - }, - }); - - await expect(runDailyUpdate({}, test.dependencies)).rejects.toThrow("did not confirm"); - expect(test.calls).toEqual(["stage", "prepare", "resume:one", "cleanup"]); - }); - - it("restores the current service when shutdown does not finish", async () => { - const test = fixture({ - stopService: async () => { - test.calls.push("stop"); - throw new Error("stop timed out"); - }, - }); - - await expect(runDailyUpdate({ bootstrap: true }, test.dependencies)).rejects.toThrow( - "stop timed out", - ); - expect(test.calls).toEqual(["stage", "prepare", "stop", "restart", "cleanup"]); - }); - - it("does nothing when no eligible update is pending", async () => { - const test = fixture({ stageUpdate: async () => ({ status: "up-to-date" }) }); - await expect(runDailyUpdate({}, test.dependencies)).resolves.toEqual({ - status: "up-to-date", - }); - expect(test.calls).toEqual([]); - }); - - it("resumes an indeterminate timed-out pause request before aborting", async () => { - const test = fixture({ - listThreads: async () => ({ - kind: "list", - threads: [test.working("one")], - }), - sendAndWait: async () => ({ kind: "timed-out", waitHandle: { requestId: "request" } }), - }); - - await expect(runDailyUpdate({}, test.dependencies)).rejects.toThrow("did not confirm"); - expect(test.calls).toEqual(["stage", "prepare", "resume:one", "cleanup"]); - }); - - it("retries a durable resume before an up-to-date early return", async () => { - const test = fixture({ stageUpdate: async () => ({ status: "up-to-date" }) }); - test.dependencies.loadPendingResumes = () => [test.working("one")]; - - await expect(runDailyUpdate({}, test.dependencies)).resolves.toEqual({ - status: "up-to-date", - }); - expect(test.calls).toEqual(["resume:one"]); - }); - - it("keeps a failed post-update resume for the next daily run", async () => { - const test = fixture(); - let listing = 0; - let resumeFails = true; - test.dependencies.listThreads = async () => ({ - kind: "list", - threads: listing++ === 0 ? [test.working("one")] : [], - }); - test.dependencies.resumeThread = async (threadId) => { - test.calls.push(`resume:${threadId}`); - if (resumeFails) throw new Error("server still starting"); - }; - - await expect(runDailyUpdate({}, test.dependencies)).rejects.toThrow("server still starting"); - - resumeFails = false; - test.dependencies.stageUpdate = async () => ({ status: "up-to-date" }); - await expect(runDailyUpdate({}, test.dependencies)).resolves.toEqual({ - status: "up-to-date", - }); - expect(test.calls.filter((call) => call === "resume:one")).toHaveLength(2); - }); - - it("restarts the restored app with the restored version after a failed update", async () => { - const targetPath = NodePath.join(temporaryDirectory(), "LastCode.app"); - NodeFS.mkdirSync(targetPath); - const prepared = { oldAppMoved: false, targetPath }; - const versions = []; - let versionRead = 0; - await replacePreparedHeadlessApp(prepared, "2.0.0", { - readVersion: () => (versionRead++ === 0 ? "2.0.0" : "1.0.0"), - replaceApp: async (_prepared, options) => { - prepared.oldAppMoved = true; - await expect(options.launchApp("/Applications/LastCode.app")).rejects.toThrow( - "new server failed", - ); - prepared.oldAppMoved = false; - await options.launchApp("/Applications/LastCode.app"); - }, - restartService: async ({ expectedVersion }) => { - versions.push(expectedVersion); - if (versions.length === 1) throw new Error("new server failed"); - }, - }); - - expect(versions).toEqual(["2.0.0", "1.0.0"]); - }); - - it("recognizes rollback when the candidate rename fails before its first launch", async () => { - const targetPath = NodePath.join(temporaryDirectory(), "LastCode.app"); - NodeFS.mkdirSync(targetPath); - const versions = []; - await replacePreparedHeadlessApp({ oldAppMoved: false, targetPath }, "2.0.0", { - readVersion: () => "1.0.0", - replaceApp: async (_prepared, options) => options.launchApp("/Applications/LastCode.app"), - restartService: async ({ expectedVersion }) => versions.push(expectedVersion), - }); - expect(versions).toEqual(["1.0.0"]); - }); - - it("discards a queued resume when the thread no longer exists", async () => { - const test = fixture({ stageUpdate: async () => ({ status: "up-to-date" }) }); - test.dependencies.loadPendingResumes = () => [test.working("gone")]; - test.dependencies.resumeThread = async () => { - throw Object.assign(new Error("missing"), { - stderr: "LastCode thread 'gone' was not found.", - }); - }; - test.dependencies.isMissingThreadError = isMissingThreadError; - - await expect(runDailyUpdate({}, test.dependencies)).resolves.toEqual({ - status: "up-to-date", - }); - }); - - it("supports one explicit bootstrap without installing it into the schedule", async () => { - expect(parseDailyUpdateOptions(["run", "--bootstrap"])).toEqual({ - bootstrap: true, - command: "run", - }); - expect(() => parseDailyUpdateOptions(["install", "--bootstrap"])).toThrow( - "accepted only by the run command", - ); - const plist = renderDailyUpdatePlist({ - logDirectory: "/Users/me/Logs & More", - modulePath: "/Users/me/lastcode-daily-update.mjs", - nodePath: "/Users/me/node", - }); - expect(plist).toContain("4"); - expect(plist).toContain("0"); - expect(plist).not.toContain("--bootstrap"); - expect(plist).toContain(NodePath.join("Logs & More", "daily-update.stderr.log")); - - const test = fixture({ - listThreads: async () => { - throw new Error("bootstrap must not inspect threads"); - }, - }); - await expect(runDailyUpdate({ bootstrap: true }, test.dependencies)).resolves.toMatchObject({ - status: "updated", - }); - expect(test.calls).toEqual(["stage", "prepare", "stop", "replace", "cleanup"]); - }); - - it("installs a daily LaunchAgent backed by standalone copied modules", () => { - const home = temporaryDirectory(); - const calls = []; - const installed = installDailyUpdateService({ - home, - nodePath: "/managed/node", - runCommand: (command, args, options) => calls.push({ args, command, options }), - }); - - expect(NodeFS.readFileSync(installed.plistPath, "utf8")).toContain("/managed/node"); - expect( - NodeFS.existsSync(NodePath.join(installed.moduleDirectory, "lastcode-intel-stage.mjs")), - ).toBe(true); - expect( - NodeFS.existsSync(NodePath.join(installed.moduleDirectory, "lastcode-headless-service.mjs")), - ).toBe(true); - expect(calls).toEqual([ - { command: "plutil", args: ["-lint", installed.plistPath], options: undefined }, - { - command: "launchctl", - args: ["bootout", installed.service], - options: { allowFailure: true }, - }, - { - command: "launchctl", - args: ["bootstrap", `gui/${process.getuid()}`, installed.plistPath], - options: undefined, - }, - ]); - }); -}); diff --git a/scripts/lastcode-headless-service.mjs b/scripts/lastcode-headless-service.mjs index 0c7c77cddc51..f071316df599 100644 --- a/scripts/lastcode-headless-service.mjs +++ b/scripts/lastcode-headless-service.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -// LastCode-only packaged server service for the trusted htulo host. +// LastCode-only packaged server service for a dedicated macOS x64 environment. import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; @@ -173,7 +173,7 @@ export async function waitForHeadlessService(options = {}) { ); } if (descriptor?.platform?.os !== "darwin" || descriptor?.platform?.arch !== "x64") { - throw new Error("readiness did not report the htulo macOS x64 server"); + throw new Error("readiness did not report the expected macOS x64 server"); } const verifyListenerOwnership = options.verifyListenerOwnership ?? verifyHeadlessListener; if (!verifyListenerOwnership(options)) { diff --git a/scripts/lastcode-headless-service.test.mjs b/scripts/lastcode-headless-service.test.mjs index 8c0bf560be95..4a26c82cfd7f 100644 --- a/scripts/lastcode-headless-service.test.mjs +++ b/scripts/lastcode-headless-service.test.mjs @@ -36,15 +36,15 @@ function temporaryDirectory() { function descriptor(version) { return { - environmentId: "htulo", - label: "htulo", + environmentId: "managed-server", + label: "Managed server", platform: { arch: "x64", os: "darwin" }, serverVersion: version, }; } describe("LastCode headless service", () => { - it("renders the packaged server on htulo's fixed port without Electron UI", () => { + it("renders the packaged server on its fixed port without Electron UI", () => { const plist = renderHeadlessServicePlist({ executablePath: "/Applications/LastCode.app/Contents/MacOS/LastCode", home: "/Users/me & you", diff --git a/scripts/lastcode-intel-stage.test.mjs b/scripts/lastcode-intel-stage.test.mjs index 0f5c578f8027..ca1de11b83f0 100644 --- a/scripts/lastcode-intel-stage.test.mjs +++ b/scripts/lastcode-intel-stage.test.mjs @@ -187,7 +187,7 @@ describe("LastCode Intel staging", () => { { currentVersion: "1.2.3-nightly.20260820.1", homeDirectory: root, - maximumVersionHost: "airy", + maximumVersionHost: "version-source.example", }, dependencies(olderTag, commit, { listReleases: async () => @@ -198,7 +198,7 @@ describe("LastCode Intel staging", () => { isPrerelease: true, })), readRemoteInstalledVersion: async (host) => { - expect(host).toBe("airy"); + expect(host).toBe("version-source.example"); return "1.2.3-nightly.20260821.7"; }, }), @@ -226,22 +226,22 @@ describe("LastCode Intel staging", () => { { currentVersion: "1.2.3-nightly.20260820.1", homeDirectory: root, - maximumVersionHost: "airy", + maximumVersionHost: "version-source.example", }, dependencies(tag, commit, { readRemoteInstalledVersion: async () => { - throw new Error("airy is offline"); + throw new Error("version source is offline"); }, }), ), - ).rejects.toThrow("airy is offline"); + ).rejects.toThrow("version source is offline"); expect(readPending(root)).toMatchObject({ candidateId: pending.candidateId, tag }); }); it("reads the installed LastCode version through one SSH command", () => { let invocation; - const version = readRemoteInstalledVersion("airy", (command, args) => { + const version = readRemoteInstalledVersion("version-source.example", (command, args) => { invocation = { args, command }; return "1.2.3-nightly.20260821.7"; }); @@ -255,7 +255,7 @@ describe("LastCode Intel staging", () => { "-o", "ConnectTimeout=10", "--", - "airy", + "version-source.example", "/usr/libexec/PlistBuddy", "-c", "Print:CFBundleShortVersionString", @@ -543,11 +543,11 @@ describe("LastCode Intel staging", () => { "--repository", "lastobelus/lastCode", "--maximum-version-host", - "airy", + "version-source.example", ]), ).toMatchObject({ command: "stage", - maximumVersionHost: "airy", + maximumVersionHost: "version-source.example", repository: "lastobelus/lastCode", }); expect(parseStageOptions(["status", "--home-dir", "/tmp/intel"])).toMatchObject({ diff --git a/scripts/lastcode-managed-checkout.mjs b/scripts/lastcode-managed-checkout.mjs new file mode 100644 index 000000000000..bbcf6104b441 --- /dev/null +++ b/scripts/lastcode-managed-checkout.mjs @@ -0,0 +1,276 @@ +#!/usr/bin/env node + +// Generic, guarded synchronization for a checkout explicitly reserved for automation. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +const GIT_MAX_BUFFER = 16 * 1024 * 1024; +const GIT_OPERATION_MARKERS = [ + "MERGE_HEAD", + "CHERRY_PICK_HEAD", + "REVERT_HEAD", + "rebase-apply", + "rebase-merge", + "sequencer", + "BISECT_LOG", +]; + +function fail(message, options) { + throw new Error(message, options); +} + +function validateRefName(value, label) { + if (typeof value !== "string" || !value) fail(`${label} is required.`); + const result = NodeChildProcess.spawnSync("git", ["check-ref-format", value], { + encoding: "utf8", + }); + if (result.status !== 0) fail(`${label} '${value}' is not a valid Git ref.`); + return value; +} + +function runGit(worktree, args, options = {}) { + const result = NodeChildProcess.spawnSync("git", ["-C", worktree, ...args], { + encoding: options.raw ? "buffer" : "utf8", + input: options.input, + maxBuffer: options.maxBuffer ?? 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0 && !options.allowStatuses?.includes(result.status)) { + const stderr = Buffer.isBuffer(result.stderr) + ? result.stderr.toString("utf8").trim() + : result.stderr.trim(); + fail(stderr || `git ${args.join(" ")} failed with ${result.status ?? "unknown"}.`); + } + return Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : result.stdout.trim(); +} + +function pathAndAncestors(paths) { + const expanded = new Set(); + for (const path of paths) { + expanded.add(path); + let separator = path.lastIndexOf("/"); + while (separator > 0) { + expanded.add(path.slice(0, separator)); + separator = path.lastIndexOf("/", separator - 1); + } + } + return expanded; +} + +function normalizeFilesystemPath(path, ignoreCase) { + const normalized = path.normalize("NFC").replace(/\/+$/u, ""); + return ignoreCase ? normalized.toLowerCase() : normalized; +} + +function ignoredTargetCollision(targetPathsRaw, ignoredPathsRaw, ignoreCase) { + const targetPaths = targetPathsRaw.split("\0").filter(Boolean); + const ignoredPaths = ignoredPathsRaw.split("\0").filter(Boolean); + const normalizedTargets = targetPaths.map((path) => normalizeFilesystemPath(path, ignoreCase)); + const normalizedIgnored = ignoredPaths.map((path) => normalizeFilesystemPath(path, ignoreCase)); + const targetsAndAncestors = pathAndAncestors(normalizedTargets); + const ignoredAndAncestors = pathAndAncestors(normalizedIgnored); + return ( + ignoredPaths.find((_, index) => targetsAndAncestors.has(normalizedIgnored[index])) ?? + targetPaths.find((_, index) => ignoredAndAncestors.has(normalizedTargets[index])) ?? + null + ); +} + +function changedInitializedGitlink(worktree, fromCommit, toCommit) { + const raw = runGit( + worktree, + ["diff-tree", "-r", "--no-commit-id", "--raw", "-z", fromCommit, toCommit], + { maxBuffer: GIT_MAX_BUFFER, raw: true }, + ); + const fields = raw.split("\0"); + for (let index = 0; index < fields.length; ) { + const metadata = fields[index++]; + if (!metadata) continue; + const match = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])\d*$/u.exec(metadata); + if (!match) fail(`Managed checkout reported invalid raw diff '${metadata}'.`); + const sourcePath = fields[index++]; + if (!sourcePath) fail("Managed checkout reported a raw diff without a path."); + const destinationPath = match[3] === "R" || match[3] === "C" ? fields[index++] : sourcePath; + if (!destinationPath) fail("Managed checkout reported a renamed diff without a path."); + if ( + (match[1] === "160000" || match[2] === "160000") && + (NodeFS.existsSync(NodePath.join(worktree, sourcePath, ".git")) || + NodeFS.existsSync(NodePath.join(worktree, destinationPath, ".git"))) + ) { + return sourcePath; + } + } + return null; +} + +export function managedCheckoutBackupRef(prefix, commit, objectFormat = "sha1") { + const normalizedPrefix = validateRefName(prefix, "Backup ref prefix"); + const objectIdLength = objectFormat === "sha1" ? 40 : objectFormat === "sha256" ? 64 : null; + if (objectIdLength === null) { + fail(`Managed checkout reported unsupported object format '${objectFormat}'.`); + } + if (!new RegExp(`^[0-9a-f]{${objectIdLength}}$`, "u").test(commit)) { + fail(`Managed checkout reported invalid commit '${commit}'.`); + } + return `${normalizedPrefix}/${commit}`; +} + +export function normalizeManagedCheckoutConfig(config) { + if (!config || typeof config !== "object" || Array.isArray(config)) { + fail("Managed checkout configuration must be an object."); + } + const { backupRefPrefix, branch, gitCommonDirectory, remote, remoteBranch, worktree } = config; + if (typeof worktree !== "string" || !NodePath.isAbsolute(worktree)) { + fail("Managed checkout worktree must be an absolute path."); + } + if (typeof gitCommonDirectory !== "string" || !NodePath.isAbsolute(gitCommonDirectory)) { + fail("Managed checkout Git identity must be an absolute path."); + } + if (typeof remote !== "string" || !/^[A-Za-z0-9._-]+$/u.test(remote)) { + fail("Managed checkout remote must be a simple Git remote name."); + } + validateRefName(`refs/heads/${branch}`, "Managed branch"); + validateRefName(`refs/heads/${remoteBranch}`, "Remote branch"); + validateRefName(backupRefPrefix, "Backup ref prefix"); + return { + backupRefPrefix, + branch, + gitCommonDirectory: NodePath.resolve(gitCommonDirectory), + remote, + remoteBranch, + worktree: NodePath.resolve(worktree), + }; +} + +/** + * Align an automation-owned checkout with a fetched remote branch. + * + * The caller must ensure no human or other process writes to the checkout while + * this function runs. The guards detect stale configuration and ordinary dirty + * state; they cannot make arbitrary filesystem writes participate in Git's lock. + */ +export function syncManagedCheckout(rawConfig, dependencies = {}) { + const config = normalizeManagedCheckoutConfig(rawConfig); + const git = dependencies.runGit ?? runGit; + const fetch = + dependencies.fetch ?? + (() => + git(config.worktree, [ + "fetch", + "--no-tags", + "--no-recurse-submodules", + config.remote, + `+refs/heads/${config.remoteBranch}:refs/remotes/${config.remote}/${config.remoteBranch}`, + ])); + const worktree = NodeFS.realpathSync(config.worktree); + const configuredGitCommonDirectory = NodeFS.realpathSync(config.gitCommonDirectory); + const branchRef = `refs/heads/${config.branch}`; + const remoteRef = `refs/remotes/${config.remote}/${config.remoteBranch}`; + + const assertSafe = (expectedCommit) => { + const topLevel = NodeFS.realpathSync(git(worktree, ["rev-parse", "--show-toplevel"])); + if (topLevel !== worktree) + fail(`Managed checkout resolved to unexpected worktree '${topLevel}'.`); + const reportedCommon = git(worktree, ["rev-parse", "--git-common-dir"]); + const commonDirectory = NodeFS.realpathSync( + NodePath.isAbsolute(reportedCommon) + ? reportedCommon + : NodePath.join(worktree, reportedCommon), + ); + if (commonDirectory !== configuredGitCommonDirectory) { + fail("Managed checkout no longer belongs to the configured repository."); + } + const selectedBranch = git(worktree, ["branch", "--show-current"]); + if (selectedBranch !== config.branch) { + fail( + `Managed checkout must be on ${config.branch}; found '${selectedBranch || "detached HEAD"}'.`, + ); + } + if (git(worktree, ["status", "--porcelain=v1", "--untracked-files=all"])) { + fail("Managed checkout has uncommitted or untracked changes."); + } + for (const marker of GIT_OPERATION_MARKERS) { + const markerPath = git(worktree, ["rev-parse", "--git-path", marker]); + const resolved = NodePath.isAbsolute(markerPath) + ? markerPath + : NodePath.join(worktree, markerPath); + if (NodeFS.existsSync(resolved)) + fail(`Managed checkout has an active Git operation (${marker}).`); + } + const commit = git(worktree, ["rev-parse", "HEAD"]); + if (expectedCommit && commit !== expectedCommit) { + fail("Managed checkout changed while its remote was being refreshed."); + } + return commit; + }; + + const currentCommit = assertSafe(); + fetch(); + assertSafe(currentCommit); + const targetCommit = git(worktree, ["rev-parse", remoteRef]); + if (targetCommit === currentCommit) return { commit: currentCommit, status: "current" }; + + const gitlink = changedInitializedGitlink(worktree, currentCommit, targetCommit); + if (gitlink) fail(`Managed checkout target changes initialized submodule '${gitlink}'.`); + const targetPaths = git(worktree, ["ls-tree", "-r", "--name-only", "-z", targetCommit], { + maxBuffer: GIT_MAX_BUFFER, + raw: true, + }); + const ignoredPaths = git( + worktree, + ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], + { maxBuffer: GIT_MAX_BUFFER, raw: true }, + ); + const ignoreCase = + git(worktree, ["config", "--bool", "--default", "false", "core.ignorecase"]) === "true"; + const collision = ignoredTargetCollision(targetPaths, ignoredPaths, ignoreCase); + if (collision) fail(`Managed checkout target would replace ignored content ('${collision}').`); + + assertSafe(currentCommit); + const objectFormat = git(worktree, ["rev-parse", "--show-object-format"]); + const backupRef = managedCheckoutBackupRef(config.backupRefPrefix, currentCommit, objectFormat); + git(worktree, ["update-ref", backupRef, currentCommit]); + git(worktree, ["update-ref", branchRef, targetCommit, currentCommit]); + try { + if (git(worktree, ["branch", "--show-current"]) !== config.branch) { + fail("Managed checkout changed branches before its tree update."); + } + git(worktree, ["reset", "--hard", targetCommit]); + const verifiedCommit = assertSafe(targetCommit); + return { + backupRef, + fromCommit: currentCommit, + status: "updated", + toCommit: verifiedCommit, + }; + } catch (error) { + fail( + `Managed checkout ref moved to ${targetCommit}, but its tree could not be verified; recover from ${backupRef}.`, + { cause: error }, + ); + } +} + +export function parseManagedCheckoutArgs(argv) { + if (argv[0] !== "sync") { + fail("Usage: lastcode-managed-checkout sync --config "); + } + if (argv.length !== 3 || argv[1] !== "--config" || !NodePath.isAbsolute(argv[2] ?? "")) { + fail("sync requires --config followed by an absolute JSON path."); + } + return { command: "sync", configPath: argv[2] }; +} + +if (import.meta.main) { + try { + const { configPath } = parseManagedCheckoutArgs(process.argv.slice(2)); + const config = JSON.parse(NodeFS.readFileSync(configPath, "utf8")); + console.log(JSON.stringify(syncManagedCheckout(config))); + } catch (error) { + console.error( + `[lastcode:managed-checkout] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/lastcode-managed-checkout.test.mjs b/scripts/lastcode-managed-checkout.test.mjs new file mode 100644 index 000000000000..ff5f3264d5a0 --- /dev/null +++ b/scripts/lastcode-managed-checkout.test.mjs @@ -0,0 +1,202 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + managedCheckoutBackupRef, + normalizeManagedCheckoutConfig, + parseManagedCheckoutArgs, + syncManagedCheckout, +} from "./lastcode-managed-checkout.mjs"; + +const temporaryDirectories = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + NodeFS.rmSync(directory, { force: true, recursive: true }); + } +}); + +function git(cwd, args) { + return NodeChildProcess.execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +function write(path, contents) { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, contents); +} + +function fixture() { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "managed-checkout-")); + temporaryDirectories.push(root); + const remote = NodePath.join(root, "remote.git"); + const publisher = NodePath.join(root, "publisher"); + const managed = NodePath.join(root, "managed"); + git(root, ["init", "--bare", remote]); + git(root, ["clone", remote, publisher]); + git(publisher, ["config", "user.email", "test@example.com"]); + git(publisher, ["config", "user.name", "Test"]); + write(NodePath.join(publisher, "tracked.txt"), "one\n"); + git(publisher, ["add", "tracked.txt"]); + git(publisher, ["commit", "-m", "initial"]); + git(publisher, ["branch", "-M", "release"]); + git(publisher, ["push", "-u", "origin", "release"]); + git(root, ["clone", "--branch", "release", remote, managed]); + const gitCommonDirectory = NodeFS.realpathSync(NodePath.join(managed, ".git")); + return { + config: { + backupRefPrefix: "refs/example/managed-checkout-backups", + branch: "release", + gitCommonDirectory, + remote: "origin", + remoteBranch: "release", + worktree: managed, + }, + managed, + publisher, + }; +} + +function publish(test, path, contents) { + write(NodePath.join(test.publisher, path), contents); + git(test.publisher, ["add", path]); + git(test.publisher, ["commit", "-m", `update ${path}`]); + git(test.publisher, ["push", "origin", "release"]); + return git(test.publisher, ["rev-parse", "HEAD"]); +} + +describe("managed checkout configuration", () => { + it("accepts a complete environment-neutral configuration", () => { + const test = fixture(); + expect(normalizeManagedCheckoutConfig(test.config)).toEqual(test.config); + }); + + it("requires explicit absolute checkout identity", () => { + const test = fixture(); + expect(() => normalizeManagedCheckoutConfig({ ...test.config, worktree: "relative" })).toThrow( + /absolute path/u, + ); + expect(() => + normalizeManagedCheckoutConfig({ ...test.config, gitCommonDirectory: "/missing" }), + ).not.toThrow(); + }); + + it("parses only an explicit absolute config path", () => { + expect(parseManagedCheckoutArgs(["sync", "--config", "/tmp/checkout.json"])).toEqual({ + command: "sync", + configPath: "/tmp/checkout.json", + }); + expect(() => parseManagedCheckoutArgs(["sync", "--config", "checkout.json"])).toThrow( + /absolute JSON path/u, + ); + }); + + it("builds backup refs for SHA-1 and SHA-256 object IDs", () => { + const prefix = "refs/example/managed-checkout-backups"; + expect(managedCheckoutBackupRef(prefix, "a".repeat(40))).toBe(`${prefix}/${"a".repeat(40)}`); + expect(managedCheckoutBackupRef(prefix, "b".repeat(64), "sha256")).toBe( + `${prefix}/${"b".repeat(64)}`, + ); + expect(() => managedCheckoutBackupRef(prefix, "c".repeat(41))).toThrow(/invalid commit/u); + expect(() => managedCheckoutBackupRef(prefix, "d".repeat(40), "unknown")).toThrow( + /unsupported object format/u, + ); + }); +}); + +describe("syncManagedCheckout", () => { + it("updates a clean reserved checkout and retains its old tip", () => { + const test = fixture(); + const oldCommit = git(test.managed, ["rev-parse", "HEAD"]); + const targetCommit = publish(test, "tracked.txt", "two\n"); + + expect(syncManagedCheckout(test.config)).toEqual({ + backupRef: managedCheckoutBackupRef(test.config.backupRefPrefix, oldCommit), + fromCommit: oldCommit, + status: "updated", + toCommit: targetCommit, + }); + expect(git(test.managed, ["rev-parse", "HEAD"])).toBe(targetCommit); + expect(NodeFS.readFileSync(NodePath.join(test.managed, "tracked.txt"), "utf8")).toBe("two\n"); + expect( + git(test.managed, [ + "rev-parse", + managedCheckoutBackupRef(test.config.backupRefPrefix, oldCommit), + ]), + ).toBe(oldCommit); + }); + + it("is idempotent when the remote branch is already current", () => { + const test = fixture(); + const commit = git(test.managed, ["rev-parse", "HEAD"]); + expect(syncManagedCheckout(test.config)).toEqual({ commit, status: "current" }); + }); + + it("fetches only the configured branch without tags or submodules", () => { + const test = fixture(); + publish(test, "tracked.txt", "two\n"); + const calls = []; + const runGit = (cwd, args, options = {}) => { + calls.push(args); + const output = NodeChildProcess.execFileSync("git", ["-C", cwd, ...args], { + encoding: "utf8", + input: options.input, + maxBuffer: options.maxBuffer, + }); + return options.raw ? output : output.trim(); + }; + + syncManagedCheckout(test.config, { runGit }); + + expect(calls).toContainEqual([ + "fetch", + "--no-tags", + "--no-recurse-submodules", + "origin", + "+refs/heads/release:refs/remotes/origin/release", + ]); + }); + + it("refuses dirty, wrong-branch, and wrong-repository checkouts", () => { + const dirty = fixture(); + write(NodePath.join(dirty.managed, "local.txt"), "local\n"); + expect(() => syncManagedCheckout(dirty.config)).toThrow(/uncommitted or untracked/u); + + const detached = fixture(); + git(detached.managed, ["checkout", "--detach"]); + expect(() => syncManagedCheckout(detached.config)).toThrow(/must be on release/u); + + const wrongIdentity = fixture(); + const other = fixture(); + expect(() => + syncManagedCheckout({ + ...wrongIdentity.config, + gitCommonDirectory: other.config.gitCommonDirectory, + }), + ).toThrow(/configured repository/u); + }); + + it("refuses a target that would replace ignored local content", () => { + const test = fixture(); + write(NodePath.join(test.managed, ".git", "info", "exclude"), "generated/\n"); + write(NodePath.join(test.managed, "generated", "local.txt"), "local\n"); + publish(test, "generated/from-remote.txt", "remote\n"); + expect(() => syncManagedCheckout(test.config)).toThrow(/replace ignored content/u); + expect(NodeFS.readFileSync(NodePath.join(test.managed, "generated", "local.txt"), "utf8")).toBe( + "local\n", + ); + }); + + it("revalidates the checkout after fetching", () => { + const test = fixture(); + publish(test, "tracked.txt", "two\n"); + expect(() => + syncManagedCheckout(test.config, { + fetch: () => write(NodePath.join(test.managed, "late.txt"), "late\n"), + }), + ).toThrow(/uncommitted or untracked/u); + }); +});