From aa6b6c1bc1c691355a0e5f3cf105abbdc5650111 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 14 Aug 2026 14:31:32 -0700 Subject: [PATCH 01/25] feat(lastcode): add userland checkpoint builder --- docs/lastcode/local-nightly-updates.md | 5 + docs/lastcode/nightly-workflow.md | 28 ++- package.json | 1 + scripts/lastcode-build.mjs | 270 +++++++++++++++++++++++++ scripts/lastcode-build.test.mjs | 66 ++++++ 5 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 scripts/lastcode-build.mjs create mode 100644 scripts/lastcode-build.test.mjs diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index 823304632b63..1a8732d8a506 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -18,6 +18,7 @@ Before enabling it, install the checkpoint service and dashboard: ```bash pnpm lastcode:checkpoint:service install pnpm lastcode:checkpoints --install +pnpm lastcode:build --install ``` The dashboard installer records the dedicated automation worktree in @@ -25,6 +26,10 @@ The dashboard installer records the dedicated automation worktree in read checkpoint tags and launch the versioned helper; it never checks out or cleans a human development worktree. +The optional `lastcode-build [CHECKPOINT]` command exposes the same builder for +manual bootstrap builds. It defaults to the newest checkpoint; a final nightly +number such as `1090` selects the unique checkpoint ending in `.1090`. + ## User flow 1. The desktop checks the local repository at startup, every four minutes, and diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index 089c0b835805..c9ef40083df0 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -206,8 +206,32 @@ The launch agent is opt-in. Repository installation and tests never register it. ## Selecting a Build -Check out the desired checkpoint, run full checkpoint CI, then build that same -tag: +For routine use, install the userland build command beside the checkpoint +dashboard: + +```bash +pnpm lastcode:build --install +``` + +The installer places the versioned command under `~/.lastcode/bin` and exposes +it through the dotfiles-managed `~/.local/bin` PATH. With no selector, it builds +the newest local checkpoint. The final nightly sequence number is accepted as +shorthand when selecting an older checkpoint: + +```bash +lastcode-build +lastcode-build 1090 +lastcode-build --checkpoint 1090 +``` + +`1090` is a **checkpoint selector**: it resolves to the unique immutable +`lastcode/checkpoint/*-nightly.*.1090` tag. A full upstream nightly tag or full +checkpoint tag is also accepted. The command uses the same dedicated worktree, +full local CI, immutable artifact directory, and DMG/ZIP builder as the in-app +local updater. Completed builds are reused. + +For lower-level or diagnostic use, check out the desired checkpoint, run full +checkpoint CI, then build that same tag: ```bash git switch --detach lastcode/checkpoint/v0.0.34-nightly.20260812.1072 diff --git a/package.json b/package.json index e611be167fd4..71fc9d4345ec 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "connect:announce-ga": "node scripts/announce-connect-ga.ts", "lastcode:checkpoint": "mise exec node@24.13.1 -- node scripts/lastcode-checkpoint.ts", "lastcode:checkpoints": "mise exec node@24.13.1 -- node scripts/lastcode-checkpoints.mjs", + "lastcode:build": "mise exec node@24.13.1 -- node scripts/lastcode-build.mjs", "lastcode:checkpoint:service": "mise exec node@24.13.1 -- node scripts/lastcode-nightly-service.ts", "lastcode:build:mac:arm64": "mise exec node@24.13.1 -- node scripts/lastcode-build-mac-arm64.ts", "lastcode:ci": "mise exec node@24.13.1 -- node scripts/lastcode-local-ci.ts --full", diff --git a/scripts/lastcode-build.mjs b/scripts/lastcode-build.mjs new file mode 100644 index 000000000000..648d9e12a2a7 --- /dev/null +++ b/scripts/lastcode-build.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +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 NodeURL from "node:url"; + +const CHECKPOINT_PREFIX = "lastcode/checkpoint/"; +const RESULT_PREFIX = "LASTCODE_LOCAL_UPDATE_RESULT="; + +const ansiEnabled = + process.stdout.isTTY && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb"; +const ansi = { + reset: "\u001b[0m", + projectName: "\u001b[1m\u001b[38;2;255;162;28m", + lavender: "\u001b[38;2;126;107;143m", + pacific: "\u001b[38;2;24;143;167m", + iceBold: "\u001b[1m\u001b[38;2;203;247;237m", + error: "\u001b[38;2;203;0;44m", + green: "\u001b[1;32m", +}; + +function style(code, value) { + return ansiEnabled ? `${code}${value}${ansi.reset}` : value; +} + +function shellQuote(value) { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +export function renderLauncher(modulePath) { + return `#!/bin/sh\nexec mise exec node@24.13.1 -- node ${shellQuote(modulePath)} "$@"\n`; +} + +function runGit(repoRoot, args) { + const result = NodeChildProcess.spawnSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed.`); + } + return result.stdout.trim(); +} + +function splitLines(value) { + return value + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function parseNightly(tag) { + const match = /^v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag); + return match?.slice(1).map(Number); +} + +function compareNightlies(left, right) { + const leftParts = parseNightly(left); + const rightParts = parseNightly(right); + if (!leftParts || !rightParts) return left.localeCompare(right); + for (let index = 0; index < leftParts.length; index += 1) { + const difference = leftParts[index] - rightParts[index]; + if (difference !== 0) return difference; + } + return 0; +} + +export function parseOptions(argv) { + let checkpoint; + let install = false; + let repoRoot; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") continue; + if (arg === "--install") install = true; + else if (arg === "-c" || arg === "--checkpoint" || arg === "--repo") { + const value = argv[index + 1]; + if (!value) throw new Error(`Missing value for ${arg}.`); + if (arg === "--repo") repoRoot = value; + else checkpoint = value; + index += 1; + } else if (arg === "-h" || arg === "--help") { + return { help: true, checkpoint, install, repoRoot }; + } else if (arg.startsWith("-")) { + throw new Error(`Unknown argument '${arg}'.`); + } else if (checkpoint) { + throw new Error(`Unexpected second checkpoint selector '${arg}'.`); + } else { + checkpoint = arg; + } + } + return { help: false, checkpoint, install, repoRoot }; +} + +export function resolveCheckpointTag(tags, selector) { + const validTags = tags.filter((tag) => { + if (!tag.startsWith(CHECKPOINT_PREFIX)) return false; + return parseNightly(tag.slice(CHECKPOINT_PREFIX.length)) !== undefined; + }); + if (validTags.length === 0) throw new Error("No local LastCode checkpoint tags were found."); + if (!selector) { + return validTags.toSorted((left, right) => + compareNightlies(right.slice(CHECKPOINT_PREFIX.length), left.slice(CHECKPOINT_PREFIX.length)), + )[0]; + } + + const normalized = selector.startsWith(CHECKPOINT_PREFIX) + ? selector + : selector.startsWith("v") + ? `${CHECKPOINT_PREFIX}${selector}` + : undefined; + if (normalized) { + if (validTags.includes(normalized)) return normalized; + throw new Error(`Checkpoint '${selector}' was not found.`); + } + if (!/^\d+$/.test(selector)) { + throw new Error( + `Invalid checkpoint selector '${selector}'. Use a number such as 1090 or a full nightly tag.`, + ); + } + const matches = validTags.filter((tag) => tag.endsWith(`.${selector}`)); + if (matches.length === 1) return matches[0]; + if (matches.length === 0) throw new Error(`Checkpoint ending in .${selector} was not found.`); + throw new Error( + `Checkpoint selector '${selector}' is ambiguous:\n${matches.map((tag) => ` ${tag}`).join("\n")}`, + ); +} + +function resolveConfiguredRepo(home, override) { + if (override) return NodePath.resolve(override); + if (process.env.LASTCODE_REPO) return NodePath.resolve(process.env.LASTCODE_REPO); + const configPath = NodePath.join(home, ".lastcode", "dashboard.json"); + if (NodeFS.existsSync(configPath)) { + const config = JSON.parse(NodeFS.readFileSync(configPath, "utf8")); + if (typeof config.repoRoot === "string") return config.repoRoot; + } + return runGit(process.cwd(), ["rev-parse", "--show-toplevel"]); +} + +function selectAutomationWorktree(repoRoot) { + const worktrees = runGit(repoRoot, ["worktree", "list", "--porcelain"]) + .split(/\r?\n/) + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length)); + return worktrees.find((worktree) => NodePath.basename(worktree) === "lastcode-automation"); +} + +function replaceManagedSymlink(exposed, target) { + const existing = NodeFS.lstatSync(exposed, { throwIfNoEntry: false }); + if (existing) { + if (!existing.isSymbolicLink() || NodeFS.readlinkSync(exposed) !== target) { + throw new Error(`${exposed} already exists and is not managed by LastCode.`); + } + NodeFS.unlinkSync(exposed); + } + NodeFS.symlinkSync(target, exposed); +} + +function installCommand(repoRoot, home) { + const automationWorktree = selectAutomationWorktree(repoRoot); + if (!automationWorktree) { + throw new Error( + "LastCode automation worktree is not installed. Run pnpm lastcode:checkpoint:service install first.", + ); + } + const binDirectory = NodePath.join(home, ".lastcode", "bin"); + const moduleTarget = NodePath.join(binDirectory, "lastcode-build.mjs"); + const target = NodePath.join(binDirectory, "lastcode-build"); + const exposedDirectory = NodePath.join(home, ".local", "bin"); + const exposed = NodePath.join(exposedDirectory, "lastcode-build"); + const configPath = NodePath.join(home, ".lastcode", "dashboard.json"); + + NodeFS.mkdirSync(binDirectory, { recursive: true }); + NodeFS.mkdirSync(exposedDirectory, { recursive: true }); + NodeFS.copyFileSync(NodeURL.fileURLToPath(import.meta.url), moduleTarget); + NodeFS.writeFileSync(target, renderLauncher(moduleTarget), { encoding: "utf8", mode: 0o755 }); + NodeFS.chmodSync(target, 0o755); + NodeFS.writeFileSync( + configPath, + `${JSON.stringify({ repoRoot: automationWorktree }, undefined, 2)}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + replaceManagedSymlink(exposed, target); + + console.log(`Installed ${target} with the pinned Node 24 runtime`); + console.log(`Exposed on PATH as ${exposed}`); +} + +export function parseBuildResult(stdout) { + const line = splitLines(stdout).find((entry) => entry.startsWith(RESULT_PREFIX)); + if (!line) throw new Error("The local build helper did not return a build result."); + const result = JSON.parse(line.slice(RESULT_PREFIX.length)); + if ( + result?.schemaVersion !== 1 || + result.status !== "built" || + typeof result.outputDir !== "string" + ) { + throw new Error("The local build helper returned an invalid build result."); + } + return result; +} + +function buildCheckpoint(repoRoot, home, checkpointTag) { + const helperPath = NodePath.join(repoRoot, "scripts", "lastcode-local-update.mjs"); + if (!NodeFS.existsSync(helperPath)) { + throw new Error(`Local build helper is missing at ${helperPath}.`); + } + const logPath = NodePath.join(home, ".lastcode", "local-updates", "build.log"); + console.log( + `${style(ansi.projectName, "LastCode")} ${style(ansi.pacific, "build")} ${style(ansi.iceBold, checkpointTag)}`, + ); + console.log(style(ansi.lavender, `Full CI and packaging logs: ${logPath}`)); + + const result = NodeChildProcess.spawnSync( + process.execPath, + [helperPath, "build", "--repo", repoRoot, "--home", home, "--checkpoint", checkpointTag], + { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `Local build failed with exit code ${result.status}.`); + } + const build = parseBuildResult(result.stdout); + const dmg = NodeFS.readdirSync(build.outputDir).find((entry) => entry.endsWith(".dmg")); + if (!dmg) throw new Error(`Build completed without a DMG in ${build.outputDir}.`); + console.log(style(ansi.green, "Build ready")); + console.log(NodePath.join(build.outputDir, dmg)); +} + +function main(argv) { + const options = parseOptions(argv); + if (options.help) { + console.log("Usage: lastcode-build [CHECKPOINT]"); + console.log(" lastcode-build --checkpoint CHECKPOINT"); + console.log(""); + console.log("CHECKPOINT may be 1090, a full nightly tag, or a lastcode/checkpoint tag."); + console.log("Without CHECKPOINT, the newest local checkpoint is built."); + return; + } + const home = NodeOS.homedir(); + const repoRoot = resolveConfiguredRepo(home, options.repoRoot); + if (options.install) { + installCommand(repoRoot, home); + return; + } + const tags = splitLines(runGit(repoRoot, ["tag", "--list", `${CHECKPOINT_PREFIX}v*-nightly.*`])); + buildCheckpoint(repoRoot, home, resolveCheckpointTag(tags, options.checkpoint)); +} + +if ( + process.argv[1] && + NodeFS.realpathSync(process.argv[1]) === + NodeFS.realpathSync(NodeURL.fileURLToPath(import.meta.url)) +) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error( + style( + ansi.error, + `lastcode-build: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + process.exitCode = 1; + } +} diff --git a/scripts/lastcode-build.test.mjs b/scripts/lastcode-build.test.mjs new file mode 100644 index 000000000000..ddd8299338b0 --- /dev/null +++ b/scripts/lastcode-build.test.mjs @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + parseBuildResult, + parseOptions, + renderLauncher, + resolveCheckpointTag, +} from "./lastcode-build.mjs"; + +const tags = [ + "lastcode/checkpoint/v0.0.34-nightly.20260814.1090", + "lastcode/checkpoint/v0.0.34-nightly.20260814.1092", + "lastcode/checkpoint/v0.0.34-nightly.20260814.1095", +]; + +describe("LastCode userland build command", () => { + it("accepts positional and named checkpoint selectors", () => { + expect(parseOptions(["1090"]).checkpoint).toBe("1090"); + expect(parseOptions(["--checkpoint", "1092"]).checkpoint).toBe("1092"); + expect(parseOptions(["-c", "1095"]).checkpoint).toBe("1095"); + expect(() => parseOptions(["1090", "1092"])).toThrow("Unexpected second checkpoint"); + }); + + it("selects the newest checkpoint by default", () => { + expect(resolveCheckpointTag(tags)).toBe("lastcode/checkpoint/v0.0.34-nightly.20260814.1095"); + }); + + it("resolves checkpoint number shorthand and full tags", () => { + expect(resolveCheckpointTag(tags, "1090")).toBe( + "lastcode/checkpoint/v0.0.34-nightly.20260814.1090", + ); + expect(resolveCheckpointTag(tags, "v0.0.34-nightly.20260814.1092")).toBe( + "lastcode/checkpoint/v0.0.34-nightly.20260814.1092", + ); + expect(resolveCheckpointTag(tags, "lastcode/checkpoint/v0.0.34-nightly.20260814.1095")).toBe( + "lastcode/checkpoint/v0.0.34-nightly.20260814.1095", + ); + }); + + it("rejects missing and ambiguous shorthand", () => { + expect(() => resolveCheckpointTag(tags, "1000")).toThrow("was not found"); + expect(() => + resolveCheckpointTag( + [ + "lastcode/checkpoint/v0.0.34-nightly.20260814.1090", + "lastcode/checkpoint/v0.0.35-nightly.20260815.1090", + ], + "1090", + ), + ).toThrow("ambiguous"); + }); + + it("parses the existing local update helper result", () => { + expect( + parseBuildResult( + 'noise\nLASTCODE_LOCAL_UPDATE_RESULT={"schemaVersion":1,"status":"built","outputDir":"/tmp/build"}\n', + ), + ).toMatchObject({ status: "built", outputDir: "/tmp/build" }); + }); + + it("launches with the repository's pinned Node runtime", () => { + expect(renderLauncher("/tmp/Last Code/lastcode-build.mjs")).toContain( + "mise exec node@24.13.1 -- node '/tmp/Last Code/lastcode-build.mjs' \"$@\"", + ); + }); +}); From d18a5791ac3a34769fb53cf67cc4bbe7d8eb2c99 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 14 Aug 2026 15:10:53 -0700 Subject: [PATCH 02/25] fix(lastcode): stabilize local checkpoint builds --- docs/lastcode/nightly-workflow.md | 4 +- scripts/lastcode-build.mjs | 227 ++++++++++++++++++++++++-- scripts/lastcode-build.test.mjs | 26 +++ scripts/lastcode-local-update.mjs | 76 +++++++-- scripts/lastcode-local-update.test.ts | 43 +++++ 5 files changed, 348 insertions(+), 28 deletions(-) diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index c9ef40083df0..c4b9022ba1fd 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -228,7 +228,9 @@ lastcode-build --checkpoint 1090 `lastcode/checkpoint/*-nightly.*.1090` tag. A full upstream nightly tag or full checkpoint tag is also accepted. The command uses the same dedicated worktree, full local CI, immutable artifact directory, and DMG/ZIP builder as the in-app -local updater. Completed builds are reused. +local updater. During a build it shows the latest log line above a stage-weighted +estimated progress bar; the complete output remains in +`~/.lastcode/local-updates/build.log`. Completed builds are reused. For lower-level or diagnostic use, check out the desired checkpoint, run full checkpoint CI, then build that same tag: diff --git a/scripts/lastcode-build.mjs b/scripts/lastcode-build.mjs index 648d9e12a2a7..e949068b984f 100644 --- a/scripts/lastcode-build.mjs +++ b/scripts/lastcode-build.mjs @@ -5,9 +5,47 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; const CHECKPOINT_PREFIX = "lastcode/checkpoint/"; const RESULT_PREFIX = "LASTCODE_LOCAL_UPDATE_RESULT="; +const LOG_POLL_INTERVAL_MS = 400; + +export const BUILD_PHASES = [ + { marker: "Building lastcode/checkpoint/", start: 0, estimateMs: 10_000 }, + { marker: "Preparing worktree", start: 0.01, estimateMs: 20_000 }, + { marker: "Scope: all", start: 0.03, estimateMs: 45_000 }, + { marker: "Done in", start: 0.08, estimateMs: 5_000 }, + { marker: "[lastcode:ci] 1/11", start: 0.09, estimateMs: 5_000 }, + { marker: "[lastcode:ci] 2/11", start: 0.1, estimateMs: 30_000 }, + { marker: "[lastcode:ci] 3/11", start: 0.14, estimateMs: 50_000 }, + { marker: "[lastcode:ci] 4/11", start: 0.2, estimateMs: 300_000 }, + { marker: "[lastcode:ci] 5/11", start: 0.35, estimateMs: 10_000 }, + { marker: "[lastcode:ci] 6/11", start: 0.36, estimateMs: 120_000 }, + { marker: "[lastcode:ci] 7/11", start: 0.49, estimateMs: 5_000 }, + { marker: "[lastcode:ci] 8/11", start: 0.5, estimateMs: 60_000 }, + { marker: "[lastcode:ci] 9/11", start: 0.55, estimateMs: 5_000 }, + { marker: "[lastcode:ci] 10/11", start: 0.56, estimateMs: 90_000 }, + { marker: "[lastcode:ci] 11/11", start: 0.64, estimateMs: 120_000 }, + { marker: "[lastcode:ci] Full local CI passed", start: 0.75, estimateMs: 5_000 }, + { marker: "Reusing full local CI stamp", start: 0.75, estimateMs: 5_000 }, + { marker: "[lastcode:build] Building", start: 0.76, estimateMs: 10_000 }, + { + marker: "[desktop-artifact] Building desktop/server/web artifacts", + start: 0.78, + estimateMs: 35_000, + }, + { marker: "web client branding", start: 0.84, estimateMs: 10_000 }, + { marker: "[desktop-artifact] Staging release app", start: 0.86, estimateMs: 15_000 }, + { + marker: "[desktop-artifact] Installing staged production dependencies", + start: 0.88, + estimateMs: 12_000, + }, + { marker: "[desktop-artifact] Building mac/dmg", start: 0.94, estimateMs: 110_000 }, + { marker: "[desktop-artifact] Done. Artifacts", start: 0.99, estimateMs: 10_000 }, + { marker: "[lastcode:build] Created", start: 0.995, estimateMs: 5_000 }, +]; const ansiEnabled = process.stdout.isTTY && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb"; @@ -25,6 +63,120 @@ function style(code, value) { return ansiEnabled ? `${code}${value}${ansi.reset}` : value; } +export function resolveBuildPhaseIndex(logChunk, currentIndex = 0) { + let resolved = currentIndex; + for (let index = currentIndex; index < BUILD_PHASES.length; index += 1) { + if (logChunk.includes(BUILD_PHASES[index].marker)) resolved = index; + } + return resolved; +} + +export function estimateBuildProgress(phaseIndex, elapsedMs) { + const phase = BUILD_PHASES[phaseIndex] ?? BUILD_PHASES[0]; + const nextStart = BUILD_PHASES[phaseIndex + 1]?.start ?? 1; + const phaseFraction = Math.min(0.95, Math.max(0, elapsedMs) / phase.estimateMs); + return phase.start + (nextStart - phase.start) * phaseFraction; +} + +export function renderProgressBar(progress, width = 44) { + const bounded = Math.min(1, Math.max(0, progress)); + const filled = Math.round(bounded * width); + return `<${"=".repeat(filled)}${"-".repeat(width - filled)}> ${String(Math.round(bounded * 100)).padStart(3)}% est.`; +} + +export function sanitizeLogLine(value, width = 80) { + const normalized = NodeUtil.stripVTControlCharacters(value).replaceAll(/\s+/g, " ").trim(); + if (normalized.length <= width) return normalized; + return `${normalized.slice(0, Math.max(1, width - 1))}…`; +} + +class BuildProgressDisplay { + constructor(logPath) { + this.logPath = logPath; + this.offset = NodeFS.existsSync(logPath) ? NodeFS.statSync(logPath).size : 0; + this.phaseIndex = 0; + this.phaseStartedAt = Date.now(); + this.lastLine = "Starting local build…"; + this.scanCarry = ""; + this.rendered = false; + this.lastNonTtyPhase = -1; + } + + readNewLog() { + if (!NodeFS.existsSync(this.logPath)) return; + const size = NodeFS.statSync(this.logPath).size; + if (size < this.offset) this.offset = 0; + if (size === this.offset) return; + const maximumRead = 2 * 1024 * 1024; + const position = Math.max(this.offset, size - maximumRead); + const length = size - position; + const buffer = Buffer.alloc(length); + const descriptor = NodeFS.openSync(this.logPath, "r"); + try { + NodeFS.readSync(descriptor, buffer, 0, length, position); + } finally { + NodeFS.closeSync(descriptor); + } + this.offset = size; + const chunk = buffer.toString("utf8"); + const scanText = `${this.scanCarry}${NodeUtil.stripVTControlCharacters(chunk)}`; + this.scanCarry = scanText.slice(-256); + const resolvedPhase = resolveBuildPhaseIndex(scanText, this.phaseIndex); + if (resolvedPhase !== this.phaseIndex) { + this.phaseIndex = resolvedPhase; + this.phaseStartedAt = Date.now(); + } + const lines = chunk.replaceAll("\r", "\n").split("\n"); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = sanitizeLogLine(lines[index], Math.max(30, (process.stdout.columns ?? 80) - 1)); + if (line) { + this.lastLine = line; + break; + } + } + } + + progress() { + return estimateBuildProgress(this.phaseIndex, Date.now() - this.phaseStartedAt); + } + + render() { + this.readNewLog(); + const progress = this.progress(); + if (!process.stdout.isTTY) { + if (this.lastNonTtyPhase !== this.phaseIndex) { + console.log(`[${Math.round(progress * 100)}% est.] ${this.lastLine}`); + this.lastNonTtyPhase = this.phaseIndex; + } + return; + } + const terminalWidth = process.stdout.columns ?? 80; + const barWidth = Math.max(16, Math.min(52, terminalWidth - 12)); + const status = sanitizeLogLine(this.lastLine, Math.max(30, terminalWidth - 1)); + const bar = renderProgressBar(progress, barWidth); + if (this.rendered) process.stdout.write("\r\u001b[2K\u001b[1A\r\u001b[2K"); + process.stdout.write(`${status}\n${bar}`); + this.rendered = true; + } + + start() { + this.render(); + this.timer = setInterval(() => this.render(), LOG_POLL_INTERVAL_MS); + } + + stop(completed) { + if (this.timer) clearInterval(this.timer); + this.readNewLog(); + if (completed) { + this.phaseIndex = BUILD_PHASES.length - 1; + this.phaseStartedAt = Date.now() - BUILD_PHASES.at(-1).estimateMs; + this.lastLine = "Build complete"; + } + this.render(); + if (process.stdout.isTTY && this.rendered) process.stdout.write("\n"); + } +} + function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; } @@ -169,6 +321,7 @@ function installCommand(repoRoot, home) { } const binDirectory = NodePath.join(home, ".lastcode", "bin"); const moduleTarget = NodePath.join(binDirectory, "lastcode-build.mjs"); + const helperTarget = NodePath.join(binDirectory, "lastcode-local-update.mjs"); const target = NodePath.join(binDirectory, "lastcode-build"); const exposedDirectory = NodePath.join(home, ".local", "bin"); const exposed = NodePath.join(exposedDirectory, "lastcode-build"); @@ -177,6 +330,13 @@ function installCommand(repoRoot, home) { NodeFS.mkdirSync(binDirectory, { recursive: true }); NodeFS.mkdirSync(exposedDirectory, { recursive: true }); NodeFS.copyFileSync(NodeURL.fileURLToPath(import.meta.url), moduleTarget); + NodeFS.copyFileSync( + NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "lastcode-local-update.mjs", + ), + helperTarget, + ); NodeFS.writeFileSync(target, renderLauncher(moduleTarget), { encoding: "utf8", mode: 0o755 }); NodeFS.chmodSync(target, 0o755); NodeFS.writeFileSync( @@ -204,8 +364,11 @@ export function parseBuildResult(stdout) { return result; } -function buildCheckpoint(repoRoot, home, checkpointTag) { - const helperPath = NodePath.join(repoRoot, "scripts", "lastcode-local-update.mjs"); +async function buildCheckpoint(repoRoot, home, checkpointTag) { + const helperPath = NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "lastcode-local-update.mjs", + ); if (!NodeFS.existsSync(helperPath)) { throw new Error(`Local build helper is missing at ${helperPath}.`); } @@ -215,23 +378,57 @@ function buildCheckpoint(repoRoot, home, checkpointTag) { ); console.log(style(ansi.lavender, `Full CI and packaging logs: ${logPath}`)); - const result = NodeChildProcess.spawnSync( + const display = new BuildProgressDisplay(logPath); + const child = NodeChildProcess.spawn( process.execPath, [helperPath, "build", "--repo", repoRoot, "--home", home, "--checkpoint", checkpointTag], - { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + { + cwd: repoRoot, + env: { + ...process.env, + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + PATH: `${NodePath.join(repoRoot, "node_modules", ".bin")}${NodePath.delimiter}${process.env.PATH ?? ""}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, ); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error(result.stderr.trim() || `Local build failed with exit code ${result.status}.`); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + display.start(); + let completed = false; + try { + const result = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + if (result.code !== 0) { + throw new Error( + stderr.trim() || + `Local build failed with ${result.signal ? `signal ${result.signal}` : `exit code ${result.code}`}.`, + ); + } + const build = parseBuildResult(stdout); + const dmg = NodeFS.readdirSync(build.outputDir).find((entry) => entry.endsWith(".dmg")); + if (!dmg) throw new Error(`Build completed without a DMG in ${build.outputDir}.`); + completed = true; + display.stop(true); + console.log(style(ansi.green, "Build ready")); + console.log(NodePath.join(build.outputDir, dmg)); + } finally { + if (!completed) display.stop(false); } - const build = parseBuildResult(result.stdout); - const dmg = NodeFS.readdirSync(build.outputDir).find((entry) => entry.endsWith(".dmg")); - if (!dmg) throw new Error(`Build completed without a DMG in ${build.outputDir}.`); - console.log(style(ansi.green, "Build ready")); - console.log(NodePath.join(build.outputDir, dmg)); } -function main(argv) { +async function main(argv) { const options = parseOptions(argv); if (options.help) { console.log("Usage: lastcode-build [CHECKPOINT]"); @@ -248,7 +445,7 @@ function main(argv) { return; } const tags = splitLines(runGit(repoRoot, ["tag", "--list", `${CHECKPOINT_PREFIX}v*-nightly.*`])); - buildCheckpoint(repoRoot, home, resolveCheckpointTag(tags, options.checkpoint)); + await buildCheckpoint(repoRoot, home, resolveCheckpointTag(tags, options.checkpoint)); } if ( @@ -257,7 +454,7 @@ if ( NodeFS.realpathSync(NodeURL.fileURLToPath(import.meta.url)) ) { try { - main(process.argv.slice(2)); + await main(process.argv.slice(2)); } catch (error) { console.error( style( diff --git a/scripts/lastcode-build.test.mjs b/scripts/lastcode-build.test.mjs index ddd8299338b0..afc5d26d87af 100644 --- a/scripts/lastcode-build.test.mjs +++ b/scripts/lastcode-build.test.mjs @@ -1,10 +1,15 @@ import { describe, expect, it } from "vite-plus/test"; import { + BUILD_PHASES, + estimateBuildProgress, parseBuildResult, parseOptions, + renderProgressBar, renderLauncher, + resolveBuildPhaseIndex, resolveCheckpointTag, + sanitizeLogLine, } from "./lastcode-build.mjs"; const tags = [ @@ -58,6 +63,27 @@ describe("LastCode userland build command", () => { ).toMatchObject({ status: "built", outputDir: "/tmp/build" }); }); + it("advances estimated progress from build log stage markers", () => { + const testsPhase = resolveBuildPhaseIndex("[lastcode:ci] 4/11 Workspace tests"); + expect(BUILD_PHASES[testsPhase].start).toBe(0.2); + expect(estimateBuildProgress(testsPhase, 75_000)).toBeGreaterThan(0.2); + expect(estimateBuildProgress(testsPhase, 1_000_000)).toBeLessThan(0.35); + expect(resolveBuildPhaseIndex("older output", testsPhase)).toBe(testsPhase); + expect( + resolveBuildPhaseIndex("[desktop-artifact] Building mac/dmg", testsPhase), + ).toBeGreaterThan(testsPhase); + }); + + it("renders a bounded estimated progress bar", () => { + expect(renderProgressBar(0.25, 8)).toBe("<==------> 25% est."); + expect(renderProgressBar(2, 4)).toBe("<====> 100% est."); + }); + + it("turns colored, long log output into one terminal-safe status line", () => { + expect(sanitizeLogLine("\u001b[32mhello\u001b[0m\tworld", 80)).toBe("hello world"); + expect(sanitizeLogLine("a very long status line", 10)).toBe("a very lo…"); + }); + it("launches with the repository's pinned Node runtime", () => { expect(renderLauncher("/tmp/Last Code/lastcode-build.mjs")).toContain( "mise exec node@24.13.1 -- node '/tmp/Last Code/lastcode-build.mjs' \"$@\"", diff --git a/scripts/lastcode-local-update.mjs b/scripts/lastcode-local-update.mjs index 98c4237da91d..d9ea3d869163 100644 --- a/scripts/lastcode-local-update.mjs +++ b/scripts/lastcode-local-update.mjs @@ -11,6 +11,33 @@ import * as NodePath from "node:path"; const CHECKPOINT_PREFIX = "lastcode/checkpoint/"; const RESULT_PREFIX = "LASTCODE_LOCAL_UPDATE_RESULT="; +export function resolveDeterministicBuildEnvironment(environment = process.env) { + return { ...environment, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }; +} + +export function resolveLocalBuildEnvironment(worktreePath, environment = process.env) { + const resolved = resolveDeterministicBuildEnvironment(environment); + return { + ...resolved, + PATH: `${NodePath.join(worktreePath, "node_modules", ".bin")}${NodePath.delimiter}${resolved.PATH ?? ""}`, + }; +} + +export function isReusableCheckpointCiStamp( + stamp, + checkpointTag, + checkpointCommit, + upstreamCommit, +) { + return ( + stamp?.schemaVersion === 2 && + stamp.commit === checkpointCommit && + stamp.context?.kind === "checkpoint" && + stamp.context.checkpointTag === checkpointTag && + stamp.context.upstreamCommit === upstreamCommit + ); +} + function run(cwd, command, args, options = {}) { const result = NodeChildProcess.spawnSync(command, args, { cwd, @@ -270,6 +297,7 @@ function build(options) { } const worktreePath = NodePath.join(updateRoot, "build-worktree"); prepareBuildWorktree(options.repoRoot, worktreePath, options.checkpointTag, logFd); + const buildEnvironment = resolveLocalBuildEnvironment(worktreePath); const installer = NodePath.join(options.repoRoot, "node_modules", ".bin", "vp"); if (!NodeFS.existsSync(installer)) { throw new Error(`Checkpoint automation dependencies are missing at ${installer}.`); @@ -277,18 +305,42 @@ function build(options) { run(worktreePath, installer, ["install", "--frozen-lockfile"], { logFd }); const mise = resolveMise(options.home); const nodeCommand = ["exec", "node@24.13.1", "--", "node"]; - run( - worktreePath, - mise, - [ - ...nodeCommand, - "scripts/lastcode-local-ci.ts", - "--full", - "--checkpoint", - options.checkpointTag, - ], - { logFd }, + const nightlyTag = options.checkpointTag.slice(CHECKPOINT_PREFIX.length); + const upstreamCommit = git(options.repoRoot, ["rev-parse", `${nightlyTag}^{commit}`]); + const commonGitDirectory = NodePath.resolve( + options.repoRoot, + git(options.repoRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), ); + const stampPath = NodePath.join(commonGitDirectory, "lastcode-ci", `${checkpointCommit}.json`); + let reusableCiStamp = false; + if (NodeFS.existsSync(stampPath)) { + try { + reusableCiStamp = isReusableCheckpointCiStamp( + JSON.parse(NodeFS.readFileSync(stampPath, "utf8")), + options.checkpointTag, + checkpointCommit, + upstreamCommit, + ); + } catch { + reusableCiStamp = false; + } + } + if (reusableCiStamp) { + NodeFS.writeSync(logFd, `Reusing full local CI stamp: ${stampPath}\n`); + } else { + run( + worktreePath, + mise, + [ + ...nodeCommand, + "scripts/lastcode-local-ci.ts", + "--full", + "--checkpoint", + options.checkpointTag, + ], + { logFd, env: buildEnvironment }, + ); + } run( worktreePath, mise, @@ -300,7 +352,7 @@ function build(options) { "--output-root", outputRoot, ], - { logFd }, + { logFd, env: buildEnvironment }, ); } catch (error) { throw new Error( diff --git a/scripts/lastcode-local-update.test.ts b/scripts/lastcode-local-update.test.ts index b5cb385f1884..48bfa5195c29 100644 --- a/scripts/lastcode-local-update.test.ts +++ b/scripts/lastcode-local-update.test.ts @@ -7,15 +7,58 @@ import * as NodeChildProcess from "node:child_process"; import { compareNightlyVersions, + isReusableCheckpointCiStamp, parseNightlyVersion, parseOptions, prepareBuildWorktree, quarantineIncompleteBuild, + resolveDeterministicBuildEnvironment, resolveExistingBuild, resolveLatestCheckpointTag, + resolveLocalBuildEnvironment, } from "./lastcode-local-update.mjs"; describe("lastcode-local-update", () => { + it("uses a deterministic locale for checkpoint validation and packaging", () => { + assert.deepInclude(resolveDeterministicBuildEnvironment({ PATH: "/bin" }), { + PATH: "/bin", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + }); + assert.match( + resolveLocalBuildEnvironment("/tmp/build tree", { PATH: "/bin" }).PATH, + /^\/tmp\/build tree\/node_modules\/\.bin:/, + ); + }); + + it("only reuses a full CI stamp for the exact checkpoint context", () => { + const stamp = { + schemaVersion: 2, + commit: "checkpoint-commit", + context: { + kind: "checkpoint", + checkpointTag: "lastcode/checkpoint/v0.0.34-nightly.20260814.1090", + upstreamCommit: "upstream-commit", + }, + }; + assert.isTrue( + isReusableCheckpointCiStamp( + stamp, + "lastcode/checkpoint/v0.0.34-nightly.20260814.1090", + "checkpoint-commit", + "upstream-commit", + ), + ); + assert.isFalse( + isReusableCheckpointCiStamp( + stamp, + "lastcode/checkpoint/v0.0.34-nightly.20260814.1091", + "checkpoint-commit", + "upstream-commit", + ), + ); + }); + it("orders and selects immutable checkpoint tags", () => { assert.deepEqual( parseNightlyVersion("0.0.34-nightly.20260814.1089")?.parts, From 206dc061d64f573f60dbac0807f0af74ebbae2e1 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 14 Aug 2026 16:38:44 -0700 Subject: [PATCH 03/25] fix(web): recover desktop auth after keychain prompts --- apps/web/src/authBootstrap.test.ts | 30 +++++++++++++ apps/web/src/environments/primary/auth.ts | 51 ++++++++++++++--------- 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb03..1aa486984d48 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -269,6 +269,36 @@ describe("resolveInitialServerAuthGateState", () => { expect(attempts).toBe(4); }); + it("retries desktop session bootstrap after a blocking credential prompt", async () => { + vi.useFakeTimers(); + installDesktopBootstrap(); + let attempts = 0; + const request = HttpClientRequest.get("http://localhost/api/auth/session"); + const response = HttpClientResponse.fromWeb( + request, + new Response("Internal Server Error", { status: 500 }), + ); + const runner: PrimaryHttpEffectRunner = async () => { + attempts += 1; + if (attempts === 1) { + await new Promise((resolve) => setTimeout(resolve, 20_000)); + throw new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); + } + return unauthenticatedSession(DESKTOP_AUTH) as A; + }; + __setPrimaryHttpRunnerForTests(runner); + + const { fetchSessionState } = await import("./environments/primary"); + + const sessionPromise = fetchSessionState(); + await vi.advanceTimersByTimeAsync(20_500); + + await expect(sessionPromise).resolves.toEqual(unauthenticatedSession(DESKTOP_AUTH)); + expect(attempts).toBe(2); + }); + it("takes a pairing token from the location hash and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); const { takePairingTokenFromUrl } = await import("./environments/primary"); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f9381bcad714..bef0d77dbff8 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -187,20 +187,23 @@ function getDesktopBootstrapCredential(): string | null { } export async function fetchSessionState(): Promise { - return retryTransientBootstrap(async () => { - try { - return await runPrimaryHttp( - PrimaryEnvironmentHttpClient.pipe( - Effect.flatMap((client) => client.auth.session({ headers: {} })), - ), - ); - } catch (error) { - throw PrimaryEnvironmentRequestError.fromCause({ - operation: "fetch-session-state", - cause: error, - }); - } - }); + return retryTransientBootstrap( + async () => { + try { + return await runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => client.auth.session({ headers: {} })), + ), + ); + } catch (error) { + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-session-state", + cause: error, + }); + } + }, + { retryInternalServerError: window.desktopBridge !== undefined }, + ); } function readHttpApiStatus(error: unknown): number | null { @@ -280,20 +283,27 @@ const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); const BOOTSTRAP_RETRY_TIMEOUT_MS = 15_000; const BOOTSTRAP_RETRY_STEP_MS = 500; -export async function retryTransientBootstrap(operation: () => Promise): Promise { +export async function retryTransientBootstrap( + operation: () => Promise, + options: { readonly retryInternalServerError?: boolean } = {}, +): Promise { const startedAt = Date.now(); + let retryCount = 0; while (true) { try { return await operation(); } catch (error) { - if (!isTransientBootstrapError(error)) { + if (!isTransientBootstrapError(error, options.retryInternalServerError === true)) { throw error; } - if (Date.now() - startedAt >= BOOTSTRAP_RETRY_TIMEOUT_MS) { + // A native credential prompt can block Electron's main process longer + // than this deadline. Always allow the first retry after control returns. + if (retryCount > 0 && Date.now() - startedAt >= BOOTSTRAP_RETRY_TIMEOUT_MS) { throw error; } + retryCount += 1; await waitForBootstrapRetry(BOOTSTRAP_RETRY_STEP_MS); } } @@ -305,9 +315,12 @@ function waitForBootstrapRetry(delayMs: number): Promise { }); } -function isTransientBootstrapError(error: unknown): boolean { +function isTransientBootstrapError(error: unknown, retryInternalServerError: boolean): boolean { if (isPrimaryEnvironmentRequestError(error)) { - return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); + return ( + TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status) || + (retryInternalServerError && error.status === 500) + ); } if (error instanceof TypeError) { From ca3af15652fdbbbe354c99b64738b6d0441e5108 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 14 Aug 2026 16:46:42 -0700 Subject: [PATCH 04/25] feat(lastcode): stabilize local nightly lifecycle --- docs/lastcode/local-nightly-updates.md | 15 ++- docs/lastcode/nightly-workflow.md | 53 +++++++- scripts/build-desktop-artifact.test.ts | 19 +++ scripts/build-desktop-artifact.ts | 8 +- scripts/lastcode-build-mac-arm64.ts | 4 + scripts/lastcode-build.mjs | 163 ++++++++++++++++++++++- scripts/lastcode-build.test.mjs | 31 +++++ scripts/lastcode-checkpoint.test.ts | 23 ++++ scripts/lastcode-checkpoint.ts | 75 ++++++++++- scripts/lastcode-local-update.d.mts | 24 ++++ scripts/lastcode-local-update.mjs | 34 ++++- scripts/lastcode-local-update.test.ts | 40 +++++- scripts/lastcode-nightly-service.test.ts | 2 +- scripts/lastcode-nightly-service.ts | 1 + 14 files changed, 476 insertions(+), 16 deletions(-) diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index 1a8732d8a506..0bd05856ba83 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -54,8 +54,16 @@ number such as `1090` selects the unique checkpoint ending in `.1090`. app, and relaunches it. The DMG is retained as the inspectable, manually installable artifact. The -paired ZIP and `nightly-mac.yml` are generated by the same ad-hoc-signed build -and are used only to reuse Electron's existing install machinery. +paired ZIP and `nightly-mac.yml` are generated by the same locally signed build +and are used only to reuse Electron's existing install machinery. Builds use +the persistent identity selected by `lastcode-build --setup-signing` when +configured, and otherwise fall back to ad-hoc signing. + +Electron's macOS credential storage can synchronously block its main process +while the Keychain prompt is open. If that delays the first local authentication +request long enough to return an internal error, the desktop client performs a +fresh request after the prompt is resolved rather than leaving the startup +error screen visible. ## Failure handling and logs @@ -87,7 +95,8 @@ restoring hosted updater state, so a late poll cannot turn the feature back on. - packaged LastCode desktop builds on Apple Silicon macOS; - local `lastcode/checkpoint/*` tags already fetched by the checkpoint daemon; -- ad-hoc-signed, non-notarized personal builds; and +- locally signed, non-notarized personal builds (persistent identity when + configured, ad-hoc otherwise); and - one build at a time, initiated interactively from the sidebar. Public releases, notarization, x64 builds, remote build hosts, and automatic diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index c4b9022ba1fd..441d64808e17 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -46,7 +46,10 @@ pnpm lastcode:checkpoint --dry-run Run it and publish checkpoint tags: ```bash -pnpm lastcode:checkpoint --push-tags --promote-if-no-open-prs +pnpm lastcode:checkpoint \ + --push-tags \ + --promote-if-no-open-prs \ + --mirror-upstream-main ``` The command: @@ -119,9 +122,19 @@ the local push gate or pushing an unchanged branch. The job executes: ```bash -pnpm lastcode:checkpoint --push-tags --promote-if-no-open-prs +pnpm lastcode:checkpoint \ + --push-tags \ + --promote-if-no-open-prs \ + --mirror-upstream-main ``` +The scheduled job also keeps the fork's clean `main` branch synchronized with +`upstream/main`. This is a guarded remote mirror, not a checkout operation: it +never moves or rewrites a human worktree's local `main` branch. The mirror only +advances when the fork branch is an ancestor of upstream, and pushes with an +exact force-with-lease value. If the fork's `main` has diverged, checkpointing +stops and reports the divergence instead of overwriting either history. + Operational commands: ```bash @@ -260,7 +273,41 @@ release-lastcode/ `build-manifest.json` records the checkpoint tag, upstream tag and commit, LastCode commit, build tag, build time, platform, architecture, artifact sizes, -and SHA-256 hashes. `SHA256SUMS` provides a conventional verification file. +SHA-256 hashes, and the local signing identity when one was used. `SHA256SUMS` +provides a conventional verification file. + +### Stable local signing + +Ad-hoc signing works without an Apple account, but each rebuild has a different +code identity. macOS can therefore ask for Keychain access again after an +update. For stable personal builds, create a free **Apple Development** +certificate with Xcode's Personal Team, then configure the userland builder: + +```bash +lastcode-build --setup-signing +lastcode-build --signing-status +``` + +If the Mac has multiple eligible identities, choose the one printed by the +setup error: + +```bash +lastcode-build --setup-signing --signing-identity <40-character-hash> +``` + +The selected identity is recorded by hash and name in +`~/.lastcode/local-signing.json` with user-only permissions. It is not a +certificate or private key; those remain in the login Keychain. Later builds +pass the stable identity to Electron Builder. If no configuration exists, +LastCode deliberately retains the ad-hoc fallback. A previously completed +ad-hoc artifact is not reused after persistent signing is enabled; it is +quarantined and rebuilt so the requested checkpoint actually has the selected +identity. + +Creating an Apple Development certificate through a Personal Team does not +require the paid Apple Developer Program. It does require signing into Xcode +once, and Personal Team certificates and provisioning assets may expire and +need renewal. The build creates a local annotated `lastcode/build/...` tag. Pass `--push-tag` only when that build record should be published to the fork. diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index faf5480420a9..fec71303fd37 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1042,6 +1042,25 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); + it.effect("uses a configured persistent identity for local macOS builds", () => + Effect.gen(function* () { + const config = yield* createBuildConfig( + "mac", + "dmg", + "1.2.3", + false, + false, + undefined, + undefined, + "0123456789ABCDEF0123456789ABCDEF01234567", + ); + + const mac = config.mac as Record; + assert.equal(mac.identity, "0123456789ABCDEF0123456789ABCDEF01234567"); + assert.equal(mac.hardenedRuntime, false); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), + ); + it.effect("keeps executable resource editing enabled for unsigned Windows builds", () => Effect.gen(function* () { const config = yield* createBuildConfig( diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 2d046d030173..77c9bce285dd 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -759,6 +759,7 @@ interface ResolvedBuildOptions { readonly mockUpdates: boolean; readonly mockUpdateServerPort: number | undefined; readonly wslPrebuild: string | undefined; + readonly localMacSigningIdentity: string | undefined; } interface StagePackageJson { @@ -1234,6 +1235,7 @@ const BuildEnvConfig = Config.all({ // into the staged node-pty so the WSL backend ships a ready binary and never // compiles on the user's machine. wslPrebuild: Config.string("T3CODE_DESKTOP_WSL_PREBUILD").pipe(Config.option), + localMacSigningIdentity: Config.string("LASTCODE_LOCAL_MAC_SIGNING_IDENTITY").pipe(Config.option), }); const MockUpdateServerPortSchema = Schema.NumberFromString.check( @@ -1327,6 +1329,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const wslPrebuild = Option.getOrUndefined(input.wslPrebuild) ?? Option.getOrUndefined(env.wslPrebuild); + const localMacSigningIdentity = Option.getOrUndefined(env.localMacSigningIdentity); return { platform, @@ -1341,6 +1344,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( mockUpdates, mockUpdateServerPort, wslPrebuild, + localMacSigningIdentity, } satisfies ResolvedBuildOptions; }); @@ -2025,6 +2029,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( readonly provisioningProfilePath: string; } | undefined, + localMacSigningIdentity?: string, ) { const buildConfig: Record = { appId: LASTCODE_DESKTOP_DISTRIBUTION.appId, @@ -2064,7 +2069,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( category: "public.app-category.developer-tools", ...(!signed ? { - identity: "-", + identity: localMacSigningIdentity ?? "-", hardenedRuntime: false, } : {}), @@ -2922,6 +2927,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( provisioningProfilePath: macPasskeySigning.provisioningProfilePath, } : undefined, + options.localMacSigningIdentity, ), dependencies: stageDependencies, devDependencies: { diff --git a/scripts/lastcode-build-mac-arm64.ts b/scripts/lastcode-build-mac-arm64.ts index 276fd1e43a6b..b35dcebb609b 100644 --- a/scripts/lastcode-build-mac-arm64.ts +++ b/scripts/lastcode-build-mac-arm64.ts @@ -35,6 +35,7 @@ interface BuildManifest { readonly checkpointTag: string; readonly lastCodeCommit: string; readonly platform: "mac"; + readonly signingIdentity?: string; readonly upstreamCommit: string; readonly upstreamTag: string; } @@ -208,6 +209,9 @@ function main(argv: ReadonlyArray): void { checkpointTag: options.checkpointTag, lastCodeCommit: commit, platform: "mac", + ...(env.LASTCODE_LOCAL_MAC_SIGNING_IDENTITY + ? { signingIdentity: env.LASTCODE_LOCAL_MAC_SIGNING_IDENTITY } + : {}), upstreamCommit, upstreamTag: nightlyTag, }; diff --git a/scripts/lastcode-build.mjs b/scripts/lastcode-build.mjs index e949068b984f..47338368244d 100644 --- a/scripts/lastcode-build.mjs +++ b/scripts/lastcode-build.mjs @@ -10,6 +10,7 @@ import * as NodeUtil from "node:util"; const CHECKPOINT_PREFIX = "lastcode/checkpoint/"; const RESULT_PREFIX = "LASTCODE_LOCAL_UPDATE_RESULT="; const LOG_POLL_INTERVAL_MS = 400; +const SIGNING_CONFIG_FILE = "local-signing.json"; export const BUILD_PHASES = [ { marker: "Building lastcode/checkpoint/", start: 0, estimateMs: 10_000 }, @@ -225,18 +226,37 @@ export function parseOptions(argv) { let checkpoint; let install = false; let repoRoot; + let setupSigning = false; + let signingStatus = false; + let signingIdentity; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--") continue; if (arg === "--install") install = true; - else if (arg === "-c" || arg === "--checkpoint" || arg === "--repo") { + else if (arg === "--setup-signing") setupSigning = true; + else if (arg === "--signing-status") signingStatus = true; + else if ( + arg === "-c" || + arg === "--checkpoint" || + arg === "--repo" || + arg === "--signing-identity" + ) { const value = argv[index + 1]; if (!value) throw new Error(`Missing value for ${arg}.`); if (arg === "--repo") repoRoot = value; + else if (arg === "--signing-identity") signingIdentity = value; else checkpoint = value; index += 1; } else if (arg === "-h" || arg === "--help") { - return { help: true, checkpoint, install, repoRoot }; + return { + help: true, + checkpoint, + install, + repoRoot, + setupSigning, + signingStatus, + signingIdentity, + }; } else if (arg.startsWith("-")) { throw new Error(`Unknown argument '${arg}'.`); } else if (checkpoint) { @@ -245,7 +265,134 @@ export function parseOptions(argv) { checkpoint = arg; } } - return { help: false, checkpoint, install, repoRoot }; + if (signingIdentity && !setupSigning) { + throw new Error("--signing-identity requires --setup-signing."); + } + const specialActionCount = [install, setupSigning, signingStatus].filter(Boolean).length; + if (specialActionCount > 1 || (specialActionCount > 0 && checkpoint)) { + throw new Error("Install, signing setup/status, and checkpoint builds are separate actions."); + } + return { + help: false, + checkpoint, + install, + repoRoot, + setupSigning, + signingStatus, + signingIdentity, + }; +} + +export function parseCodeSigningIdentities(output) { + return splitLines(output).flatMap((line) => { + const match = /^\d+\)\s+([0-9A-F]{40})\s+"([^"]+)"$/.exec(line); + if (!match) return []; + return [{ hash: match[1], name: match[2] }]; + }); +} + +function isPersistentMacSigningIdentity(identity) { + return ["Apple Development:", "Mac Developer:", "Developer ID Application:"].some((prefix) => + identity.name.startsWith(prefix), + ); +} + +export function selectCodeSigningIdentity(identities, selector) { + const eligible = identities.filter(isPersistentMacSigningIdentity); + if (selector) { + const normalized = selector.toUpperCase(); + const matches = eligible.filter(({ hash, name }) => hash === normalized || name === selector); + if (matches.length === 1) return matches[0]; + if (matches.length === 0) { + throw new Error(`No usable macOS code-signing identity matched '${selector}'.`); + } + throw new Error(`Signing identity selector '${selector}' is ambiguous.`); + } + if (eligible.length === 1) return eligible[0]; + if (eligible.length === 0) { + throw new Error( + "No persistent macOS signing identity was found. In Xcode Settings → Accounts, select your free Personal Team, open Manage Certificates, and create an Apple Development certificate; then rerun lastcode-build --setup-signing.", + ); + } + throw new Error( + `Multiple usable signing identities were found. Rerun with --setup-signing --signing-identity HASH:\n${eligible.map(({ hash, name }) => ` ${hash} ${name}`).join("\n")}`, + ); +} + +function findCodeSigningIdentities() { + const result = NodeChildProcess.spawnSync( + "security", + ["find-identity", "-v", "-p", "codesigning"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(result.stderr.trim() || "Could not inspect macOS code-signing identities."); + } + return parseCodeSigningIdentities(result.stdout); +} + +function signingConfigPath(home) { + return NodePath.join(home, ".lastcode", SIGNING_CONFIG_FILE); +} + +export function readSigningConfiguration(home) { + const path = signingConfigPath(home); + if (!NodeFS.existsSync(path)) return undefined; + const config = JSON.parse(NodeFS.readFileSync(path, "utf8")); + if ( + config?.schemaVersion !== 1 || + typeof config.identityHash !== "string" || + !/^[0-9A-F]{40}$/.test(config.identityHash) || + typeof config.identityName !== "string" + ) { + throw new Error(`Invalid LastCode signing configuration at ${path}.`); + } + return config; +} + +function setupSigning(home, selector) { + const identity = selectCodeSigningIdentity(findCodeSigningIdentities(), selector); + const path = signingConfigPath(home); + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync( + path, + `${JSON.stringify( + { + schemaVersion: 1, + identityHash: identity.hash, + identityName: identity.name, + configuredAt: new Date().toISOString(), + }, + undefined, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + NodeFS.chmodSync(path, 0o600); + console.log(style(ansi.green, "Persistent local signing enabled")); + console.log(`${identity.name}\n${identity.hash}`); + console.log(`Configuration: ${path}`); +} + +function showSigningStatus(home) { + const config = readSigningConfiguration(home); + if (!config) { + console.log("Persistent local signing is not configured; builds use ad-hoc signing."); + console.log( + "Run lastcode-build --setup-signing after creating an Apple Development certificate in Xcode.", + ); + return; + } + const installed = findCodeSigningIdentities().some(({ hash }) => hash === config.identityHash); + console.log( + `Persistent local signing: ${installed ? "ready" : "configured identity is unavailable"}`, + ); + console.log(`${config.identityName}\n${config.identityHash}`); + console.log(`Configuration: ${signingConfigPath(home)}`); } export function resolveCheckpointTag(tags, selector) { @@ -433,12 +580,22 @@ async function main(argv) { if (options.help) { console.log("Usage: lastcode-build [CHECKPOINT]"); console.log(" lastcode-build --checkpoint CHECKPOINT"); + console.log(" lastcode-build --setup-signing [--signing-identity HASH]"); + console.log(" lastcode-build --signing-status"); console.log(""); console.log("CHECKPOINT may be 1090, a full nightly tag, or a lastcode/checkpoint tag."); console.log("Without CHECKPOINT, the newest local checkpoint is built."); return; } const home = NodeOS.homedir(); + if (options.setupSigning) { + setupSigning(home, options.signingIdentity); + return; + } + if (options.signingStatus) { + showSigningStatus(home); + return; + } const repoRoot = resolveConfiguredRepo(home, options.repoRoot); if (options.install) { installCommand(repoRoot, home); diff --git a/scripts/lastcode-build.test.mjs b/scripts/lastcode-build.test.mjs index afc5d26d87af..816d5f901afb 100644 --- a/scripts/lastcode-build.test.mjs +++ b/scripts/lastcode-build.test.mjs @@ -4,7 +4,9 @@ import { BUILD_PHASES, estimateBuildProgress, parseBuildResult, + parseCodeSigningIdentities, parseOptions, + selectCodeSigningIdentity, renderProgressBar, renderLauncher, resolveBuildPhaseIndex, @@ -26,6 +28,35 @@ describe("LastCode userland build command", () => { expect(() => parseOptions(["1090", "1092"])).toThrow("Unexpected second checkpoint"); }); + it("parses signing setup as a separate userland action", () => { + expect( + parseOptions([ + "--setup-signing", + "--signing-identity", + "0123456789ABCDEF0123456789ABCDEF01234567", + ]), + ).toMatchObject({ + setupSigning: true, + signingIdentity: "0123456789ABCDEF0123456789ABCDEF01234567", + }); + expect(parseOptions(["--signing-status"]).signingStatus).toBe(true); + expect(() => parseOptions(["--setup-signing", "1090"])).toThrow("separate actions"); + }); + + it("selects a persistent Apple Development identity", () => { + const identities = parseCodeSigningIdentities(` + 1) 0123456789ABCDEF0123456789ABCDEF01234567 "Apple Development: LastCode Test (TEAM123456)" + 2) FEDCBA9876543210FEDCBA9876543210FEDCBA98 "Unrelated Certificate" + 2 valid identities found + `); + + expect(selectCodeSigningIdentity(identities)).toEqual({ + hash: "0123456789ABCDEF0123456789ABCDEF01234567", + name: "Apple Development: LastCode Test (TEAM123456)", + }); + expect(() => selectCodeSigningIdentity([])).toThrow("Xcode Settings"); + }); + it("selects the newest checkpoint by default", () => { expect(resolveCheckpointTag(tags)).toBe("lastcode/checkpoint/v0.0.34-nightly.20260814.1095"); }); diff --git a/scripts/lastcode-checkpoint.test.ts b/scripts/lastcode-checkpoint.test.ts index cb85aa7dc555..e8cc1e299f6b 100644 --- a/scripts/lastcode-checkpoint.test.ts +++ b/scripts/lastcode-checkpoint.test.ts @@ -8,7 +8,9 @@ import { checkpointVpPaths, promotionNeeded, resolveCheckpointPlan, + resolveUpstreamMainMirror, unpublishedCheckpointTags, + upstreamMainMirrorPushArgs, worktreeAddArgs, worktreeVp, } from "./lastcode-checkpoint.ts"; @@ -106,6 +108,27 @@ it("skips promotion when main already points at the checkpoint", () => { assert.equal(promotionNeeded("main", "checkpoint"), true); }); +it("mirrors upstream main with an exact lease on the fork branch", () => { + const pushArgs = [ + "push", + "--force-with-lease=refs/heads/main:old-main", + "origin", + "upstream-main:refs/heads/main", + ]; + assert.deepStrictEqual( + upstreamMainMirrorPushArgs("origin", "old-main", "upstream-main"), + pushArgs, + ); + assert.deepStrictEqual( + resolveUpstreamMainMirror("origin", "old-main", "upstream-main", true), + pushArgs, + ); + assert.equal(resolveUpstreamMainMirror("origin", "same", "same", true), undefined); + expect(() => resolveUpstreamMainMirror("origin", "fork-main", "upstream-main", false)).toThrow( + /origin\/main has diverged/, + ); +}); + it("cleans up publication failures but retains recovery state for earlier failures", () => { assert.deepStrictEqual( checkpointFailureDisposition("lastcode/checkpoint/v1", "sync/nightly/v1"), diff --git a/scripts/lastcode-checkpoint.ts b/scripts/lastcode-checkpoint.ts index 0af53885cd14..165aac430674 100644 --- a/scripts/lastcode-checkpoint.ts +++ b/scripts/lastcode-checkpoint.ts @@ -29,6 +29,7 @@ export type PromotionMode = "never" | "always" | "if-no-open-prs"; interface CheckpointOptions { readonly dryRun: boolean; readonly fetch: boolean; + readonly mirrorUpstreamMain: boolean; readonly promotion: PromotionMode; readonly pushTags: boolean; readonly smoke: boolean; @@ -235,6 +236,32 @@ export function promotionNeeded(remoteCommit: string, checkpointCommit: string): return remoteCommit !== checkpointCommit; } +export function upstreamMainMirrorPushArgs( + pushRemote: string, + remoteCommit: string, + upstreamCommit: string, +): ReadonlyArray { + return [ + "push", + `--force-with-lease=refs/heads/main:${remoteCommit}`, + pushRemote, + `${upstreamCommit}:refs/heads/main`, + ]; +} + +export function resolveUpstreamMainMirror( + pushRemote: string, + remoteCommit: string, + upstreamCommit: string, + remoteIsAncestor: boolean, +): ReadonlyArray | undefined { + if (remoteCommit === upstreamCommit) return undefined; + if (!remoteIsAncestor) { + throw new Error(`Refusing to mirror upstream: ${pushRemote}/main has diverged.`); + } + return upstreamMainMirrorPushArgs(pushRemote, remoteCommit, upstreamCommit); +} + export function checkpointFailureDisposition( pendingCheckpointTag: string | undefined, recoveryBranch: string, @@ -267,6 +294,7 @@ function pruneUnpublishedCheckpointTags(repoRoot: string, pushRemote: string): v function parseArgs(argv: ReadonlyArray): CheckpointOptions { let dryRun = false; let fetch = true; + let mirrorUpstreamMain = false; let promotion: PromotionMode = "never"; let pushTags = false; let smoke = true; @@ -279,6 +307,7 @@ function parseArgs(argv: ReadonlyArray): CheckpointOptions { if (arg === "--") continue; if (arg === "--dry-run") dryRun = true; else if (arg === "--no-fetch") fetch = false; + else if (arg === "--mirror-upstream-main") mirrorUpstreamMain = true; else if (arg === "--no-smoke") smoke = false; else if (arg === "--push-tags") pushTags = true; else if (arg === "--promote") promotion = "always"; @@ -295,7 +324,17 @@ function parseArgs(argv: ReadonlyArray): CheckpointOptions { } } - return { dryRun, fetch, promotion, pushTags, smoke, sourceRef, upstreamRemote, pushRemote }; + return { + dryRun, + fetch, + mirrorUpstreamMain, + promotion, + pushTags, + smoke, + sourceRef, + upstreamRemote, + pushRemote, + }; } export function checkpointMessage(input: { @@ -490,6 +529,31 @@ function promoteCheckpoint( console.log(`[lastcode:checkpoint] Promoted ${commit} to ${options.pushRemote}/lastcode/main.`); } +function mirrorUpstreamMain(repoRoot: string, options: CheckpointOptions): void { + const upstreamRef = `refs/remotes/${options.upstreamRemote}/main`; + const remoteRef = `refs/remotes/${options.pushRemote}/main`; + const upstreamCommit = git(repoRoot, ["rev-parse", `${upstreamRef}^{commit}`]); + const remoteCommit = git(repoRoot, ["rev-parse", `${remoteRef}^{commit}`]); + const pushArgs = resolveUpstreamMainMirror( + options.pushRemote, + remoteCommit, + upstreamCommit, + isAncestor(repoRoot, remoteCommit, upstreamCommit), + ); + if (!pushArgs) { + console.log(`[lastcode:checkpoint] ${options.pushRemote}/main already mirrors ${upstreamRef}.`); + return; + } + if (options.dryRun) { + console.log( + `[lastcode:checkpoint] Would fast-forward ${options.pushRemote}/main from ${remoteCommit} to ${upstreamCommit}.`, + ); + return; + } + run(repoRoot, "git", pushArgs); + console.log(`[lastcode:checkpoint] Mirrored ${upstreamRef} to ${options.pushRemote}/main.`); +} + function main(argv: ReadonlyArray): void { const options = parseArgs(argv); const hostPlatform = Effect.runSync(HostProcessPlatform); @@ -514,8 +578,17 @@ function main(argv: ReadonlyArray): void { options.pushRemote, `+refs/heads/lastcode/main:refs/remotes/${options.pushRemote}/lastcode/main`, ]); + if (options.mirrorUpstreamMain) { + run(repoRoot, "git", [ + "fetch", + options.pushRemote, + `+refs/heads/main:refs/remotes/${options.pushRemote}/main`, + ]); + } } + if (options.mirrorUpstreamMain) mirrorUpstreamMain(repoRoot, options); + if (options.pushTags && !options.dryRun) { pruneUnpublishedCheckpointTags(repoRoot, options.pushRemote); } diff --git a/scripts/lastcode-local-update.d.mts b/scripts/lastcode-local-update.d.mts index f0d8dd3bc641..5b6257356c56 100644 --- a/scripts/lastcode-local-update.d.mts +++ b/scripts/lastcode-local-update.d.mts @@ -21,8 +21,32 @@ export interface ExistingBuildOptions { readonly outputRoot: string; readonly checkpointTag: string; readonly checkpointCommit: string; + readonly signingIdentity?: string; } +export interface LocalSigningConfiguration { + readonly schemaVersion: 1; + readonly identityHash: string; + readonly identityName: string; + readonly configuredAt?: string; +} + +export function resolveDeterministicBuildEnvironment( + environment?: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv; +export function readLocalSigningConfiguration(home: string): LocalSigningConfiguration | undefined; +export function resolveLocalBuildEnvironment( + worktreePath: string, + home: string, + environment?: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv; +export function isReusableCheckpointCiStamp( + stamp: unknown, + checkpointTag: string, + checkpointCommit: string, + upstreamCommit: string, +): boolean; + export function parseNightlyVersion(value: string): ParsedNightlyVersion | undefined; export function compareNightlyVersions(left: string, right: string): number; export function resolveLatestCheckpointTag(tags: ReadonlyArray): string | undefined; diff --git a/scripts/lastcode-local-update.mjs b/scripts/lastcode-local-update.mjs index d9ea3d869163..7431c90bda78 100644 --- a/scripts/lastcode-local-update.mjs +++ b/scripts/lastcode-local-update.mjs @@ -15,11 +15,29 @@ export function resolveDeterministicBuildEnvironment(environment = process.env) return { ...environment, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }; } -export function resolveLocalBuildEnvironment(worktreePath, environment = process.env) { +export function readLocalSigningConfiguration(home) { + const configPath = NodePath.join(home, ".lastcode", "local-signing.json"); + if (!NodeFS.existsSync(configPath)) return undefined; + const config = JSON.parse(NodeFS.readFileSync(configPath, "utf8")); + if ( + config?.schemaVersion !== 1 || + typeof config.identityHash !== "string" || + !/^[0-9A-F]{40}$/.test(config.identityHash) || + typeof config.identityName !== "string" || + config.identityName.length === 0 + ) { + throw new Error(`Invalid LastCode signing configuration at ${configPath}.`); + } + return config; +} + +export function resolveLocalBuildEnvironment(worktreePath, home, environment = process.env) { const resolved = resolveDeterministicBuildEnvironment(environment); + const signing = readLocalSigningConfiguration(home); return { ...resolved, PATH: `${NodePath.join(worktreePath, "node_modules", ".bin")}${NodePath.delimiter}${resolved.PATH ?? ""}`, + ...(signing ? { LASTCODE_LOCAL_MAC_SIGNING_IDENTITY: signing.identityHash } : {}), }; } @@ -129,7 +147,13 @@ export function parseOptions(argv) { return { command, repoRoot, home, currentVersion, checkpointTag }; } -export function resolveExistingBuild({ repoRoot, outputRoot, checkpointTag, checkpointCommit }) { +export function resolveExistingBuild({ + repoRoot, + outputRoot, + checkpointTag, + checkpointCommit, + signingIdentity, +}) { const nightlyTag = checkpointTag.slice(CHECKPOINT_PREFIX.length); const shortCommit = checkpointCommit.slice(0, 10); const outputDir = NodePath.join(outputRoot, nightlyTag, shortCommit); @@ -140,6 +164,7 @@ export function resolveExistingBuild({ repoRoot, outputRoot, checkpointTag, chec manifest.schemaVersion !== 1 || manifest.checkpointTag !== checkpointTag || manifest.lastCodeCommit !== checkpointCommit || + manifest.signingIdentity !== signingIdentity || typeof manifest.buildTag !== "string" || !manifest.buildTag.startsWith(checkpointTag.replace(CHECKPOINT_PREFIX, "lastcode/build/") + ".") ) { @@ -261,6 +286,7 @@ function build(options) { ]); const updateRoot = NodePath.join(options.home, ".lastcode", "local-updates"); const outputRoot = NodePath.join(updateRoot, "artifacts"); + const signingIdentity = readLocalSigningConfiguration(options.home)?.identityHash; let existing; let incompleteBuildError; try { @@ -269,6 +295,7 @@ function build(options) { outputRoot, checkpointTag: options.checkpointTag, checkpointCommit, + signingIdentity, }); } catch (error) { incompleteBuildError = error; @@ -297,7 +324,7 @@ function build(options) { } const worktreePath = NodePath.join(updateRoot, "build-worktree"); prepareBuildWorktree(options.repoRoot, worktreePath, options.checkpointTag, logFd); - const buildEnvironment = resolveLocalBuildEnvironment(worktreePath); + const buildEnvironment = resolveLocalBuildEnvironment(worktreePath, options.home); const installer = NodePath.join(options.repoRoot, "node_modules", ".bin", "vp"); if (!NodeFS.existsSync(installer)) { throw new Error(`Checkpoint automation dependencies are missing at ${installer}.`); @@ -368,6 +395,7 @@ function build(options) { outputRoot, checkpointTag: options.checkpointTag, checkpointCommit, + signingIdentity, }); if (!built) throw new Error(`Build completed without a usable artifact for ${options.checkpointTag}.`); diff --git a/scripts/lastcode-local-update.test.ts b/scripts/lastcode-local-update.test.ts index 48bfa5195c29..13ff5c9e0284 100644 --- a/scripts/lastcode-local-update.test.ts +++ b/scripts/lastcode-local-update.test.ts @@ -12,6 +12,7 @@ import { parseOptions, prepareBuildWorktree, quarantineIncompleteBuild, + readLocalSigningConfiguration, resolveDeterministicBuildEnvironment, resolveExistingBuild, resolveLatestCheckpointTag, @@ -26,11 +27,40 @@ describe("lastcode-local-update", () => { LC_ALL: "en_US.UTF-8", }); assert.match( - resolveLocalBuildEnvironment("/tmp/build tree", { PATH: "/bin" }).PATH, + resolveLocalBuildEnvironment("/tmp/build tree", "/tmp/unsigned-home", { PATH: "/bin" }) + .PATH ?? "", /^\/tmp\/build tree\/node_modules\/\.bin:/, ); }); + it("adds a configured persistent signing identity to local builds", () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-signing-")); + try { + const configDirectory = NodePath.join(home, ".lastcode"); + NodeFS.mkdirSync(configDirectory); + NodeFS.writeFileSync( + NodePath.join(configDirectory, "local-signing.json"), + JSON.stringify({ + schemaVersion: 1, + identityHash: "0123456789ABCDEF0123456789ABCDEF01234567", + identityName: "Apple Development: LastCode Test", + }), + ); + + assert.deepEqual(readLocalSigningConfiguration(home), { + schemaVersion: 1, + identityHash: "0123456789ABCDEF0123456789ABCDEF01234567", + identityName: "Apple Development: LastCode Test", + }); + assert.equal( + resolveLocalBuildEnvironment("/tmp/build", home, {}).LASTCODE_LOCAL_MAC_SIGNING_IDENTITY, + "0123456789ABCDEF0123456789ABCDEF01234567", + ); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } + }); + it("only reuses a full CI stamp for the exact checkpoint context", () => { const stamp = { schemaVersion: 2, @@ -144,6 +174,14 @@ describe("lastcode-local-update", () => { checkpointCommit: commit, }; assert.equal(resolveExistingBuild(buildOptions)?.outputDir, output); + assert.throws( + () => + resolveExistingBuild({ + ...buildOptions, + signingIdentity: "0123456789ABCDEF0123456789ABCDEF01234567", + }), + /does not match/, + ); NodeFS.unlinkSync(NodePath.join(output, "LastCode.zip")); assert.throws(() => resolveExistingBuild(buildOptions), /missing \.zip/); NodeFS.writeFileSync(NodePath.join(output, "LastCode.zip"), "zip"); diff --git a/scripts/lastcode-nightly-service.test.ts b/scripts/lastcode-nightly-service.test.ts index 568319d0849d..6f97d9832dc6 100644 --- a/scripts/lastcode-nightly-service.test.ts +++ b/scripts/lastcode-nightly-service.test.ts @@ -9,7 +9,7 @@ it("renders an hourly checkpoint-only launch agent with escaped durable paths", }); expect(plist).toContain("3600"); - expect(plist).toContain("--push-tags --promote-if-no-open-prs"); + expect(plist).toContain("--push-tags --promote-if-no-open-prs --mirror-upstream-main"); expect(plist).toContain("git checkout --detach --force refs/remotes/origin/lastcode/main"); expect(plist).toContain("./node_modules/.bin/vp install --frozen-lockfile"); expect(plist).not.toContain("lastcode-build"); diff --git a/scripts/lastcode-nightly-service.ts b/scripts/lastcode-nightly-service.ts index f364066a3f7f..c1debc7439d5 100644 --- a/scripts/lastcode-nightly-service.ts +++ b/scripts/lastcode-nightly-service.ts @@ -33,6 +33,7 @@ export function renderLaunchAgentPlist(input: { "mise exec node@24.13.1 -- node scripts/lastcode-checkpoint.ts", "--push-tags", "--promote-if-no-open-prs", + "--mirror-upstream-main", ].join(" "); return ` From 9749b22eea10406839a7674237ef5f63c1f78c09 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 14 Aug 2026 16:49:18 -0700 Subject: [PATCH 05/25] docs(lastcode): pass script flags explicitly --- docs/lastcode/README.md | 10 +++++----- docs/lastcode/local-nightly-updates.md | 4 ++-- docs/lastcode/nightly-workflow.md | 14 +++++++------- docs/lastcode/release.md | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/lastcode/README.md b/docs/lastcode/README.md index 5ca078deafaa..5623f0ae2452 100644 --- a/docs/lastcode/README.md +++ b/docs/lastcode/README.md @@ -28,22 +28,22 @@ directories. ```bash # Inspect what the checkpoint job would do. -pnpm lastcode:checkpoint --dry-run +pnpm run lastcode:checkpoint -- --dry-run # Checkpoint every missing nightly and push immutable tags. -pnpm lastcode:checkpoint --push-tags --promote-if-no-open-prs +pnpm run lastcode:checkpoint -- --push-tags --promote-if-no-open-prs # Enable the same operation at login and hourly. pnpm lastcode:checkpoint:service install # Install and inspect the checkpoint dashboard (eight rows by default). -pnpm lastcode:checkpoints --install +pnpm run lastcode:checkpoints -- --install lastcode-checkpoints lastcode-checkpoints -n 20 # Validate and build one explicit checkpoint. -pnpm lastcode:ci --checkpoint lastcode/checkpoint/ -pnpm lastcode:build:mac:arm64 --checkpoint lastcode/checkpoint/ +pnpm run lastcode:ci -- --checkpoint lastcode/checkpoint/ +pnpm run lastcode:build:mac:arm64 -- --checkpoint lastcode/checkpoint/ ``` None of the checkpoint commands builds an application. An opted-in packaged diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index 0bd05856ba83..d2fbb59f8406 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -17,8 +17,8 @@ Before enabling it, install the checkpoint service and dashboard: ```bash pnpm lastcode:checkpoint:service install -pnpm lastcode:checkpoints --install -pnpm lastcode:build --install +pnpm run lastcode:checkpoints -- --install +pnpm run lastcode:build -- --install ``` The dashboard installer records the dedicated automation worktree in diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index 441d64808e17..2d9de8e24e56 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -40,13 +40,13 @@ LastCode tags record the rebased downstream state and never move. Preview the operation: ```bash -pnpm lastcode:checkpoint --dry-run +pnpm run lastcode:checkpoint -- --dry-run ``` Run it and publish checkpoint tags: ```bash -pnpm lastcode:checkpoint \ +pnpm run lastcode:checkpoint -- \ --push-tags \ --promote-if-no-open-prs \ --mirror-upstream-main @@ -122,7 +122,7 @@ the local push gate or pushing an unchanged branch. The job executes: ```bash -pnpm lastcode:checkpoint \ +pnpm run lastcode:checkpoint -- \ --push-tags \ --promote-if-no-open-prs \ --mirror-upstream-main @@ -150,7 +150,7 @@ install the checkpoint dashboard as a user command: ```bash pnpm lastcode:checkpoint:service install -pnpm lastcode:checkpoints --install +pnpm run lastcode:checkpoints -- --install ``` The installer puts the executable at `~/.lastcode/bin/lastcode-checkpoints` @@ -223,7 +223,7 @@ For routine use, install the userland build command beside the checkpoint dashboard: ```bash -pnpm lastcode:build --install +pnpm run lastcode:build -- --install ``` The installer places the versioned command under `~/.lastcode/bin` and exposes @@ -250,8 +250,8 @@ checkpoint CI, then build that same tag: ```bash git switch --detach lastcode/checkpoint/v0.0.34-nightly.20260812.1072 -pnpm lastcode:ci --checkpoint lastcode/checkpoint/v0.0.34-nightly.20260812.1072 -pnpm lastcode:build:mac:arm64 \ +pnpm run lastcode:ci -- --checkpoint lastcode/checkpoint/v0.0.34-nightly.20260812.1072 +pnpm run lastcode:build:mac:arm64 -- \ --checkpoint lastcode/checkpoint/v0.0.34-nightly.20260812.1072 ``` diff --git a/docs/lastcode/release.md b/docs/lastcode/release.md index e6b51f67bf73..f4c57c562cdb 100644 --- a/docs/lastcode/release.md +++ b/docs/lastcode/release.md @@ -75,7 +75,7 @@ A release build uses a different full-CI context because rebasing intentionally rewrites ancestry. Check out the immutable checkpoint and run: ```bash -pnpm lastcode:ci --checkpoint lastcode/checkpoint/ +pnpm run lastcode:ci -- --checkpoint lastcode/checkpoint/ ``` The resulting stamp binds the exact LastCode commit, checkpoint tag, upstream From 4be9ac2d23d1e372e0e643cbb1e840400f0ff112 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 13:35:20 -0700 Subject: [PATCH 06/25] fix(web): keep desktop auth recovery active for an hour --- apps/web/src/authBootstrap.test.ts | 8 ++++---- apps/web/src/environments/primary/auth.ts | 22 ++++++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1aa486984d48..908468f3c630 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -269,7 +269,7 @@ describe("resolveInitialServerAuthGateState", () => { expect(attempts).toBe(4); }); - it("retries desktop session bootstrap after a blocking credential prompt", async () => { + it("keeps retrying desktop session bootstrap across delayed credential prompts", async () => { vi.useFakeTimers(); installDesktopBootstrap(); let attempts = 0; @@ -280,7 +280,7 @@ describe("resolveInitialServerAuthGateState", () => { ); const runner: PrimaryHttpEffectRunner = async () => { attempts += 1; - if (attempts === 1) { + if (attempts < 3) { await new Promise((resolve) => setTimeout(resolve, 20_000)); throw new HttpClientError.HttpClientError({ reason: new HttpClientError.StatusCodeError({ request, response }), @@ -293,10 +293,10 @@ describe("resolveInitialServerAuthGateState", () => { const { fetchSessionState } = await import("./environments/primary"); const sessionPromise = fetchSessionState(); - await vi.advanceTimersByTimeAsync(20_500); + await vi.advanceTimersByTimeAsync(41_000); await expect(sessionPromise).resolves.toEqual(unauthenticatedSession(DESKTOP_AUTH)); - expect(attempts).toBe(2); + expect(attempts).toBe(3); }); it("takes a pairing token from the location hash and strips it immediately", async () => { diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index bef0d77dbff8..285fe9a36ff1 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -187,6 +187,7 @@ function getDesktopBootstrapCredential(): string | null { } export async function fetchSessionState(): Promise { + const isDesktop = window.desktopBridge !== undefined; return retryTransientBootstrap( async () => { try { @@ -202,7 +203,10 @@ export async function fetchSessionState(): Promise { }); } }, - { retryInternalServerError: window.desktopBridge !== undefined }, + { + retryInternalServerError: isDesktop, + ...(isDesktop ? { timeoutMs: DESKTOP_BOOTSTRAP_RETRY_TIMEOUT_MS } : {}), + }, ); } @@ -281,14 +285,17 @@ async function waitForAuthenticatedSessionAfterBootstrap(): Promise( operation: () => Promise, - options: { readonly retryInternalServerError?: boolean } = {}, + options: { + readonly retryInternalServerError?: boolean; + readonly timeoutMs?: number; + } = {}, ): Promise { - const startedAt = Date.now(); - let retryCount = 0; + let retryStartedAt: number | null = null; while (true) { try { return await operation(); @@ -297,13 +304,12 @@ export async function retryTransientBootstrap( throw error; } - // A native credential prompt can block Electron's main process longer - // than this deadline. Always allow the first retry after control returns. - if (retryCount > 0 && Date.now() - startedAt >= BOOTSTRAP_RETRY_TIMEOUT_MS) { + const now = Date.now(); + retryStartedAt ??= now; + if (now - retryStartedAt >= (options.timeoutMs ?? BOOTSTRAP_RETRY_TIMEOUT_MS)) { throw error; } - retryCount += 1; await waitForBootstrapRetry(BOOTSTRAP_RETRY_STEP_MS); } } From 7aa8b04bb642a98a0a88d5533e2e60e55a0dad8d Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 13:36:07 -0700 Subject: [PATCH 07/25] fix(lastcode): keep local builds ad-hoc signed --- docs/lastcode/local-nightly-updates.md | 17 ++- docs/lastcode/nightly-workflow.md | 36 +----- scripts/build-desktop-artifact.test.ts | 19 --- scripts/build-desktop-artifact.ts | 8 +- scripts/lastcode-build-mac-arm64.ts | 4 - scripts/lastcode-build.mjs | 163 +------------------------ scripts/lastcode-build.test.mjs | 31 ----- scripts/lastcode-local-update.d.mts | 10 -- scripts/lastcode-local-update.mjs | 34 +----- scripts/lastcode-local-update.test.ts | 40 +----- 10 files changed, 16 insertions(+), 346 deletions(-) diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index d2fbb59f8406..f9ec171f9db1 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -54,16 +54,14 @@ number such as `1090` selects the unique checkpoint ending in `.1090`. app, and relaunches it. The DMG is retained as the inspectable, manually installable artifact. The -paired ZIP and `nightly-mac.yml` are generated by the same locally signed build -and are used only to reuse Electron's existing install machinery. Builds use -the persistent identity selected by `lastcode-build --setup-signing` when -configured, and otherwise fall back to ad-hoc signing. +paired ZIP and `nightly-mac.yml` are generated by the same ad-hoc-signed build +and are used only to reuse Electron's existing install machinery. Electron's macOS credential storage can synchronously block its main process -while the Keychain prompt is open. If that delays the first local authentication -request long enough to return an internal error, the desktop client performs a -fresh request after the prompt is resolved rather than leaving the startup -error screen visible. +while the Keychain prompt is open. If that delays local authentication requests, +the desktop client keeps retrying for one hour. You can leave the build or +install unattended, return to handle a prompt, and continue without rebuilding, +reinstalling, or relaunching LastCode. ## Failure handling and logs @@ -95,8 +93,7 @@ restoring hosted updater state, so a late poll cannot turn the feature back on. - packaged LastCode desktop builds on Apple Silicon macOS; - local `lastcode/checkpoint/*` tags already fetched by the checkpoint daemon; -- locally signed, non-notarized personal builds (persistent identity when - configured, ad-hoc otherwise); and +- ad-hoc-signed, non-notarized personal builds; and - one build at a time, initiated interactively from the sidebar. Public releases, notarization, x64 builds, remote build hosts, and automatic diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index 2d9de8e24e56..985ff776fbc0 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -273,41 +273,7 @@ release-lastcode/ `build-manifest.json` records the checkpoint tag, upstream tag and commit, LastCode commit, build tag, build time, platform, architecture, artifact sizes, -SHA-256 hashes, and the local signing identity when one was used. `SHA256SUMS` -provides a conventional verification file. - -### Stable local signing - -Ad-hoc signing works without an Apple account, but each rebuild has a different -code identity. macOS can therefore ask for Keychain access again after an -update. For stable personal builds, create a free **Apple Development** -certificate with Xcode's Personal Team, then configure the userland builder: - -```bash -lastcode-build --setup-signing -lastcode-build --signing-status -``` - -If the Mac has multiple eligible identities, choose the one printed by the -setup error: - -```bash -lastcode-build --setup-signing --signing-identity <40-character-hash> -``` - -The selected identity is recorded by hash and name in -`~/.lastcode/local-signing.json` with user-only permissions. It is not a -certificate or private key; those remain in the login Keychain. Later builds -pass the stable identity to Electron Builder. If no configuration exists, -LastCode deliberately retains the ad-hoc fallback. A previously completed -ad-hoc artifact is not reused after persistent signing is enabled; it is -quarantined and rebuilt so the requested checkpoint actually has the selected -identity. - -Creating an Apple Development certificate through a Personal Team does not -require the paid Apple Developer Program. It does require signing into Xcode -once, and Personal Team certificates and provisioning assets may expire and -need renewal. +and SHA-256 hashes. `SHA256SUMS` provides a conventional verification file. The build creates a local annotated `lastcode/build/...` tag. Pass `--push-tag` only when that build record should be published to the fork. diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index fec71303fd37..faf5480420a9 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1042,25 +1042,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); - it.effect("uses a configured persistent identity for local macOS builds", () => - Effect.gen(function* () { - const config = yield* createBuildConfig( - "mac", - "dmg", - "1.2.3", - false, - false, - undefined, - undefined, - "0123456789ABCDEF0123456789ABCDEF01234567", - ); - - const mac = config.mac as Record; - assert.equal(mac.identity, "0123456789ABCDEF0123456789ABCDEF01234567"); - assert.equal(mac.hardenedRuntime, false); - }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), - ); - it.effect("keeps executable resource editing enabled for unsigned Windows builds", () => Effect.gen(function* () { const config = yield* createBuildConfig( diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 77c9bce285dd..2d046d030173 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -759,7 +759,6 @@ interface ResolvedBuildOptions { readonly mockUpdates: boolean; readonly mockUpdateServerPort: number | undefined; readonly wslPrebuild: string | undefined; - readonly localMacSigningIdentity: string | undefined; } interface StagePackageJson { @@ -1235,7 +1234,6 @@ const BuildEnvConfig = Config.all({ // into the staged node-pty so the WSL backend ships a ready binary and never // compiles on the user's machine. wslPrebuild: Config.string("T3CODE_DESKTOP_WSL_PREBUILD").pipe(Config.option), - localMacSigningIdentity: Config.string("LASTCODE_LOCAL_MAC_SIGNING_IDENTITY").pipe(Config.option), }); const MockUpdateServerPortSchema = Schema.NumberFromString.check( @@ -1329,7 +1327,6 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const wslPrebuild = Option.getOrUndefined(input.wslPrebuild) ?? Option.getOrUndefined(env.wslPrebuild); - const localMacSigningIdentity = Option.getOrUndefined(env.localMacSigningIdentity); return { platform, @@ -1344,7 +1341,6 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( mockUpdates, mockUpdateServerPort, wslPrebuild, - localMacSigningIdentity, } satisfies ResolvedBuildOptions; }); @@ -2029,7 +2025,6 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( readonly provisioningProfilePath: string; } | undefined, - localMacSigningIdentity?: string, ) { const buildConfig: Record = { appId: LASTCODE_DESKTOP_DISTRIBUTION.appId, @@ -2069,7 +2064,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( category: "public.app-category.developer-tools", ...(!signed ? { - identity: localMacSigningIdentity ?? "-", + identity: "-", hardenedRuntime: false, } : {}), @@ -2927,7 +2922,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( provisioningProfilePath: macPasskeySigning.provisioningProfilePath, } : undefined, - options.localMacSigningIdentity, ), dependencies: stageDependencies, devDependencies: { diff --git a/scripts/lastcode-build-mac-arm64.ts b/scripts/lastcode-build-mac-arm64.ts index b35dcebb609b..276fd1e43a6b 100644 --- a/scripts/lastcode-build-mac-arm64.ts +++ b/scripts/lastcode-build-mac-arm64.ts @@ -35,7 +35,6 @@ interface BuildManifest { readonly checkpointTag: string; readonly lastCodeCommit: string; readonly platform: "mac"; - readonly signingIdentity?: string; readonly upstreamCommit: string; readonly upstreamTag: string; } @@ -209,9 +208,6 @@ function main(argv: ReadonlyArray): void { checkpointTag: options.checkpointTag, lastCodeCommit: commit, platform: "mac", - ...(env.LASTCODE_LOCAL_MAC_SIGNING_IDENTITY - ? { signingIdentity: env.LASTCODE_LOCAL_MAC_SIGNING_IDENTITY } - : {}), upstreamCommit, upstreamTag: nightlyTag, }; diff --git a/scripts/lastcode-build.mjs b/scripts/lastcode-build.mjs index 47338368244d..e949068b984f 100644 --- a/scripts/lastcode-build.mjs +++ b/scripts/lastcode-build.mjs @@ -10,7 +10,6 @@ import * as NodeUtil from "node:util"; const CHECKPOINT_PREFIX = "lastcode/checkpoint/"; const RESULT_PREFIX = "LASTCODE_LOCAL_UPDATE_RESULT="; const LOG_POLL_INTERVAL_MS = 400; -const SIGNING_CONFIG_FILE = "local-signing.json"; export const BUILD_PHASES = [ { marker: "Building lastcode/checkpoint/", start: 0, estimateMs: 10_000 }, @@ -226,37 +225,18 @@ export function parseOptions(argv) { let checkpoint; let install = false; let repoRoot; - let setupSigning = false; - let signingStatus = false; - let signingIdentity; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--") continue; if (arg === "--install") install = true; - else if (arg === "--setup-signing") setupSigning = true; - else if (arg === "--signing-status") signingStatus = true; - else if ( - arg === "-c" || - arg === "--checkpoint" || - arg === "--repo" || - arg === "--signing-identity" - ) { + else if (arg === "-c" || arg === "--checkpoint" || arg === "--repo") { const value = argv[index + 1]; if (!value) throw new Error(`Missing value for ${arg}.`); if (arg === "--repo") repoRoot = value; - else if (arg === "--signing-identity") signingIdentity = value; else checkpoint = value; index += 1; } else if (arg === "-h" || arg === "--help") { - return { - help: true, - checkpoint, - install, - repoRoot, - setupSigning, - signingStatus, - signingIdentity, - }; + return { help: true, checkpoint, install, repoRoot }; } else if (arg.startsWith("-")) { throw new Error(`Unknown argument '${arg}'.`); } else if (checkpoint) { @@ -265,134 +245,7 @@ export function parseOptions(argv) { checkpoint = arg; } } - if (signingIdentity && !setupSigning) { - throw new Error("--signing-identity requires --setup-signing."); - } - const specialActionCount = [install, setupSigning, signingStatus].filter(Boolean).length; - if (specialActionCount > 1 || (specialActionCount > 0 && checkpoint)) { - throw new Error("Install, signing setup/status, and checkpoint builds are separate actions."); - } - return { - help: false, - checkpoint, - install, - repoRoot, - setupSigning, - signingStatus, - signingIdentity, - }; -} - -export function parseCodeSigningIdentities(output) { - return splitLines(output).flatMap((line) => { - const match = /^\d+\)\s+([0-9A-F]{40})\s+"([^"]+)"$/.exec(line); - if (!match) return []; - return [{ hash: match[1], name: match[2] }]; - }); -} - -function isPersistentMacSigningIdentity(identity) { - return ["Apple Development:", "Mac Developer:", "Developer ID Application:"].some((prefix) => - identity.name.startsWith(prefix), - ); -} - -export function selectCodeSigningIdentity(identities, selector) { - const eligible = identities.filter(isPersistentMacSigningIdentity); - if (selector) { - const normalized = selector.toUpperCase(); - const matches = eligible.filter(({ hash, name }) => hash === normalized || name === selector); - if (matches.length === 1) return matches[0]; - if (matches.length === 0) { - throw new Error(`No usable macOS code-signing identity matched '${selector}'.`); - } - throw new Error(`Signing identity selector '${selector}' is ambiguous.`); - } - if (eligible.length === 1) return eligible[0]; - if (eligible.length === 0) { - throw new Error( - "No persistent macOS signing identity was found. In Xcode Settings → Accounts, select your free Personal Team, open Manage Certificates, and create an Apple Development certificate; then rerun lastcode-build --setup-signing.", - ); - } - throw new Error( - `Multiple usable signing identities were found. Rerun with --setup-signing --signing-identity HASH:\n${eligible.map(({ hash, name }) => ` ${hash} ${name}`).join("\n")}`, - ); -} - -function findCodeSigningIdentities() { - const result = NodeChildProcess.spawnSync( - "security", - ["find-identity", "-v", "-p", "codesigning"], - { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }, - ); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error(result.stderr.trim() || "Could not inspect macOS code-signing identities."); - } - return parseCodeSigningIdentities(result.stdout); -} - -function signingConfigPath(home) { - return NodePath.join(home, ".lastcode", SIGNING_CONFIG_FILE); -} - -export function readSigningConfiguration(home) { - const path = signingConfigPath(home); - if (!NodeFS.existsSync(path)) return undefined; - const config = JSON.parse(NodeFS.readFileSync(path, "utf8")); - if ( - config?.schemaVersion !== 1 || - typeof config.identityHash !== "string" || - !/^[0-9A-F]{40}$/.test(config.identityHash) || - typeof config.identityName !== "string" - ) { - throw new Error(`Invalid LastCode signing configuration at ${path}.`); - } - return config; -} - -function setupSigning(home, selector) { - const identity = selectCodeSigningIdentity(findCodeSigningIdentities(), selector); - const path = signingConfigPath(home); - NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); - NodeFS.writeFileSync( - path, - `${JSON.stringify( - { - schemaVersion: 1, - identityHash: identity.hash, - identityName: identity.name, - configuredAt: new Date().toISOString(), - }, - undefined, - 2, - )}\n`, - { encoding: "utf8", mode: 0o600 }, - ); - NodeFS.chmodSync(path, 0o600); - console.log(style(ansi.green, "Persistent local signing enabled")); - console.log(`${identity.name}\n${identity.hash}`); - console.log(`Configuration: ${path}`); -} - -function showSigningStatus(home) { - const config = readSigningConfiguration(home); - if (!config) { - console.log("Persistent local signing is not configured; builds use ad-hoc signing."); - console.log( - "Run lastcode-build --setup-signing after creating an Apple Development certificate in Xcode.", - ); - return; - } - const installed = findCodeSigningIdentities().some(({ hash }) => hash === config.identityHash); - console.log( - `Persistent local signing: ${installed ? "ready" : "configured identity is unavailable"}`, - ); - console.log(`${config.identityName}\n${config.identityHash}`); - console.log(`Configuration: ${signingConfigPath(home)}`); + return { help: false, checkpoint, install, repoRoot }; } export function resolveCheckpointTag(tags, selector) { @@ -580,22 +433,12 @@ async function main(argv) { if (options.help) { console.log("Usage: lastcode-build [CHECKPOINT]"); console.log(" lastcode-build --checkpoint CHECKPOINT"); - console.log(" lastcode-build --setup-signing [--signing-identity HASH]"); - console.log(" lastcode-build --signing-status"); console.log(""); console.log("CHECKPOINT may be 1090, a full nightly tag, or a lastcode/checkpoint tag."); console.log("Without CHECKPOINT, the newest local checkpoint is built."); return; } const home = NodeOS.homedir(); - if (options.setupSigning) { - setupSigning(home, options.signingIdentity); - return; - } - if (options.signingStatus) { - showSigningStatus(home); - return; - } const repoRoot = resolveConfiguredRepo(home, options.repoRoot); if (options.install) { installCommand(repoRoot, home); diff --git a/scripts/lastcode-build.test.mjs b/scripts/lastcode-build.test.mjs index 816d5f901afb..afc5d26d87af 100644 --- a/scripts/lastcode-build.test.mjs +++ b/scripts/lastcode-build.test.mjs @@ -4,9 +4,7 @@ import { BUILD_PHASES, estimateBuildProgress, parseBuildResult, - parseCodeSigningIdentities, parseOptions, - selectCodeSigningIdentity, renderProgressBar, renderLauncher, resolveBuildPhaseIndex, @@ -28,35 +26,6 @@ describe("LastCode userland build command", () => { expect(() => parseOptions(["1090", "1092"])).toThrow("Unexpected second checkpoint"); }); - it("parses signing setup as a separate userland action", () => { - expect( - parseOptions([ - "--setup-signing", - "--signing-identity", - "0123456789ABCDEF0123456789ABCDEF01234567", - ]), - ).toMatchObject({ - setupSigning: true, - signingIdentity: "0123456789ABCDEF0123456789ABCDEF01234567", - }); - expect(parseOptions(["--signing-status"]).signingStatus).toBe(true); - expect(() => parseOptions(["--setup-signing", "1090"])).toThrow("separate actions"); - }); - - it("selects a persistent Apple Development identity", () => { - const identities = parseCodeSigningIdentities(` - 1) 0123456789ABCDEF0123456789ABCDEF01234567 "Apple Development: LastCode Test (TEAM123456)" - 2) FEDCBA9876543210FEDCBA9876543210FEDCBA98 "Unrelated Certificate" - 2 valid identities found - `); - - expect(selectCodeSigningIdentity(identities)).toEqual({ - hash: "0123456789ABCDEF0123456789ABCDEF01234567", - name: "Apple Development: LastCode Test (TEAM123456)", - }); - expect(() => selectCodeSigningIdentity([])).toThrow("Xcode Settings"); - }); - it("selects the newest checkpoint by default", () => { expect(resolveCheckpointTag(tags)).toBe("lastcode/checkpoint/v0.0.34-nightly.20260814.1095"); }); diff --git a/scripts/lastcode-local-update.d.mts b/scripts/lastcode-local-update.d.mts index 5b6257356c56..39c12b34c28e 100644 --- a/scripts/lastcode-local-update.d.mts +++ b/scripts/lastcode-local-update.d.mts @@ -21,23 +21,13 @@ export interface ExistingBuildOptions { readonly outputRoot: string; readonly checkpointTag: string; readonly checkpointCommit: string; - readonly signingIdentity?: string; -} - -export interface LocalSigningConfiguration { - readonly schemaVersion: 1; - readonly identityHash: string; - readonly identityName: string; - readonly configuredAt?: string; } export function resolveDeterministicBuildEnvironment( environment?: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv; -export function readLocalSigningConfiguration(home: string): LocalSigningConfiguration | undefined; export function resolveLocalBuildEnvironment( worktreePath: string, - home: string, environment?: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv; export function isReusableCheckpointCiStamp( diff --git a/scripts/lastcode-local-update.mjs b/scripts/lastcode-local-update.mjs index 7431c90bda78..d9ea3d869163 100644 --- a/scripts/lastcode-local-update.mjs +++ b/scripts/lastcode-local-update.mjs @@ -15,29 +15,11 @@ export function resolveDeterministicBuildEnvironment(environment = process.env) return { ...environment, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }; } -export function readLocalSigningConfiguration(home) { - const configPath = NodePath.join(home, ".lastcode", "local-signing.json"); - if (!NodeFS.existsSync(configPath)) return undefined; - const config = JSON.parse(NodeFS.readFileSync(configPath, "utf8")); - if ( - config?.schemaVersion !== 1 || - typeof config.identityHash !== "string" || - !/^[0-9A-F]{40}$/.test(config.identityHash) || - typeof config.identityName !== "string" || - config.identityName.length === 0 - ) { - throw new Error(`Invalid LastCode signing configuration at ${configPath}.`); - } - return config; -} - -export function resolveLocalBuildEnvironment(worktreePath, home, environment = process.env) { +export function resolveLocalBuildEnvironment(worktreePath, environment = process.env) { const resolved = resolveDeterministicBuildEnvironment(environment); - const signing = readLocalSigningConfiguration(home); return { ...resolved, PATH: `${NodePath.join(worktreePath, "node_modules", ".bin")}${NodePath.delimiter}${resolved.PATH ?? ""}`, - ...(signing ? { LASTCODE_LOCAL_MAC_SIGNING_IDENTITY: signing.identityHash } : {}), }; } @@ -147,13 +129,7 @@ export function parseOptions(argv) { return { command, repoRoot, home, currentVersion, checkpointTag }; } -export function resolveExistingBuild({ - repoRoot, - outputRoot, - checkpointTag, - checkpointCommit, - signingIdentity, -}) { +export function resolveExistingBuild({ repoRoot, outputRoot, checkpointTag, checkpointCommit }) { const nightlyTag = checkpointTag.slice(CHECKPOINT_PREFIX.length); const shortCommit = checkpointCommit.slice(0, 10); const outputDir = NodePath.join(outputRoot, nightlyTag, shortCommit); @@ -164,7 +140,6 @@ export function resolveExistingBuild({ manifest.schemaVersion !== 1 || manifest.checkpointTag !== checkpointTag || manifest.lastCodeCommit !== checkpointCommit || - manifest.signingIdentity !== signingIdentity || typeof manifest.buildTag !== "string" || !manifest.buildTag.startsWith(checkpointTag.replace(CHECKPOINT_PREFIX, "lastcode/build/") + ".") ) { @@ -286,7 +261,6 @@ function build(options) { ]); const updateRoot = NodePath.join(options.home, ".lastcode", "local-updates"); const outputRoot = NodePath.join(updateRoot, "artifacts"); - const signingIdentity = readLocalSigningConfiguration(options.home)?.identityHash; let existing; let incompleteBuildError; try { @@ -295,7 +269,6 @@ function build(options) { outputRoot, checkpointTag: options.checkpointTag, checkpointCommit, - signingIdentity, }); } catch (error) { incompleteBuildError = error; @@ -324,7 +297,7 @@ function build(options) { } const worktreePath = NodePath.join(updateRoot, "build-worktree"); prepareBuildWorktree(options.repoRoot, worktreePath, options.checkpointTag, logFd); - const buildEnvironment = resolveLocalBuildEnvironment(worktreePath, options.home); + const buildEnvironment = resolveLocalBuildEnvironment(worktreePath); const installer = NodePath.join(options.repoRoot, "node_modules", ".bin", "vp"); if (!NodeFS.existsSync(installer)) { throw new Error(`Checkpoint automation dependencies are missing at ${installer}.`); @@ -395,7 +368,6 @@ function build(options) { outputRoot, checkpointTag: options.checkpointTag, checkpointCommit, - signingIdentity, }); if (!built) throw new Error(`Build completed without a usable artifact for ${options.checkpointTag}.`); diff --git a/scripts/lastcode-local-update.test.ts b/scripts/lastcode-local-update.test.ts index 13ff5c9e0284..5e41c831581d 100644 --- a/scripts/lastcode-local-update.test.ts +++ b/scripts/lastcode-local-update.test.ts @@ -12,7 +12,6 @@ import { parseOptions, prepareBuildWorktree, quarantineIncompleteBuild, - readLocalSigningConfiguration, resolveDeterministicBuildEnvironment, resolveExistingBuild, resolveLatestCheckpointTag, @@ -27,40 +26,11 @@ describe("lastcode-local-update", () => { LC_ALL: "en_US.UTF-8", }); assert.match( - resolveLocalBuildEnvironment("/tmp/build tree", "/tmp/unsigned-home", { PATH: "/bin" }) - .PATH ?? "", + resolveLocalBuildEnvironment("/tmp/build tree", { PATH: "/bin" }).PATH ?? "", /^\/tmp\/build tree\/node_modules\/\.bin:/, ); }); - it("adds a configured persistent signing identity to local builds", () => { - const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-signing-")); - try { - const configDirectory = NodePath.join(home, ".lastcode"); - NodeFS.mkdirSync(configDirectory); - NodeFS.writeFileSync( - NodePath.join(configDirectory, "local-signing.json"), - JSON.stringify({ - schemaVersion: 1, - identityHash: "0123456789ABCDEF0123456789ABCDEF01234567", - identityName: "Apple Development: LastCode Test", - }), - ); - - assert.deepEqual(readLocalSigningConfiguration(home), { - schemaVersion: 1, - identityHash: "0123456789ABCDEF0123456789ABCDEF01234567", - identityName: "Apple Development: LastCode Test", - }); - assert.equal( - resolveLocalBuildEnvironment("/tmp/build", home, {}).LASTCODE_LOCAL_MAC_SIGNING_IDENTITY, - "0123456789ABCDEF0123456789ABCDEF01234567", - ); - } finally { - NodeFS.rmSync(home, { recursive: true, force: true }); - } - }); - it("only reuses a full CI stamp for the exact checkpoint context", () => { const stamp = { schemaVersion: 2, @@ -174,14 +144,6 @@ describe("lastcode-local-update", () => { checkpointCommit: commit, }; assert.equal(resolveExistingBuild(buildOptions)?.outputDir, output); - assert.throws( - () => - resolveExistingBuild({ - ...buildOptions, - signingIdentity: "0123456789ABCDEF0123456789ABCDEF01234567", - }), - /does not match/, - ); NodeFS.unlinkSync(NodePath.join(output, "LastCode.zip")); assert.throws(() => resolveExistingBuild(buildOptions), /missing \.zip/); NodeFS.writeFileSync(NodePath.join(output, "LastCode.zip"), "zip"); From 0b59855c0f770642581ad51dc8b7d84812a15650 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 13:49:51 -0700 Subject: [PATCH 08/25] fix(lastcode): clarify checkpoint recovery status --- docs/lastcode/README.md | 1 + docs/lastcode/nightly-workflow.md | 6 ++- scripts/lastcode-checkpoints.mjs | 56 ++++++++++++++++++++++----- scripts/lastcode-checkpoints.test.mjs | 27 +++++++++++++ 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/docs/lastcode/README.md b/docs/lastcode/README.md index 5623f0ae2452..c6dd6c66dc6a 100644 --- a/docs/lastcode/README.md +++ b/docs/lastcode/README.md @@ -40,6 +40,7 @@ pnpm lastcode:checkpoint:service install pnpm run lastcode:checkpoints -- --install lastcode-checkpoints lastcode-checkpoints -n 20 +lastcode-checkpoints --verbose # Validate and build one explicit checkpoint. pnpm run lastcode:ci -- --checkpoint lastcode/checkpoint/ diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index 985ff776fbc0..1c70a7c0b11c 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -166,13 +166,17 @@ Show the latest eight checkpoint activities, or choose another count: ```bash lastcode-checkpoints lastcode-checkpoints -n 20 +lastcode-checkpoints --verbose ``` The dashboard shows success or failure, upstream nightly, number of downstream commits replayed, finish time, duration, checkpoint commit, promotion to `lastcode/main`, and whether a local build tag exists. It also summarizes the launch agent and whether the local checkpoint set has caught up to the latest -known upstream tag. Failed rows include the retained recovery branch and error. +known upstream tag. A retained recovery worktree produces an `Action required` +message with the path and a command to inspect it. Full failure errors and +recovery branch names are shown only with `--verbose`; superseded failures are +not actionable after the same nightly succeeds. Successful checkpoint metadata is stored in the annotated checkpoint tag, so it travels with the Git repository. Failed and successful local attempts are diff --git a/scripts/lastcode-checkpoints.mjs b/scripts/lastcode-checkpoints.mjs index b7764550a3d2..db85a51dedd2 100644 --- a/scripts/lastcode-checkpoints.mjs +++ b/scripts/lastcode-checkpoints.mjs @@ -55,10 +55,12 @@ export function parseOptions(argv) { let count = DEFAULT_COUNT; let install = false; let repoRoot; + let verbose = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--") continue; if (arg === "--install") install = true; + else if (arg === "-v" || arg === "--verbose") verbose = true; else if (arg === "-n" || arg === "--count" || arg === "--repo") { const value = argv[index + 1]; if (!value) throw new Error(`Missing value for ${arg}.`); @@ -71,12 +73,12 @@ export function parseOptions(argv) { } index += 1; } else if (arg === "-h" || arg === "--help") { - return { help: true, count, install, repoRoot }; + return { help: true, count, install, repoRoot, verbose }; } else { throw new Error(`Unknown argument '${arg}'.`); } } - return { help: false, count, install, repoRoot }; + return { help: false, count, install, repoRoot, verbose }; } function parseNightly(tag) { @@ -162,6 +164,16 @@ export function failedRunsWithoutPublishedTags(publishedTags, records) { ); } +export function failureDetailLines(rows, verbose) { + if (!verbose) return []; + return rows + .filter((row) => row.status === "failed") + .map((failure) => { + const recovery = failure.recoveryBranch ? ` · Recovery: ${failure.recoveryBranch}` : ""; + return `Failure ${failure.upstreamTag}: ${failure.error ?? "unknown error"}${recovery}`; + }); +} + export function checkpointTagsWithoutUnpublishedFailures(tags, publishedTags, records) { const published = new Set(publishedTags); const latestRuns = latestRunsByUpstreamTag(records); @@ -229,10 +241,22 @@ export function selectAutomationWorktree(worktreeList) { return worktrees.find((path) => NodePath.basename(path) === "lastcode-automation"); } +export function selectNightlySyncWorktree(worktreeList) { + const worktrees = worktreeList + .split(/\r?\n/) + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length)); + return worktrees.find((path) => NodePath.basename(path) === "lastcode-nightly-sync"); +} + function findAutomationWorktree(repoRoot) { return selectAutomationWorktree(git(repoRoot, ["worktree", "list", "--porcelain"])); } +function findNightlySyncWorktree(repoRoot) { + return selectNightlySyncWorktree(git(repoRoot, ["worktree", "list", "--porcelain"])); +} + function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; } @@ -384,7 +408,7 @@ function daemonSummary() { return `Daemon: ${label} · Last exit: ${exit}`; } -function printDashboard(repoRoot, home, count) { +function printDashboard(repoRoot, home, count, verbose) { const remoteState = remotePublicationState(repoRoot); const rows = checkpointRows(repoRoot, home, count, remoteState).slice(0, count); const columns = [ @@ -422,12 +446,26 @@ function printDashboard(repoRoot, home, count) { } const selectedFailures = rows.filter((row) => row.status === "failed"); - for (const failure of selectedFailures) { - const recovery = failure.recoveryBranch ? ` · Recovery: ${failure.recoveryBranch}` : ""; + for (const detail of failureDetailLines(rows, verbose)) { + console.log(style(ansi.error, detail)); + } + + const recoveryWorktree = findNightlySyncWorktree(repoRoot); + if (recoveryWorktree) { + const recoveryFailure = selectedFailures.find((failure) => failure.recoveryBranch); + const nightly = recoveryFailure ? ` for ${recoveryFailure.upstreamTag}` : ""; + console.log(""); + console.log( + style( + ansi.yellow, + `Action required: checkpoint recovery${nightly} is blocking the daemon at ${recoveryWorktree}.`, + ), + ); + console.log(style(ansi.lavender, `Start with: git -C ${shellQuote(recoveryWorktree)} status`)); console.log( style( - ansi.error, - `Failure ${failure.upstreamTag}: ${failure.error ?? "unknown error"}${recovery}`, + ansi.lavender, + `Resolve and stage the conflicts, then run: git -C ${shellQuote(recoveryWorktree)} rebase --continue`, ), ); } @@ -463,7 +501,7 @@ function printDashboard(repoRoot, home, count) { function main(argv) { const options = parseOptions(argv); if (options.help) { - console.log("Usage: lastcode-checkpoints [-n COUNT] [--repo PATH] [--install]"); + console.log("Usage: lastcode-checkpoints [-n COUNT] [--verbose] [--repo PATH] [--install]"); return; } const home = NodeOS.homedir(); @@ -472,7 +510,7 @@ function main(argv) { installCommand(repoRoot, home); return; } - printDashboard(repoRoot, home, options.count); + printDashboard(repoRoot, home, options.count, options.verbose); } if ( diff --git a/scripts/lastcode-checkpoints.test.mjs b/scripts/lastcode-checkpoints.test.mjs index 4291becd6de0..ec5e746ea2c5 100644 --- a/scripts/lastcode-checkpoints.test.mjs +++ b/scripts/lastcode-checkpoints.test.mjs @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { checkpointTagsWithoutUnpublishedFailures, checkpointFreshness, + failureDetailLines, failedRunsWithoutPublishedTags, formatDuration, parseOptions, @@ -11,12 +12,14 @@ import { renderLauncher, selectAutomationWorktree, selectCheckpointTags, + selectNightlySyncWorktree, } from "./lastcode-checkpoints.mjs"; describe("LastCode checkpoint dashboard", () => { it("shows eight entries by default and accepts a count override", () => { expect(parseOptions([]).count).toBe(8); expect(parseOptions(["-n", "12"]).count).toBe(12); + expect(parseOptions(["--verbose"]).verbose).toBe(true); expect(() => parseOptions(["-n", "0"])).toThrow("Invalid checkpoint count"); }); @@ -72,6 +75,15 @@ describe("LastCode checkpoint dashboard", () => { expect(selectAutomationWorktree("worktree /Users/lasto/projects/lastCode\n")).toBeUndefined(); }); + it("finds the retained nightly recovery worktree", () => { + expect( + selectNightlySyncWorktree( + "worktree /Users/lasto/projects/lastCode\n\nworktree /Users/lasto/projects/lastCode-worktrees/lastcode-nightly-sync\n", + ), + ).toBe("/Users/lasto/projects/lastCode-worktrees/lastcode-nightly-sync"); + expect(selectNightlySyncWorktree("worktree /Users/lasto/projects/lastCode\n")).toBeUndefined(); + }); + it("lets a published checkpoint tag reconcile an ambiguous failed push record", () => { const publishedTag = "lastcode/checkpoint/v0.0.1-nightly.20260812.2"; const failedRecord = { @@ -82,6 +94,21 @@ describe("LastCode checkpoint dashboard", () => { expect(failedRunsWithoutPublishedTags([], [failedRecord])).toEqual([failedRecord]); }); + it("shows full failure details only in verbose mode", () => { + const rows = [ + { + status: "failed", + upstreamTag: "v0.0.1-nightly.20260812.2", + error: "rebase failed", + recoveryBranch: "sync/nightly/v0.0.1-nightly.20260812.2", + }, + ]; + expect(failureDetailLines(rows, false)).toEqual([]); + expect(failureDetailLines(rows, true)).toEqual([ + "Failure v0.0.1-nightly.20260812.2: rebase failed · Recovery: sync/nightly/v0.0.1-nightly.20260812.2", + ]); + }); + it("keeps a tag with retained recovery state in the failed state", () => { const tag = "lastcode/checkpoint/v0.0.1-nightly.20260812.2"; const failedRecord = { From 964fb23bd98cc4b5d1c20751279523d6c4cfbba0 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 14:12:55 -0700 Subject: [PATCH 09/25] fix(lastcode): continue recorded checkpoint resolutions --- docs/lastcode/nightly-workflow.md | 4 +++ scripts/lastcode-checkpoint.test.ts | 13 ++++++++ scripts/lastcode-checkpoint.ts | 47 ++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index 1c70a7c0b11c..2e459c09939d 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -103,6 +103,10 @@ retains both: It also posts a macOS notification. Resolve the rebase or failure in that worktree, then decide whether to finish and tag it or abandon the sync attempt. The next automated run refuses to replace an existing recovery worktree. +After an operator resolves and completes a retained rebase, Git records those +choices through `rerere`. A later checkpoint run automatically continues when +Git reapplies and stages every remembered resolution; genuinely unmerged paths +still stop for review. No later nightly is checkpointed after a failure, because each failure should be understood before the sequence continues. diff --git a/scripts/lastcode-checkpoint.test.ts b/scripts/lastcode-checkpoint.test.ts index e8cc1e299f6b..21a686589d79 100644 --- a/scripts/lastcode-checkpoint.test.ts +++ b/scripts/lastcode-checkpoint.test.ts @@ -9,6 +9,7 @@ import { promotionNeeded, resolveCheckpointPlan, resolveUpstreamMainMirror, + shouldContinueRerereRebase, unpublishedCheckpointTags, upstreamMainMirrorPushArgs, worktreeAddArgs, @@ -33,6 +34,18 @@ it("uses Git's supported short option when creating the recovery branch", () => ]); }); +it("continues a rebase when rerere staged every remembered conflict", () => { + assert.equal(shouldContinueRerereRebase({ rebaseInProgress: true, unmergedPaths: [] }), true); + assert.equal( + shouldContinueRerereRebase({ + rebaseInProgress: true, + unmergedPaths: ["still-conflicted.ts"], + }), + false, + ); + assert.equal(shouldContinueRerereRebase({ rebaseInProgress: false, unmergedPaths: [] }), false); +}); + it("runs smoke checks with the isolated worktree's Vite+ binary", () => { assert.equal(worktreeVp("/tmp/sync"), "/tmp/sync/node_modules/.bin/vp"); }); diff --git a/scripts/lastcode-checkpoint.ts b/scripts/lastcode-checkpoint.ts index 165aac430674..6e01e23ee20c 100644 --- a/scripts/lastcode-checkpoint.ts +++ b/scripts/lastcode-checkpoint.ts @@ -87,6 +87,51 @@ function git( }); } +export function shouldContinueRerereRebase(input: { + readonly rebaseInProgress: boolean; + readonly unmergedPaths: ReadonlyArray; +}): boolean { + return input.rebaseInProgress && input.unmergedPaths.length === 0; +} + +function rebaseInProgress(worktree: string): boolean { + const gitDirectory = git(worktree, ["rev-parse", "--absolute-git-dir"], { cwd: worktree }); + return ["rebase-merge", "rebase-apply"].some((name) => + NodeFS.existsSync(NodePath.join(gitDirectory, name)), + ); +} + +function unmergedPaths(worktree: string): ReadonlyArray { + return splitLines(git(worktree, ["diff", "--name-only", "--diff-filter=U"], { cwd: worktree })); +} + +function rebaseOnto(worktree: string, upstreamTag: string, baseTag: string): void { + let failure: unknown; + try { + run(worktree, "git", ["rebase", "--onto", upstreamTag, baseTag]); + return; + } catch (error) { + failure = error; + } + + while ( + shouldContinueRerereRebase({ + rebaseInProgress: rebaseInProgress(worktree), + unmergedPaths: unmergedPaths(worktree), + }) + ) { + console.log("[lastcode:checkpoint] Continuing Git's recorded conflict resolution..."); + try { + run(worktree, "git", ["-c", "core.editor=true", "rebase", "--continue"]); + return; + } catch (error) { + failure = error; + } + } + + throw failure; +} + function splitLines(value: string): ReadonlyArray { return value .split(/\r?\n/) @@ -735,7 +780,7 @@ function main(argv: ReadonlyArray): void { startedAtMs: Date.now(), }; console.log(`[lastcode:checkpoint] Rebasing LastCode from ${baseTag} onto ${nightly.tag}...`); - run(worktree, "git", ["rebase", "--onto", nightly.tag, baseTag]); + rebaseOnto(worktree, nightly.tag, baseTag); candidateCommit = git(repoRoot, ["rev-parse", "HEAD"], { cwd: worktree }); if (options.smoke) runSmokeGate(repoRoot, worktree); const finishedAtMs = Date.now(); From 3f68f96daac6c9109714e756a4ffd53ce3b1e7e4 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 14:22:53 -0700 Subject: [PATCH 10/25] fix(lastcode): sanitize checkpoint smoke environment --- scripts/lastcode-checkpoint.test.ts | 8 +++++ scripts/lastcode-checkpoint.ts | 45 +++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/scripts/lastcode-checkpoint.test.ts b/scripts/lastcode-checkpoint.test.ts index 21a686589d79..1eb75c937f6f 100644 --- a/scripts/lastcode-checkpoint.test.ts +++ b/scripts/lastcode-checkpoint.test.ts @@ -3,6 +3,7 @@ import { assert, expect, it } from "@effect/vitest"; import { checkpointFailureDisposition, checkpointMessage, + checkpointSmokeEnvironment, checkpointSourceCommit, checkpointTagPushArgs, checkpointVpPaths, @@ -50,6 +51,13 @@ it("runs smoke checks with the isolated worktree's Vite+ binary", () => { assert.equal(worktreeVp("/tmp/sync"), "/tmp/sync/node_modules/.bin/vp"); }); +it("removes the Electron host mode from checkpoint smoke subprocesses", () => { + assert.deepStrictEqual( + checkpointSmokeEnvironment({ ELECTRON_RUN_AS_NODE: "1", KEEP_ME: "yes" }), + { KEEP_ME: "yes" }, + ); +}); + it("bootstraps dependencies with the invoking worktree runner before using the isolated runner", () => { assert.deepStrictEqual(checkpointVpPaths("/tmp/automation", "/tmp/sync"), { bootstrap: "/tmp/automation/node_modules/.bin/vp", diff --git a/scripts/lastcode-checkpoint.ts b/scripts/lastcode-checkpoint.ts index 6e01e23ee20c..14e926ae6fda 100644 --- a/scripts/lastcode-checkpoint.ts +++ b/scripts/lastcode-checkpoint.ts @@ -56,11 +56,16 @@ function run( cwd: string, command: string, args: ReadonlyArray, - options: { readonly capture?: boolean; readonly allowFailure?: boolean } = {}, + options: { + readonly capture?: boolean; + readonly allowFailure?: boolean; + readonly environment?: NodeJS.ProcessEnv; + } = {}, ): string { const result = NodeChildProcess.spawnSync(command, args, { cwd, encoding: "utf8", + ...(options.environment ? { env: options.environment } : {}), stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", }); if (result.error) throw result.error; @@ -76,6 +81,14 @@ function run( return options.capture ? result.stdout.trim() : ""; } +export function checkpointSmokeEnvironment( + environment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const resolved = { ...environment }; + delete resolved.ELECTRON_RUN_AS_NODE; + return resolved; +} + function git( repoRoot: string, args: ReadonlyArray, @@ -466,19 +479,27 @@ function assertForkInvariants(worktree: string): void { function runSmokeGate(repoRoot: string, worktree: string): void { const vp = checkpointVpPaths(repoRoot, worktree); + const environment = checkpointSmokeEnvironment(); console.log("[lastcode:checkpoint] Installing checkpoint worktree dependencies..."); - run(worktree, vp.bootstrap, ["install", "--frozen-lockfile"]); + run(worktree, vp.bootstrap, ["install", "--frozen-lockfile"], { environment }); assertForkInvariants(worktree); - run(worktree, vp.isolated, [ - "test", - "run", - "scripts/lastcode-nightly.test.ts", - "scripts/lastcode-checkpoint.test.ts", - "scripts/lastcode-local-ci.test.ts", - "scripts/build-desktop-artifact.test.ts", - "apps/desktop/src/electron/ElectronProtocol.test.ts", - ]); - run(worktree, vp.isolated, ["run", "--filter", "@t3tools/scripts", "typecheck"]); + run( + worktree, + vp.isolated, + [ + "test", + "run", + "scripts/lastcode-nightly.test.ts", + "scripts/lastcode-checkpoint.test.ts", + "scripts/lastcode-local-ci.test.ts", + "scripts/build-desktop-artifact.test.ts", + "apps/desktop/src/electron/ElectronProtocol.test.ts", + ], + { environment }, + ); + run(worktree, vp.isolated, ["run", "--filter", "@t3tools/scripts", "typecheck"], { + environment, + }); } function notify(platform: NodeJS.Platform, title: string, message: string): void { From 4019d6d87823e59ce80f424845a5e077456ffc57 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 15:22:56 -0700 Subject: [PATCH 11/25] feat(lastcode): install retained nightly builds --- docs/lastcode/local-nightly-updates.md | 8 + docs/lastcode/nightly-workflow.md | 15 ++ package.json | 1 + scripts/lastcode-install.mjs | 311 +++++++++++++++++++++++++ scripts/lastcode-install.test.mjs | 84 +++++++ 5 files changed, 419 insertions(+) create mode 100644 scripts/lastcode-install.mjs create mode 100644 scripts/lastcode-install.test.mjs diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index f9ec171f9db1..3f0b953adb38 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -19,6 +19,7 @@ Before enabling it, install the checkpoint service and dashboard: pnpm lastcode:checkpoint:service install pnpm run lastcode:checkpoints -- --install pnpm run lastcode:build -- --install +pnpm run lastcode:install -- --install ``` The dashboard installer records the dedicated automation worktree in @@ -30,6 +31,13 @@ The optional `lastcode-build [CHECKPOINT]` command exposes the same builder for manual bootstrap builds. It defaults to the newest checkpoint; a final nightly number such as `1090` selects the unique checkpoint ending in `.1090`. +The companion `lastcode-install` command uses `fzf` to choose a retained DMG, +with the most recently built image selected by default. It stages and validates +the replacement before quitting LastCode, then replaces +`/Applications/LastCode.app` and relaunches it. Passing a DMG path skips the +picker. This manual bootstrap path does not require the currently installed app +to have local nightly updates enabled. + ## User flow 1. The desktop checks the local repository at startup, every four minutes, and diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index 2e459c09939d..36e359d6681a 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -232,6 +232,7 @@ dashboard: ```bash pnpm run lastcode:build -- --install +pnpm run lastcode:install -- --install ``` The installer places the versioned command under `~/.lastcode/bin` and exposes @@ -253,6 +254,20 @@ local updater. During a build it shows the latest log line above a stage-weighte estimated progress bar; the complete output remains in `~/.lastcode/local-updates/build.log`. Completed builds are reused. +Use `lastcode-install` to install one of those retained DMGs. It presents every +DMG under `~/.lastcode/local-updates/artifacts` in an `fzf` picker, ordered with +the newest build selected. After selection it validates and mounts the image, +stages the app beside `/Applications/LastCode.app`, asks a running LastCode to +quit, safely replaces it, detaches the image, and relaunches LastCode. The old +app remains in place until the replacement has been completely copied and is +restored if the final swap fails. The normal path does not invoke `sudo` or +request a password. + +```bash +lastcode-install +lastcode-install ~/.lastcode/local-updates/artifacts/.../LastCode-...-arm64.dmg +``` + For lower-level or diagnostic use, check out the desired checkpoint, run full checkpoint CI, then build that same tag: diff --git a/package.json b/package.json index 71fc9d4345ec..6d2181611099 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "lastcode:checkpoint": "mise exec node@24.13.1 -- node scripts/lastcode-checkpoint.ts", "lastcode:checkpoints": "mise exec node@24.13.1 -- node scripts/lastcode-checkpoints.mjs", "lastcode:build": "mise exec node@24.13.1 -- node scripts/lastcode-build.mjs", + "lastcode:install": "mise exec node@24.13.1 -- node scripts/lastcode-install.mjs", "lastcode:checkpoint:service": "mise exec node@24.13.1 -- node scripts/lastcode-nightly-service.ts", "lastcode:build:mac:arm64": "mise exec node@24.13.1 -- node scripts/lastcode-build-mac-arm64.ts", "lastcode:ci": "mise exec node@24.13.1 -- node scripts/lastcode-local-ci.ts --full", diff --git a/scripts/lastcode-install.mjs b/scripts/lastcode-install.mjs new file mode 100644 index 000000000000..66a21f6e2398 --- /dev/null +++ b/scripts/lastcode-install.mjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node + +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"; + +const APP_BUNDLE_ID = "codes.lastobelus.lastcode"; +const DEFAULT_APP_PATH = "/Applications/LastCode.app"; + +function shellQuote(value) { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +export function renderLauncher(modulePath) { + return `#!/bin/sh\nexec mise exec node@24.13.1 -- node ${shellQuote(modulePath)} "$@"\n`; +} + +export function parseOptions(argv) { + let artifactsDirectory; + let dmgPath; + let install = false; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") continue; + if (arg === "--install") install = true; + else if (arg === "--artifacts") { + artifactsDirectory = argv[index + 1]; + if (!artifactsDirectory) throw new Error("Missing value for --artifacts."); + index += 1; + } else if (arg === "-h" || arg === "--help") { + return { artifactsDirectory, dmgPath, help: true, install }; + } else if (arg.startsWith("-")) { + throw new Error(`Unknown argument '${arg}'.`); + } else if (dmgPath) { + throw new Error(`Unexpected second DMG path '${arg}'.`); + } else { + dmgPath = arg; + } + } + return { artifactsDirectory, dmgPath, help: false, install }; +} + +function walkDmgs(directory, results) { + for (const entry of NodeFS.readdirSync(directory, { withFileTypes: true })) { + const path = NodePath.join(directory, entry.name); + if (entry.isDirectory()) walkDmgs(path, results); + else if (entry.isFile() && entry.name.toLowerCase().endsWith(".dmg")) { + const stat = NodeFS.statSync(path); + results.push({ modifiedAt: stat.mtime, path, size: stat.size }); + } + } +} + +export function discoverDmgs(artifactsDirectory) { + if (!NodeFS.existsSync(artifactsDirectory)) return []; + const results = []; + walkDmgs(artifactsDirectory, results); + return results.toSorted((left, right) => right.modifiedAt.getTime() - left.modifiedAt.getTime()); +} + +function formatSize(bytes) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function checkpointLabel(path) { + return /nightly\.\d{8}\.(\d+)/.exec(NodePath.basename(path))?.[1] ?? "—"; +} + +export function renderDmgChoices(dmgs, locale = undefined) { + const dateFormatter = new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }); + return dmgs.map((dmg) => { + if (dmg.path.includes("\t") || dmg.path.includes("\n")) { + throw new Error(`Unsupported control character in DMG path: ${dmg.path}`); + } + const display = [ + checkpointLabel(dmg.path).padStart(4), + dateFormatter.format(dmg.modifiedAt), + formatSize(dmg.size).padStart(8), + NodePath.basename(dmg.path), + ].join(" "); + return `${dmg.path}\t${display}`; + }); +} + +export function parseDmgChoice(value) { + const separator = value.indexOf("\t"); + if (separator <= 0) throw new Error("fzf returned an invalid LastCode DMG selection."); + return value.slice(0, separator); +} + +function selectDmg(dmgs) { + const fzf = process.env.LASTCODE_FZF_BIN ?? "fzf"; + const result = NodeChildProcess.spawnSync( + fzf, + [ + "--height=12", + "--border", + "--layout=reverse", + "--no-sort", + "--delimiter=\\t", + "--with-nth=2..", + "--prompt=Install LastCode > ", + "--header=Newest build selected · Enter installs · Esc cancels", + ], + { + encoding: "utf8", + input: `${renderDmgChoices(dmgs).join("\n")}\n`, + stdio: ["pipe", "pipe", "inherit"], + }, + ); + if (result.error?.code === "ENOENT") { + throw new Error("fzf is required. Install it with 'brew install fzf'."); + } + if (result.error) throw result.error; + if (result.status === 130 || result.status === 1) return undefined; + if (result.status !== 0) throw new Error(`fzf failed with exit code ${result.status}.`); + return parseDmgChoice(result.stdout.trimEnd()); +} + +function run(command, args, options = {}) { + const result = NodeChildProcess.spawnSync(command, args, { + encoding: "utf8", + stdio: options.inherit ? "inherit" : ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = options.inherit ? "" : result.stderr.trim() || result.stdout.trim(); + throw new Error(detail || `${command} failed with exit code ${result.status}.`); + } + return options.inherit ? "" : result.stdout.trim(); +} + +function bundleValue(appPath, key) { + return run("/usr/libexec/PlistBuddy", [ + "-c", + `Print:${key}`, + NodePath.join(appPath, "Contents", "Info.plist"), + ]); +} + +function validateApp(appPath) { + if (!NodeFS.statSync(appPath, { throwIfNoEntry: false })?.isDirectory()) { + throw new Error(`The DMG does not contain ${NodePath.basename(appPath)}.`); + } + const bundleIdentifier = bundleValue(appPath, "CFBundleIdentifier"); + if (bundleIdentifier !== APP_BUNDLE_ID) { + throw new Error(`Expected bundle ${APP_BUNDLE_ID}, found ${bundleIdentifier}.`); + } + run("codesign", ["--verify", "--deep", "--strict", appPath]); + return bundleValue(appPath, "CFBundleShortVersionString"); +} + +function appIsRunning() { + return run("osascript", ["-e", `application id "${APP_BUNDLE_ID}" is running`]) === "true"; +} + +async function quitApp() { + if (!appIsRunning()) return; + console.log("Quitting LastCode…"); + run("osascript", ["-e", `tell application id "${APP_BUNDLE_ID}" to quit`]); + const deadline = Date.now() + 30_000; + while (appIsRunning()) { + if (Date.now() >= deadline) { + throw new Error("LastCode did not quit within 30 seconds. Quit it manually and try again."); + } + await NodeTimersPromises.setTimeout(250); + } +} + +export function temporaryAppPaths(targetPath, processId = process.pid) { + const parent = NodePath.dirname(targetPath); + return { + backup: NodePath.join(parent, `.LastCode.previous-${processId}.app`), + staging: NodePath.join(parent, `.LastCode.install-${processId}.app`), + }; +} + +async function installDmg(dmgPath, targetPath = DEFAULT_APP_PATH) { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone installed script has no Effect runtime. + if (process.platform !== "darwin") throw new Error("lastcode-install only supports macOS."); + const resolvedDmg = NodePath.resolve(dmgPath); + if (!NodeFS.statSync(resolvedDmg, { throwIfNoEntry: false })?.isFile()) { + throw new Error(`DMG not found: ${resolvedDmg}`); + } + + const mountPoint = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-install-")); + const { backup, staging } = temporaryAppPaths(targetPath); + let attached = false; + let oldAppMoved = false; + try { + console.log(`Mounting ${NodePath.basename(resolvedDmg)}…`); + run("hdiutil", ["attach", "-nobrowse", "-readonly", "-mountpoint", mountPoint, resolvedDmg]); + attached = true; + const sourceApp = NodePath.join(mountPoint, "LastCode.app"); + const version = validateApp(sourceApp); + + NodeFS.rmSync(staging, { force: true, recursive: true }); + NodeFS.rmSync(backup, { force: true, recursive: true }); + console.log(`Preparing LastCode ${version}…`); + run("ditto", [sourceApp, staging]); + validateApp(staging); + await quitApp(); + + if (NodeFS.existsSync(targetPath)) { + NodeFS.renameSync(targetPath, backup); + oldAppMoved = true; + } + try { + NodeFS.renameSync(staging, targetPath); + run("open", [targetPath]); + } catch (error) { + NodeFS.rmSync(targetPath, { force: true, recursive: true }); + if (oldAppMoved) { + NodeFS.renameSync(backup, targetPath); + oldAppMoved = false; + } + throw error; + } + NodeFS.rmSync(backup, { force: true, recursive: true }); + oldAppMoved = false; + console.log(`Installed and launched LastCode ${version}`); + } finally { + NodeFS.rmSync(staging, { force: true, recursive: true }); + if (oldAppMoved && !NodeFS.existsSync(targetPath) && NodeFS.existsSync(backup)) { + NodeFS.renameSync(backup, targetPath); + } + if (attached) { + try { + run("hdiutil", ["detach", mountPoint]); + } catch (error) { + console.error(`Warning: could not detach ${mountPoint}: ${error.message}`); + } + } + NodeFS.rmSync(mountPoint, { force: true, recursive: true }); + } +} + +function replaceManagedSymlink(exposed, target) { + const existing = NodeFS.lstatSync(exposed, { throwIfNoEntry: false }); + if (existing) { + if (!existing.isSymbolicLink() || NodeFS.readlinkSync(exposed) !== target) { + throw new Error(`${exposed} already exists and is not managed by LastCode.`); + } + NodeFS.unlinkSync(exposed); + } + NodeFS.symlinkSync(target, exposed); +} + +function installCommand(home) { + const binDirectory = NodePath.join(home, ".lastcode", "bin"); + const moduleTarget = NodePath.join(binDirectory, "lastcode-install.mjs"); + const target = NodePath.join(binDirectory, "lastcode-install"); + const exposedDirectory = NodePath.join(home, ".local", "bin"); + const exposed = NodePath.join(exposedDirectory, "lastcode-install"); + NodeFS.mkdirSync(binDirectory, { recursive: true }); + NodeFS.mkdirSync(exposedDirectory, { recursive: true }); + NodeFS.copyFileSync(NodeURL.fileURLToPath(import.meta.url), moduleTarget); + NodeFS.writeFileSync(target, renderLauncher(moduleTarget), { encoding: "utf8", mode: 0o755 }); + NodeFS.chmodSync(target, 0o755); + replaceManagedSymlink(exposed, target); + console.log(`Installed ${target} with the pinned Node 24 runtime`); + console.log(`Exposed on PATH as ${exposed}`); +} + +async function main(argv) { + const options = parseOptions(argv); + if (options.help) { + console.log("Usage: lastcode-install [DMG]"); + console.log(" lastcode-install --artifacts PATH"); + console.log(""); + console.log("Without DMG, choose from ~/.lastcode/local-updates/artifacts using fzf."); + return; + } + const home = NodeOS.homedir(); + if (options.install) { + installCommand(home); + return; + } + const artifactsDirectory = NodePath.resolve( + options.artifactsDirectory ?? NodePath.join(home, ".lastcode", "local-updates", "artifacts"), + ); + const dmgs = discoverDmgs(artifactsDirectory); + if (!options.dmgPath && dmgs.length === 0) { + throw new Error(`No LastCode DMGs found under ${artifactsDirectory}.`); + } + const selected = options.dmgPath ? NodePath.resolve(options.dmgPath) : selectDmg(dmgs); + if (!selected) { + console.log("Installation cancelled."); + return; + } + await installDmg(selected); +} + +if ( + process.argv[1] && + NodeFS.realpathSync(process.argv[1]) === + NodeFS.realpathSync(NodeURL.fileURLToPath(import.meta.url)) +) { + try { + await main(process.argv.slice(2)); + } catch (error) { + console.error(`lastcode-install: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/lastcode-install.test.mjs b/scripts/lastcode-install.test.mjs new file mode 100644 index 000000000000..aa123ff913b0 --- /dev/null +++ b/scripts/lastcode-install.test.mjs @@ -0,0 +1,84 @@ +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 { + discoverDmgs, + parseDmgChoice, + parseOptions, + renderDmgChoices, + renderLauncher, + temporaryAppPaths, +} from "./lastcode-install.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-install-test-")); + temporaryDirectories.push(directory); + return directory; +} + +describe("LastCode userland install command", () => { + it("parses an optional DMG or artifacts directory", () => { + expect(parseOptions([])).toMatchObject({ dmgPath: undefined, install: false }); + expect(parseOptions(["/tmp/LastCode.dmg"]).dmgPath).toBe("/tmp/LastCode.dmg"); + expect(parseOptions(["--artifacts", "/tmp/builds"]).artifactsDirectory).toBe("/tmp/builds"); + expect(() => parseOptions(["one.dmg", "two.dmg"])).toThrow("Unexpected second DMG"); + }); + + it("discovers DMGs recursively with the newest first", () => { + const root = temporaryDirectory(); + const older = NodePath.join(root, "1095", "old.dmg"); + const newer = NodePath.join(root, "1104", "new.dmg"); + NodeFS.mkdirSync(NodePath.dirname(older), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(newer), { recursive: true }); + NodeFS.writeFileSync(older, "old"); + NodeFS.writeFileSync(newer, "new"); + NodeFS.utimesSync(older, new Date(1_000), new Date(1_000)); + NodeFS.utimesSync(newer, new Date(2_000), new Date(2_000)); + + expect(discoverDmgs(root).map((entry) => entry.path)).toEqual([newer, older]); + }); + + it("keeps the newest DMG first and round-trips its hidden path through fzf", () => { + const choices = renderDmgChoices( + [ + { + modifiedAt: new Date("2026-08-15T22:00:00Z"), + path: "/tmp/LastCode-0.0.34-nightly.20260815.1104-arm64.dmg", + size: 150 * 1024 * 1024, + }, + { + modifiedAt: new Date("2026-08-14T22:00:00Z"), + path: "/tmp/LastCode-0.0.34-nightly.20260814.1095-arm64.dmg", + size: 149 * 1024 * 1024, + }, + ], + "en-CA", + ); + expect(choices[0]).toContain("1104"); + expect(parseDmgChoice(choices[0])).toContain("20260815.1104"); + }); + + it("stages and backs up beside the application for safe renames", () => { + expect(temporaryAppPaths("/Applications/LastCode.app", 42)).toEqual({ + backup: "/Applications/.LastCode.previous-42.app", + staging: "/Applications/.LastCode.install-42.app", + }); + }); + + it("launches with the repository's pinned Node runtime", () => { + expect(renderLauncher("/tmp/Last Code/lastcode-install.mjs")).toContain( + "mise exec node@24.13.1 -- node '/tmp/Last Code/lastcode-install.mjs' \"$@\"", + ); + }); +}); From d6469a4c0e8ab07cbd108faf26b2dc8946682461 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 16:23:13 -0700 Subject: [PATCH 12/25] fix(lastcode): complete checkpoint recovery guidance --- docs/lastcode/nightly-workflow.md | 9 +++- scripts/lastcode-checkpoints.mjs | 72 ++++++++++++++++++++++++--- scripts/lastcode-checkpoints.test.mjs | 34 +++++++++++++ 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index 36e359d6681a..e720652ff21f 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -101,8 +101,13 @@ retains both: - the printed `lastcode-nightly-sync` worktree path. It also posts a macOS notification. Resolve the rebase or failure in that -worktree, then decide whether to finish and tag it or abandon the sync attempt. -The next automated run refuses to replace an existing recovery worktree. +worktree, then release the retained worktree and generated recovery branch so +the daemon can retry. `lastcode-checkpoints` prints the exact commands for the +current state: it distinguishes an in-progress rebase, a rebase already +completed by the operator, and a smoke-gate failure that must first be fixed on +`lastcode/main`. The final printed command runs the daemon immediately. The next +automated run refuses to replace an existing recovery worktree until those +cleanup steps are complete. After an operator resolves and completes a retained rebase, Git records those choices through `rerere`. A later checkpoint run automatically continues when Git reapplies and stages every remembered resolution; genuinely unmerged paths diff --git a/scripts/lastcode-checkpoints.mjs b/scripts/lastcode-checkpoints.mjs index db85a51dedd2..efc8ac9fbea8 100644 --- a/scripts/lastcode-checkpoints.mjs +++ b/scripts/lastcode-checkpoints.mjs @@ -257,10 +257,60 @@ function findNightlySyncWorktree(repoRoot) { return selectNightlySyncWorktree(git(repoRoot, ["worktree", "list", "--porcelain"])); } +function rebaseInProgress(worktree) { + return ["rebase-merge", "rebase-apply"].some((stateDirectory) => { + const gitPath = git(worktree, ["rev-parse", "--git-path", stateDirectory], { + allowFailure: true, + }); + if (!gitPath) return false; + return NodeFS.existsSync( + NodePath.isAbsolute(gitPath) ? gitPath : NodePath.join(worktree, gitPath), + ); + }); +} + function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; } +export function recoveryActionLines({ + repoRoot, + worktree, + automationWorktree, + recoveryBranch, + isRebaseInProgress, + failedDuringRebase, +}) { + const lines = []; + if (isRebaseInProgress) { + lines.push( + `Resolve and stage conflicts, then repeat until the rebase finishes: git -C ${shellQuote(worktree)} rebase --continue`, + ); + } else if (failedDuringRebase) { + lines.push( + "The retained rebase is complete; release it so the daemon can replay the recorded resolution.", + ); + } else { + lines.push( + "No rebase is in progress. Fix the smoke failure on lastcode/main, then discard this retained attempt.", + ); + } + lines.push( + `Release the daemon: git -C ${shellQuote(repoRoot)} worktree remove ${shellQuote(worktree)}`, + ); + if (recoveryBranch) { + lines.push( + `Delete the generated recovery branch: git -C ${shellQuote(repoRoot)} branch -D ${shellQuote(recoveryBranch)}`, + ); + } + if (automationWorktree) { + lines.push( + `Retry now: pnpm --dir ${shellQuote(automationWorktree)} lastcode:checkpoint:service run-now`, + ); + } + return lines; +} + export function renderLauncher(modulePath) { return `#!/bin/sh\nexec mise exec node@24.13.1 -- node ${shellQuote(modulePath)} "$@"\n`; } @@ -410,7 +460,8 @@ function daemonSummary() { function printDashboard(repoRoot, home, count, verbose) { const remoteState = remotePublicationState(repoRoot); - const rows = checkpointRows(repoRoot, home, count, remoteState).slice(0, count); + const allRows = checkpointRows(repoRoot, home, count, remoteState); + const rows = allRows.slice(0, count); const columns = [ { key: "status", label: "STATUS", value: (row) => (row.status === "success" ? "✓" : "✗") }, { key: "upstreamTag", label: "UPSTREAM NIGHTLY", value: (row) => row.upstreamTag }, @@ -445,7 +496,7 @@ function printDashboard(repoRoot, home, count, verbose) { console.log(padded.join(" ").trimEnd()); } - const selectedFailures = rows.filter((row) => row.status === "failed"); + const selectedFailures = allRows.filter((row) => row.status === "failed"); for (const detail of failureDetailLines(rows, verbose)) { console.log(style(ansi.error, detail)); } @@ -462,12 +513,17 @@ function printDashboard(repoRoot, home, count, verbose) { ), ); console.log(style(ansi.lavender, `Start with: git -C ${shellQuote(recoveryWorktree)} status`)); - console.log( - style( - ansi.lavender, - `Resolve and stage the conflicts, then run: git -C ${shellQuote(recoveryWorktree)} rebase --continue`, - ), - ); + const automationWorktree = findAutomationWorktree(repoRoot); + for (const line of recoveryActionLines({ + repoRoot, + worktree: recoveryWorktree, + automationWorktree, + recoveryBranch: recoveryFailure?.recoveryBranch, + isRebaseInProgress: rebaseInProgress(recoveryWorktree), + failedDuringRebase: recoveryFailure?.error?.includes("git rebase") ?? false, + })) { + console.log(style(ansi.lavender, line)); + } } const upstreamTags = splitLines(git(repoRoot, ["tag", "--list", "v*-nightly.*"])).sort( diff --git a/scripts/lastcode-checkpoints.test.mjs b/scripts/lastcode-checkpoints.test.mjs index ec5e746ea2c5..0d0940f94bab 100644 --- a/scripts/lastcode-checkpoints.test.mjs +++ b/scripts/lastcode-checkpoints.test.mjs @@ -9,6 +9,7 @@ import { parseOptions, parseRemotePublicationState, parseTrailers, + recoveryActionLines, renderLauncher, selectAutomationWorktree, selectCheckpointTags, @@ -84,6 +85,39 @@ describe("LastCode checkpoint dashboard", () => { expect(selectNightlySyncWorktree("worktree /Users/lasto/projects/lastCode\n")).toBeUndefined(); }); + it("shows the complete recovery lifecycle for a retained rebase", () => { + expect( + recoveryActionLines({ + repoRoot: "/tmp/Last Code", + worktree: "/tmp/Last Code-worktrees/lastcode-nightly-sync", + automationWorktree: "/tmp/Last Code-worktrees/lastcode-automation", + recoveryBranch: "sync/nightly/v1", + isRebaseInProgress: true, + failedDuringRebase: true, + }), + ).toEqual([ + "Resolve and stage conflicts, then repeat until the rebase finishes: git -C '/tmp/Last Code-worktrees/lastcode-nightly-sync' rebase --continue", + "Release the daemon: git -C '/tmp/Last Code' worktree remove '/tmp/Last Code-worktrees/lastcode-nightly-sync'", + "Delete the generated recovery branch: git -C '/tmp/Last Code' branch -D 'sync/nightly/v1'", + "Retry now: pnpm --dir '/tmp/Last Code-worktrees/lastcode-automation' lastcode:checkpoint:service run-now", + ]); + }); + + it("distinguishes smoke-gate recovery from an interrupted rebase", () => { + expect( + recoveryActionLines({ + repoRoot: "/tmp/repo", + worktree: "/tmp/recovery", + automationWorktree: "/tmp/automation", + recoveryBranch: "sync/nightly/v1", + isRebaseInProgress: false, + failedDuringRebase: false, + })[0], + ).toBe( + "No rebase is in progress. Fix the smoke failure on lastcode/main, then discard this retained attempt.", + ); + }); + it("lets a published checkpoint tag reconcile an ambiguous failed push record", () => { const publishedTag = "lastcode/checkpoint/v0.0.1-nightly.20260812.2"; const failedRecord = { From 22686fa32055e97ce971b9e35fe638920f4864f5 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 16:34:59 -0700 Subject: [PATCH 13/25] fix(lastcode): serialize local nightly builds --- docs/lastcode/local-nightly-updates.md | 6 ++ docs/lastcode/nightly-workflow.md | 5 +- scripts/lastcode-local-update.d.mts | 6 ++ scripts/lastcode-local-update.mjs | 79 ++++++++++++++++++++++++-- scripts/lastcode-local-update.test.ts | 23 ++++++++ 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index 3f0b953adb38..42ab0fbd8aa1 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -30,6 +30,10 @@ cleans a human development worktree. The optional `lastcode-build [CHECKPOINT]` command exposes the same builder for manual bootstrap builds. It defaults to the newest checkpoint; a final nightly number such as `1090` selects the unique checkpoint ending in `.1090`. +Manual and in-app builds share one cross-process lock. If either path is already +building, the other exits with the owning process and start time instead of +mutating the shared build worktree. A lock left by a terminated process is +reclaimed automatically on the next attempt. The companion `lastcode-install` command uses `fzf` to choose a retained DMG, with the most recently built image selected by default. It stages and validates @@ -48,6 +52,8 @@ to have local nightly updates enabled. 3. The first click creates or reuses `~/.lastcode/local-updates/build-worktree`, installs its pinned dependencies, runs full checkpoint CI, and builds the checkpoint's DMG plus updater ZIP. + One build at a time owns this worktree, from checkout through final artifact + validation. The build generates updater metadata for `lastobelus/lastCode` by default; a fork can set `LASTCODE_GITHUB_REPOSITORY=owner/repo` in its configured environment to select its own metadata source. diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index e720652ff21f..0ee6e0d46d7d 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -257,7 +257,10 @@ checkpoint tag is also accepted. The command uses the same dedicated worktree, full local CI, immutable artifact directory, and DMG/ZIP builder as the in-app local updater. During a build it shows the latest log line above a stage-weighted estimated progress bar; the complete output remains in -`~/.lastcode/local-updates/build.log`. Completed builds are reused. +`~/.lastcode/local-updates/build.log`. Completed builds are reused. Manual and +in-app builds share a cross-process lock for the complete checkout, CI, +packaging, and artifact-validation sequence; an overlapping request exits +without touching the shared worktree. Use `lastcode-install` to install one of those retained DMGs. It presents every DMG under `~/.lastcode/local-updates/artifacts` in an `fzf` picker, ordered with diff --git a/scripts/lastcode-local-update.d.mts b/scripts/lastcode-local-update.d.mts index 39c12b34c28e..b4e98a198512 100644 --- a/scripts/lastcode-local-update.d.mts +++ b/scripts/lastcode-local-update.d.mts @@ -23,6 +23,11 @@ export interface ExistingBuildOptions { readonly checkpointCommit: string; } +export interface LocalBuildLockOptions { + readonly pid?: number; + readonly isAlive?: (pid: number) => boolean; +} + export function resolveDeterministicBuildEnvironment( environment?: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv; @@ -54,5 +59,6 @@ export function prepareBuildWorktree( checkpointTag: string, logFd: number | undefined, ): void; +export function acquireBuildLock(updateRoot: string, options?: LocalBuildLockOptions): () => void; export const RESULT_PREFIX: string; diff --git a/scripts/lastcode-local-update.mjs b/scripts/lastcode-local-update.mjs index d9ea3d869163..1625fbc25a6d 100644 --- a/scripts/lastcode-local-update.mjs +++ b/scripts/lastcode-local-update.mjs @@ -251,15 +251,73 @@ export function prepareBuildWorktree(repoRoot, worktreePath, checkpointTag, logF if (status) throw new Error(`Dedicated local-update worktree is not clean:\n${status}`); } -function build(options) { - if (!options.checkpointTag.startsWith(CHECKPOINT_PREFIX)) { - throw new Error(`Invalid checkpoint tag '${options.checkpointTag}'.`); +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; } +} + +export function acquireBuildLock(updateRoot, options = {}) { + const pid = options.pid ?? process.pid; + const isAlive = options.isAlive ?? processIsAlive; + const lockPath = NodePath.join(updateRoot, "build.lock"); + const ownerPath = NodePath.join(lockPath, "owner.json"); + const token = `${pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + NodeFS.mkdirSync(updateRoot, { recursive: true }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + NodeFS.mkdirSync(lockPath); + NodeFS.writeFileSync( + ownerPath, + `${JSON.stringify({ schemaVersion: 1, pid, token, startedAt: new Date().toISOString() })}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + let released = false; + return () => { + if (released) return; + released = true; + const owner = JSON.parse(NodeFS.readFileSync(ownerPath, "utf8")); + if (owner.token !== token) { + throw new Error("Refusing to release a local build lock now owned by another process."); + } + NodeFS.unlinkSync(ownerPath); + NodeFS.rmdirSync(lockPath); + }; + } catch (error) { + if (error?.code !== "EEXIST" || attempt > 0) throw error; + let owner; + try { + owner = JSON.parse(NodeFS.readFileSync(ownerPath, "utf8")); + } catch { + throw new Error(`Another LastCode build owns ${lockPath}.`); + } + if (Number.isSafeInteger(owner.pid) && isAlive(owner.pid)) { + throw new Error( + `Another LastCode build is already running (PID ${owner.pid}, started ${owner.startedAt ?? "at an unknown time"}).`, + { cause: error }, + ); + } + const stalePath = `${lockPath}.stale-${pid}-${Date.now()}`; + try { + NodeFS.renameSync(lockPath, stalePath); + } catch { + throw new Error(`Another LastCode build acquired ${lockPath}.`); + } + NodeFS.rmSync(stalePath, { recursive: true, force: true }); + } + } + throw new Error(`Could not acquire the LastCode build lock at ${lockPath}.`); +} + +function buildUnlocked(options, updateRoot) { const checkpointCommit = git(options.repoRoot, [ "rev-parse", `${options.checkpointTag}^{commit}`, ]); - const updateRoot = NodePath.join(options.home, ".lastcode", "local-updates"); const outputRoot = NodePath.join(updateRoot, "artifacts"); let existing; let incompleteBuildError; @@ -379,6 +437,19 @@ function build(options) { }; } +function build(options) { + if (!options.checkpointTag.startsWith(CHECKPOINT_PREFIX)) { + throw new Error(`Invalid checkpoint tag '${options.checkpointTag}'.`); + } + const updateRoot = NodePath.join(options.home, ".lastcode", "local-updates"); + const releaseLock = acquireBuildLock(updateRoot); + try { + return buildUnlocked(options, updateRoot); + } finally { + releaseLock(); + } +} + function main(argv) { const options = parseOptions(argv); const result = options.command === "inspect" ? inspect(options) : build(options); diff --git a/scripts/lastcode-local-update.test.ts b/scripts/lastcode-local-update.test.ts index 5e41c831581d..c74c5f364cac 100644 --- a/scripts/lastcode-local-update.test.ts +++ b/scripts/lastcode-local-update.test.ts @@ -6,6 +6,7 @@ import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import { + acquireBuildLock, compareNightlyVersions, isReusableCheckpointCiStamp, parseNightlyVersion, @@ -19,6 +20,28 @@ import { } from "./lastcode-local-update.mjs"; describe("lastcode-local-update", () => { + it("serializes manual and in-app builds and recovers a stale lock", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-build-lock-")); + try { + const release = acquireBuildLock(root); + assert.throws(() => acquireBuildLock(root), /already running/); + release(); + + const lockPath = NodePath.join(root, "build.lock"); + NodeFS.mkdirSync(lockPath); + NodeFS.writeFileSync( + NodePath.join(lockPath, "owner.json"), + `${JSON.stringify({ schemaVersion: 1, pid: 12345, startedAt: "earlier" })}\n`, + ); + const releaseRecovered = acquireBuildLock(root, { isAlive: () => false }); + assert.isTrue(NodeFS.existsSync(NodePath.join(lockPath, "owner.json"))); + releaseRecovered(); + assert.isFalse(NodeFS.existsSync(lockPath)); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } + }); + it("uses a deterministic locale for checkpoint validation and packaging", () => { assert.deepInclude(resolveDeterministicBuildEnvironment({ PATH: "/bin" }), { PATH: "/bin", From 8adc3c4184d723f8a995efc667e30a1a1dade8ac Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 16:48:13 -0700 Subject: [PATCH 14/25] fix(web): limit delayed credential retries --- apps/web/src/authBootstrap.test.ts | 27 ++++++++++++++++ apps/web/src/environments/primary/auth.ts | 38 ++++++++++++++++++----- docs/lastcode/local-nightly-updates.md | 9 +++--- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 908468f3c630..a2dedd0e8981 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -1,5 +1,6 @@ import { EnvironmentAuthInvalidError, + EnvironmentInternalError, type AuthBrowserSessionResult, type AuthCreatePairingCredentialInput, type AuthSessionState, @@ -299,6 +300,32 @@ describe("resolveInitialServerAuthGateState", () => { expect(attempts).toBe(3); }); + it("surfaces genuine desktop internal errors without an hour-long retry", async () => { + vi.useFakeTimers(); + installDesktopBootstrap(); + let attempts = 0; + const runner: PrimaryHttpEffectRunner = async () => { + attempts += 1; + await new Promise((resolve) => setTimeout(resolve, 20_000)); + throw new EnvironmentInternalError({ + code: "internal_error", + reason: "internal_error", + traceId: "trace-persistent-internal-error", + }); + }; + __setPrimaryHttpRunnerForTests(runner); + + const { fetchSessionState, PrimaryEnvironmentRequestError } = + await import("./environments/primary"); + + const sessionPromise = fetchSessionState(); + const rejection = expect(sessionPromise).rejects.toBeInstanceOf(PrimaryEnvironmentRequestError); + await vi.advanceTimersByTimeAsync(20_000); + + await rejection; + expect(attempts).toBe(1); + }); + it("takes a pairing token from the location hash and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); const { takePairingTokenFromUrl } = await import("./environments/primary"); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 285fe9a36ff1..f22fe3b70f03 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -204,7 +204,12 @@ export async function fetchSessionState(): Promise { } }, { - retryInternalServerError: isDesktop, + ...(isDesktop + ? { + retryError: (error: unknown, attemptElapsedMs: number) => + isDelayedDesktopCredentialProtocolError(error, attemptElapsedMs), + } + : {}), ...(isDesktop ? { timeoutMs: DESKTOP_BOOTSTRAP_RETRY_TIMEOUT_MS } : {}), }, ); @@ -286,21 +291,28 @@ async function waitForAuthenticatedSessionAfterBootstrap(): Promise( operation: () => Promise, options: { - readonly retryInternalServerError?: boolean; + readonly retryError?: (error: unknown, attemptElapsedMs: number) => boolean; readonly timeoutMs?: number; } = {}, ): Promise { let retryStartedAt: number | null = null; while (true) { + const attemptStartedAt = Date.now(); try { return await operation(); } catch (error) { - if (!isTransientBootstrapError(error, options.retryInternalServerError === true)) { + if ( + !isTransientBootstrapError( + error, + options.retryError?.(error, Date.now() - attemptStartedAt) ?? false, + ) + ) { throw error; } @@ -321,12 +333,22 @@ function waitForBootstrapRetry(delayMs: number): Promise { }); } -function isTransientBootstrapError(error: unknown, retryInternalServerError: boolean): boolean { +function isDelayedDesktopCredentialProtocolError( + error: unknown, + attemptElapsedMs: number, +): boolean { + return ( + attemptElapsedMs >= DESKTOP_CREDENTIAL_DELAY_THRESHOLD_MS && + isPrimaryEnvironmentRequestError(error) && + error.status === 500 && + HttpClientError.isHttpClientError(error.cause) && + error.cause.response?.status === 500 + ); +} + +function isTransientBootstrapError(error: unknown, retryError: boolean): boolean { if (isPrimaryEnvironmentRequestError(error)) { - return ( - TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status) || - (retryInternalServerError && error.status === 500) - ); + return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status) || retryError; } if (error instanceof TypeError) { diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index 42ab0fbd8aa1..6968b519a8b2 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -72,10 +72,11 @@ paired ZIP and `nightly-mac.yml` are generated by the same ad-hoc-signed build and are used only to reuse Electron's existing install machinery. Electron's macOS credential storage can synchronously block its main process -while the Keychain prompt is open. If that delays local authentication requests, -the desktop client keeps retrying for one hour. You can leave the build or -install unattended, return to handle a prompt, and continue without rebuilding, -reinstalling, or relaunching LastCode. +while the Keychain prompt is open. If a long-running request returns Electron's +generic protocol-level HTTP 500 after that delay, the desktop client keeps +retrying for one hour. Structured server HTTP 500 responses still surface +immediately. You can leave the build or install unattended, return to handle a +prompt, and continue without rebuilding, reinstalling, or relaunching LastCode. ## Failure handling and logs From 6cfe4df8055e9ff9cc1b24e2bc3331cb6e84bf72 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 17:01:54 -0700 Subject: [PATCH 15/25] fix(lastcode): bound recovery retries --- apps/web/src/authBootstrap.test.ts | 28 +++++++++++++++++++++++ apps/web/src/environments/primary/auth.ts | 17 +++++++------- docs/lastcode/local-nightly-updates.md | 6 +++-- docs/lastcode/nightly-workflow.md | 5 +++- scripts/lastcode-checkpoint.test.ts | 6 +++++ scripts/lastcode-checkpoint.ts | 20 ++++++++++++++++ 6 files changed, 71 insertions(+), 11 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index a2dedd0e8981..dcf657a3820f 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -326,6 +326,34 @@ describe("resolveInitialServerAuthGateState", () => { expect(attempts).toBe(1); }); + it("keeps ordinary desktop gateway retries on the short bootstrap deadline", async () => { + vi.useFakeTimers(); + installDesktopBootstrap(); + let attempts = 0; + const request = HttpClientRequest.get("http://localhost/api/auth/session"); + const response = HttpClientResponse.fromWeb( + request, + new Response("Bad Gateway", { status: 502 }), + ); + const runner: PrimaryHttpEffectRunner = async () => { + attempts += 1; + throw new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); + }; + __setPrimaryHttpRunnerForTests(runner); + + const { fetchSessionState, PrimaryEnvironmentRequestError } = + await import("./environments/primary"); + + const sessionPromise = fetchSessionState(); + const rejection = expect(sessionPromise).rejects.toBeInstanceOf(PrimaryEnvironmentRequestError); + await vi.advanceTimersByTimeAsync(15_000); + + await rejection; + expect(attempts).toBe(31); + }); + it("takes a pairing token from the location hash and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); const { takePairingTokenFromUrl } = await import("./environments/primary"); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f22fe3b70f03..47bddba0e959 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -208,9 +208,9 @@ export async function fetchSessionState(): Promise { ? { retryError: (error: unknown, attemptElapsedMs: number) => isDelayedDesktopCredentialProtocolError(error, attemptElapsedMs), + retryErrorTimeoutMs: DESKTOP_BOOTSTRAP_RETRY_TIMEOUT_MS, } : {}), - ...(isDesktop ? { timeoutMs: DESKTOP_BOOTSTRAP_RETRY_TIMEOUT_MS } : {}), }, ); } @@ -298,6 +298,7 @@ export async function retryTransientBootstrap( operation: () => Promise, options: { readonly retryError?: (error: unknown, attemptElapsedMs: number) => boolean; + readonly retryErrorTimeoutMs?: number; readonly timeoutMs?: number; } = {}, ): Promise { @@ -307,18 +308,18 @@ export async function retryTransientBootstrap( try { return await operation(); } catch (error) { - if ( - !isTransientBootstrapError( - error, - options.retryError?.(error, Date.now() - attemptStartedAt) ?? false, - ) - ) { + const matchesAdditionalRetry = + options.retryError?.(error, Date.now() - attemptStartedAt) ?? false; + if (!isTransientBootstrapError(error, matchesAdditionalRetry)) { throw error; } const now = Date.now(); retryStartedAt ??= now; - if (now - retryStartedAt >= (options.timeoutMs ?? BOOTSTRAP_RETRY_TIMEOUT_MS)) { + const timeoutMs = matchesAdditionalRetry + ? (options.retryErrorTimeoutMs ?? options.timeoutMs ?? BOOTSTRAP_RETRY_TIMEOUT_MS) + : (options.timeoutMs ?? BOOTSTRAP_RETRY_TIMEOUT_MS); + if (now - retryStartedAt >= timeoutMs) { throw error; } diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index 6968b519a8b2..1e01f5829bd9 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -75,8 +75,10 @@ Electron's macOS credential storage can synchronously block its main process while the Keychain prompt is open. If a long-running request returns Electron's generic protocol-level HTTP 500 after that delay, the desktop client keeps retrying for one hour. Structured server HTTP 500 responses still surface -immediately. You can leave the build or install unattended, return to handle a -prompt, and continue without rebuilding, reinstalling, or relaunching LastCode. +immediately, and ordinary 502/503/504 gateway failures retain the normal +15-second retry deadline. You can leave the build or install unattended, return +to handle a prompt, and continue without rebuilding, reinstalling, or relaunching +LastCode. ## Failure handling and logs diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index 0ee6e0d46d7d..e40c68c93df1 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -111,7 +111,10 @@ cleanup steps are complete. After an operator resolves and completes a retained rebase, Git records those choices through `rerere`. A later checkpoint run automatically continues when Git reapplies and stages every remembered resolution; genuinely unmerged paths -still stop for review. +still stop for review. Automatic continuation also stops if Git rejects +`rebase --continue` without advancing the rebase (for example, because a hook +or signing key fails), preserving the recovery worktree for operator action +instead of retrying in a loop. No later nightly is checkpointed after a failure, because each failure should be understood before the sequence continues. diff --git a/scripts/lastcode-checkpoint.test.ts b/scripts/lastcode-checkpoint.test.ts index 1eb75c937f6f..a643035776b0 100644 --- a/scripts/lastcode-checkpoint.test.ts +++ b/scripts/lastcode-checkpoint.test.ts @@ -8,6 +8,7 @@ import { checkpointTagPushArgs, checkpointVpPaths, promotionNeeded, + rerereRebaseMadeProgress, resolveCheckpointPlan, resolveUpstreamMainMirror, shouldContinueRerereRebase, @@ -47,6 +48,11 @@ it("continues a rebase when rerere staged every remembered conflict", () => { assert.equal(shouldContinueRerereRebase({ rebaseInProgress: false, unmergedPaths: [] }), false); }); +it("stops automatic rebase continuation when Git makes no progress", () => { + assert.equal(rerereRebaseMadeProgress("head-a\0step:1", "head-b\0step:2"), true); + assert.equal(rerereRebaseMadeProgress("head-a\0step:1", "head-a\0step:1"), false); +}); + it("runs smoke checks with the isolated worktree's Vite+ binary", () => { assert.equal(worktreeVp("/tmp/sync"), "/tmp/sync/node_modules/.bin/vp"); }); diff --git a/scripts/lastcode-checkpoint.ts b/scripts/lastcode-checkpoint.ts index 14e926ae6fda..16cdceddde49 100644 --- a/scripts/lastcode-checkpoint.ts +++ b/scripts/lastcode-checkpoint.ts @@ -107,6 +107,10 @@ export function shouldContinueRerereRebase(input: { return input.rebaseInProgress && input.unmergedPaths.length === 0; } +export function rerereRebaseMadeProgress(previous: string, current: string): boolean { + return previous !== current; +} + function rebaseInProgress(worktree: string): boolean { const gitDirectory = git(worktree, ["rev-parse", "--absolute-git-dir"], { cwd: worktree }); return ["rebase-merge", "rebase-apply"].some((name) => @@ -118,6 +122,20 @@ function unmergedPaths(worktree: string): ReadonlyArray { return splitLines(git(worktree, ["diff", "--name-only", "--diff-filter=U"], { cwd: worktree })); } +function rebaseProgress(worktree: string): string { + const gitDirectory = git(worktree, ["rev-parse", "--absolute-git-dir"], { cwd: worktree }); + const state = [git(worktree, ["rev-parse", "HEAD"], { cwd: worktree })]; + for (const directory of ["rebase-merge", "rebase-apply"]) { + for (const name of ["msgnum", "next", "stopped-sha", "git-rebase-todo", "done"]) { + const path = NodePath.join(gitDirectory, directory, name); + if (NodeFS.existsSync(path)) { + state.push(`${directory}/${name}:${NodeFS.readFileSync(path, "utf8")}`); + } + } + } + return state.join("\0"); +} + function rebaseOnto(worktree: string, upstreamTag: string, baseTag: string): void { let failure: unknown; try { @@ -133,12 +151,14 @@ function rebaseOnto(worktree: string, upstreamTag: string, baseTag: string): voi unmergedPaths: unmergedPaths(worktree), }) ) { + const previousProgress = rebaseProgress(worktree); console.log("[lastcode:checkpoint] Continuing Git's recorded conflict resolution..."); try { run(worktree, "git", ["-c", "core.editor=true", "rebase", "--continue"]); return; } catch (error) { failure = error; + if (!rerereRebaseMadeProgress(previousProgress, rebaseProgress(worktree))) break; } } From 9414db3e92be259f456c8c40fa83a053435a3de3 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 17:19:16 -0700 Subject: [PATCH 16/25] fix(lastcode): allow concurrent branch config updates --- docs/lastcode/release.md | 10 ++++++---- scripts/lastcode-local-ci.test.ts | 19 ++++++++++++++++++- scripts/lastcode-local-ci.ts | 20 +++++++++++++++----- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/lastcode/release.md b/docs/lastcode/release.md index f4c57c562cdb..eff416c38718 100644 --- a/docs/lastcode/release.md +++ b/docs/lastcode/release.md @@ -35,10 +35,12 @@ Local CI has three independent Git-safety boundaries: disposable global Git config, so fixture identities and defaults cannot be written into the shared repository config. 3. Before quick or full CI starts, the runner requires `core.bare=false` in the - shared Git config and snapshots that config byte-for-byte. It checks the - value, config contents, and common Git directory again on every exit, - including failed CI runs. Any change fails the gate with the config path to - inspect. + shared Git config and snapshots its repository-wide settings. It checks the + value, protected settings, and common Git directory again on every exit, + including failed CI runs. Any protected change fails the gate with the config + path to inspect. Per-branch sections are excluded because T3 and GitHub CLI + legitimately add branch/worktree bookkeeping while CI runs in another + worktree. These guards protect the primary checkout and every linked worktree, which all share the same Git config. They specifically prevent temporary-repository tests diff --git a/scripts/lastcode-local-ci.test.ts b/scripts/lastcode-local-ci.test.ts index 41fc841d9ae0..b2f68091664c 100644 --- a/scripts/lastcode-local-ci.test.ts +++ b/scripts/lastcode-local-ci.test.ts @@ -91,7 +91,7 @@ describe("lastcode-local-ci", () => { NodeFS.rmSync(root, { recursive: true, force: true }); }); - it("rejects bare repositories and shared config changes during CI", () => { + it("rejects bare repositories and protected shared config changes during CI", () => { const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-integrity-test-")); const repository = NodePath.join(root, "repository"); const bareRepository = NodePath.join(root, "bare.git"); @@ -114,6 +114,23 @@ describe("lastcode-local-ci", () => { NodeFS.rmSync(root, { recursive: true, force: true }); }); + it("allows concurrent branch bookkeeping in the shared config", () => { + const repository = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "lastcode-integrity-branch-test-"), + ); + NodeChildProcess.execFileSync("git", ["init", "--quiet"], { cwd: repository }); + const snapshot = captureRepositoryIntegrity(repository); + + NodeChildProcess.execFileSync( + "git", + ["config", "branch.concurrent-worktree.gh-merge-base", "lastcode/main"], + { cwd: repository }, + ); + + expect(() => assertRepositoryIntegrity(repository, snapshot)).not.toThrow(); + NodeFS.rmSync(repository, { recursive: true, force: true }); + }); + it("diagnoses a damaged shared config before resolving the worktree root", () => { const repository = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "lastcode-integrity-entry-test-"), diff --git a/scripts/lastcode-local-ci.ts b/scripts/lastcode-local-ci.ts index 8b68f888c70d..aaa25abc9ca8 100644 --- a/scripts/lastcode-local-ci.ts +++ b/scripts/lastcode-local-ci.ts @@ -57,8 +57,8 @@ export interface LocalCiOptions { export interface RepositoryIntegritySnapshot { readonly commonGitDir: string; - readonly configContents: Buffer; readonly configPath: string; + readonly protectedConfig: string; } export interface PreparedLocalCiRepository { @@ -324,6 +324,16 @@ function readCoreBare(repoRoot: string, configPath: string): string { ); } +function readProtectedConfig(repoRoot: string, configPath: string): string { + return runProcess(repoRoot, "git", ["config", "--file", configPath, "--null", "--list"], { + capture: true, + }) + .split("\0") + .filter((entry) => entry.length > 0 && !entry.startsWith("branch.")) + .sort() + .join("\0"); +} + export function captureRepositoryIntegrity(repoRoot: string): RepositoryIntegritySnapshot { const commonGitDir = resolveCommonGitDir(repoRoot); const configPath = NodePath.join(commonGitDir, "config"); @@ -335,8 +345,8 @@ export function captureRepositoryIntegrity(repoRoot: string): RepositoryIntegrit } return { commonGitDir, - configContents: NodeFS.readFileSync(configPath), configPath, + protectedConfig: readProtectedConfig(repoRoot, configPath), }; } @@ -357,10 +367,10 @@ export function assertRepositoryIntegrity( `Shared repository integrity changed during local CI: core.bare=${coreBare || "unset"}. Stop and inspect ${before.configPath}.`, ); } - const configContents = NodeFS.readFileSync(before.configPath); - if (!configContents.equals(before.configContents)) { + const protectedConfig = readProtectedConfig(repoRoot, before.configPath); + if (protectedConfig !== before.protectedConfig) { throw new Error( - `Shared repository integrity changed during local CI: ${before.configPath} was modified. Stop and inspect its diff before continuing.`, + `Shared repository integrity changed during local CI: protected settings in ${before.configPath} were modified. Stop and inspect the config before continuing.`, ); } const commonGitDir = resolveCommonGitDir(repoRoot); From acf4622419e2ceec18876f0438a0418f37d00394 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 17:33:48 -0700 Subject: [PATCH 17/25] fix(lastcode): serialize local update operations --- apps/web/src/environments/primary/auth.ts | 44 +++---- docs/lastcode/local-nightly-updates.md | 8 +- scripts/lastcode-install.mjs | 150 ++++++++++++++++------ scripts/lastcode-install.test.mjs | 15 +++ scripts/lastcode-local-update.mjs | 11 +- scripts/lastcode-local-update.test.ts | 8 ++ 6 files changed, 167 insertions(+), 69 deletions(-) diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 47bddba0e959..f18d5db322a0 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -188,31 +188,27 @@ function getDesktopBootstrapCredential(): string | null { export async function fetchSessionState(): Promise { const isDesktop = window.desktopBridge !== undefined; - return retryTransientBootstrap( - async () => { - try { - return await runPrimaryHttp( - PrimaryEnvironmentHttpClient.pipe( - Effect.flatMap((client) => client.auth.session({ headers: {} })), - ), - ); - } catch (error) { - throw PrimaryEnvironmentRequestError.fromCause({ - operation: "fetch-session-state", - cause: error, - }); + const retryOptions = isDesktop + ? { + retryError: (error: unknown, attemptElapsedMs: number) => + isDelayedDesktopCredentialProtocolError(error, attemptElapsedMs), + retryErrorTimeoutMs: DESKTOP_BOOTSTRAP_RETRY_TIMEOUT_MS, } - }, - { - ...(isDesktop - ? { - retryError: (error: unknown, attemptElapsedMs: number) => - isDelayedDesktopCredentialProtocolError(error, attemptElapsedMs), - retryErrorTimeoutMs: DESKTOP_BOOTSTRAP_RETRY_TIMEOUT_MS, - } - : {}), - }, - ); + : {}; + return retryTransientBootstrap(async () => { + try { + return await runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => client.auth.session({ headers: {} })), + ), + ); + } catch (error) { + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-session-state", + cause: error, + }); + } + }, retryOptions); } function readHttpApiStatus(error: unknown): number | null { diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index 1e01f5829bd9..baf1d215a142 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -39,8 +39,9 @@ The companion `lastcode-install` command uses `fzf` to choose a retained DMG, with the most recently built image selected by default. It stages and validates the replacement before quitting LastCode, then replaces `/Applications/LastCode.app` and relaunches it. Passing a DMG path skips the -picker. This manual bootstrap path does not require the currently installed app -to have local nightly updates enabled. +picker. An install-wide lock prevents overlapping commands from racing the app +replacement. This manual bootstrap path does not require the currently installed +app to have local nightly updates enabled. ## User flow @@ -100,6 +101,9 @@ also requires the checksum file and the manifest's annotated interrupted finalization is retried instead of treated as complete. Interrupting or quitting during a build terminates the helper's entire process group so CI and packaging cannot continue orphaned against that worktree. +The next build also reclaims an ownerless or partial lock left behind during the +lock's initialization, after a short grace period that avoids stealing it from a +still-starting process. Turning the setting off hides the local updater and stops future checks. It does not delete build artifacts, CI stamps, Git tags, or worktrees. diff --git a/scripts/lastcode-install.mjs b/scripts/lastcode-install.mjs index 66a21f6e2398..1065d2ea0527 100644 --- a/scripts/lastcode-install.mjs +++ b/scripts/lastcode-install.mjs @@ -9,6 +9,7 @@ import * as NodeURL from "node:url"; const APP_BUNDLE_ID = "codes.lastobelus.lastcode"; const DEFAULT_APP_PATH = "/Applications/LastCode.app"; +const INSTALL_LOCK_NAME = "install.lock"; function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; @@ -181,6 +182,66 @@ export function temporaryAppPaths(targetPath, processId = process.pid) { }; } +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +export function acquireInstallLock(lockDirectory, options = {}) { + const pid = options.pid ?? process.pid; + const isAlive = options.isAlive ?? processIsAlive; + const lockPath = NodePath.join(lockDirectory, INSTALL_LOCK_NAME); + const token = `${pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + NodeFS.mkdirSync(lockDirectory, { recursive: true }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const owner = { schemaVersion: 1, pid, token, startedAt: new Date().toISOString() }; + try { + // A symlink publishes the complete owner record atomically, so another + // installer can never observe a lock without its owner metadata. + NodeFS.symlinkSync(JSON.stringify(owner), lockPath); + let released = false; + return () => { + if (released) return; + released = true; + const currentOwner = JSON.parse(NodeFS.readlinkSync(lockPath)); + if (currentOwner.token !== token) { + throw new Error( + "Refusing to release a LastCode install lock now owned by another process.", + ); + } + NodeFS.unlinkSync(lockPath); + }; + } catch (error) { + if (error?.code !== "EEXIST" || attempt > 0) throw error; + let currentOwner; + try { + currentOwner = JSON.parse(NodeFS.readlinkSync(lockPath)); + } catch { + currentOwner = undefined; + } + if (Number.isSafeInteger(currentOwner?.pid) && isAlive(currentOwner.pid)) { + throw new Error( + `Another LastCode install is already running (PID ${currentOwner.pid}, started ${currentOwner.startedAt ?? "at an unknown time"}).`, + { cause: error }, + ); + } + const stalePath = `${lockPath}.stale-${pid}-${Date.now()}`; + try { + NodeFS.renameSync(lockPath, stalePath); + } catch { + throw new Error(`Another LastCode install acquired ${lockPath}.`); + } + NodeFS.rmSync(stalePath, { force: true, recursive: true }); + } + } + throw new Error(`Could not acquire the LastCode install lock at ${lockPath}.`); +} + async function installDmg(dmgPath, targetPath = DEFAULT_APP_PATH) { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone installed script has no Effect runtime. if (process.platform !== "darwin") throw new Error("lastcode-install only supports macOS."); @@ -189,55 +250,62 @@ async function installDmg(dmgPath, targetPath = DEFAULT_APP_PATH) { throw new Error(`DMG not found: ${resolvedDmg}`); } - const mountPoint = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-install-")); - const { backup, staging } = temporaryAppPaths(targetPath); - let attached = false; - let oldAppMoved = false; + const releaseInstallLock = acquireInstallLock( + NodePath.join(NodeOS.homedir(), ".lastcode", "local-updates"), + ); try { - console.log(`Mounting ${NodePath.basename(resolvedDmg)}…`); - run("hdiutil", ["attach", "-nobrowse", "-readonly", "-mountpoint", mountPoint, resolvedDmg]); - attached = true; - const sourceApp = NodePath.join(mountPoint, "LastCode.app"); - const version = validateApp(sourceApp); + const mountPoint = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-install-")); + const { backup, staging } = temporaryAppPaths(targetPath); + let attached = false; + let oldAppMoved = false; + try { + console.log(`Mounting ${NodePath.basename(resolvedDmg)}…`); + run("hdiutil", ["attach", "-nobrowse", "-readonly", "-mountpoint", mountPoint, resolvedDmg]); + attached = true; + const sourceApp = NodePath.join(mountPoint, "LastCode.app"); + const version = validateApp(sourceApp); - NodeFS.rmSync(staging, { force: true, recursive: true }); - NodeFS.rmSync(backup, { force: true, recursive: true }); - console.log(`Preparing LastCode ${version}…`); - run("ditto", [sourceApp, staging]); - validateApp(staging); - await quitApp(); + NodeFS.rmSync(staging, { force: true, recursive: true }); + NodeFS.rmSync(backup, { force: true, recursive: true }); + console.log(`Preparing LastCode ${version}…`); + run("ditto", [sourceApp, staging]); + validateApp(staging); + await quitApp(); - if (NodeFS.existsSync(targetPath)) { - NodeFS.renameSync(targetPath, backup); - oldAppMoved = true; - } - try { - NodeFS.renameSync(staging, targetPath); - run("open", [targetPath]); - } catch (error) { - NodeFS.rmSync(targetPath, { force: true, recursive: true }); - if (oldAppMoved) { - NodeFS.renameSync(backup, targetPath); - oldAppMoved = false; + if (NodeFS.existsSync(targetPath)) { + NodeFS.renameSync(targetPath, backup); + oldAppMoved = true; } - throw error; - } - NodeFS.rmSync(backup, { force: true, recursive: true }); - oldAppMoved = false; - console.log(`Installed and launched LastCode ${version}`); - } finally { - NodeFS.rmSync(staging, { force: true, recursive: true }); - if (oldAppMoved && !NodeFS.existsSync(targetPath) && NodeFS.existsSync(backup)) { - NodeFS.renameSync(backup, targetPath); - } - if (attached) { try { - run("hdiutil", ["detach", mountPoint]); + NodeFS.renameSync(staging, targetPath); + run("open", [targetPath]); } catch (error) { - console.error(`Warning: could not detach ${mountPoint}: ${error.message}`); + NodeFS.rmSync(targetPath, { force: true, recursive: true }); + if (oldAppMoved) { + NodeFS.renameSync(backup, targetPath); + oldAppMoved = false; + } + throw error; } + NodeFS.rmSync(backup, { force: true, recursive: true }); + oldAppMoved = false; + console.log(`Installed and launched LastCode ${version}`); + } finally { + NodeFS.rmSync(staging, { force: true, recursive: true }); + if (oldAppMoved && !NodeFS.existsSync(targetPath) && NodeFS.existsSync(backup)) { + NodeFS.renameSync(backup, targetPath); + } + if (attached) { + try { + run("hdiutil", ["detach", mountPoint]); + } catch (error) { + console.error(`Warning: could not detach ${mountPoint}: ${error.message}`); + } + } + NodeFS.rmSync(mountPoint, { force: true, recursive: true }); } - NodeFS.rmSync(mountPoint, { force: true, recursive: true }); + } finally { + releaseInstallLock(); } } diff --git a/scripts/lastcode-install.test.mjs b/scripts/lastcode-install.test.mjs index aa123ff913b0..6929c425e3f7 100644 --- a/scripts/lastcode-install.test.mjs +++ b/scripts/lastcode-install.test.mjs @@ -5,6 +5,7 @@ import * as NodePath from "node:path"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { + acquireInstallLock, discoverDmgs, parseDmgChoice, parseOptions, @@ -81,4 +82,18 @@ describe("LastCode userland install command", () => { "mise exec node@24.13.1 -- node '/tmp/Last Code/lastcode-install.mjs' \"$@\"", ); }); + + it("serializes installers and recovers an abandoned lock", () => { + const root = temporaryDirectory(); + const release = acquireInstallLock(root); + expect(() => acquireInstallLock(root)).toThrow("already running"); + release(); + + const lockPath = NodePath.join(root, "install.lock"); + NodeFS.symlinkSync(JSON.stringify({ schemaVersion: 1, pid: 12345 }), lockPath); + const releaseRecovered = acquireInstallLock(root, { isAlive: () => false }); + expect(NodeFS.lstatSync(lockPath).isSymbolicLink()).toBe(true); + releaseRecovered(); + expect(NodeFS.existsSync(lockPath)).toBe(false); + }); }); diff --git a/scripts/lastcode-local-update.mjs b/scripts/lastcode-local-update.mjs index 1625fbc25a6d..de1e1c71dd9a 100644 --- a/scripts/lastcode-local-update.mjs +++ b/scripts/lastcode-local-update.mjs @@ -260,6 +260,8 @@ function processIsAlive(pid) { } } +const BUILD_LOCK_INITIALIZATION_GRACE_MS = 5_000; + export function acquireBuildLock(updateRoot, options = {}) { const pid = options.pid ?? process.pid; const isAlive = options.isAlive ?? processIsAlive; @@ -293,9 +295,14 @@ export function acquireBuildLock(updateRoot, options = {}) { try { owner = JSON.parse(NodeFS.readFileSync(ownerPath, "utf8")); } catch { - throw new Error(`Another LastCode build owns ${lockPath}.`); + const lockAgeMs = Date.now() - NodeFS.statSync(lockPath).mtimeMs; + if (lockAgeMs < BUILD_LOCK_INITIALIZATION_GRACE_MS) { + throw new Error( + `Another LastCode build is initializing ${lockPath}. Retry in a few seconds.`, + ); + } } - if (Number.isSafeInteger(owner.pid) && isAlive(owner.pid)) { + if (Number.isSafeInteger(owner?.pid) && isAlive(owner.pid)) { throw new Error( `Another LastCode build is already running (PID ${owner.pid}, started ${owner.startedAt ?? "at an unknown time"}).`, { cause: error }, diff --git a/scripts/lastcode-local-update.test.ts b/scripts/lastcode-local-update.test.ts index c74c5f364cac..2a6e5fb2f39c 100644 --- a/scripts/lastcode-local-update.test.ts +++ b/scripts/lastcode-local-update.test.ts @@ -37,6 +37,14 @@ describe("lastcode-local-update", () => { assert.isTrue(NodeFS.existsSync(NodePath.join(lockPath, "owner.json"))); releaseRecovered(); assert.isFalse(NodeFS.existsSync(lockPath)); + + NodeFS.mkdirSync(lockPath); + assert.throws(() => acquireBuildLock(root, { isAlive: () => false }), /initializing/); + NodeFS.utimesSync(lockPath, 0, 0); + const releaseOwnerless = acquireBuildLock(root, { isAlive: () => false }); + assert.isTrue(NodeFS.existsSync(NodePath.join(lockPath, "owner.json"))); + releaseOwnerless(); + assert.isFalse(NodeFS.existsSync(lockPath)); } finally { NodeFS.rmSync(root, { recursive: true, force: true }); } From a039db425f6755b579694856641ec6d65da41f7d Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 17:44:33 -0700 Subject: [PATCH 18/25] fix(lastcode): serialize local CI test tasks --- docs/lastcode/release.md | 5 +++++ scripts/lastcode-local-ci.test.ts | 7 ++++++- scripts/lastcode-local-ci.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/lastcode/release.md b/docs/lastcode/release.md index eff416c38718..46cc83f7113a 100644 --- a/docs/lastcode/release.md +++ b/docs/lastcode/release.md @@ -49,6 +49,11 @@ a Git hook. They do not automatically repair an integrity failure: stop, inspect the reported config and worktree state, and preserve evidence before changing anything. +Workspace test tasks run one package and one Vitest worker at a time. This takes +longer than the task-runner defaults but avoids cross-suite state leaks plus +memory and CPU contention between the web, mobile, desktop, and server suites on +the single local build machine. + Before merging a LastCode PR, run the full gate from a clean feature branch: ```bash diff --git a/scripts/lastcode-local-ci.test.ts b/scripts/lastcode-local-ci.test.ts index b2f68091664c..aa311d2d7897 100644 --- a/scripts/lastcode-local-ci.test.ts +++ b/scripts/lastcode-local-ci.test.ts @@ -55,7 +55,8 @@ describe("lastcode-local-ci", () => { }); it("keeps release, native, Rust, and preload checks in the full gate", () => { - const quickLabels = resolveLocalCiSteps("quick").map(({ label }) => label); + const quickSteps = resolveLocalCiSteps("quick"); + const quickLabels = quickSteps.map(({ label }) => label); const fullLabels = resolveLocalCiSteps("full").map(({ label }) => label); expect(quickLabels).toEqual([ @@ -74,6 +75,10 @@ describe("lastcode-local-ci", () => { "Release smoke", ]), ); + expect(quickSteps.find(({ label }) => label === "Workspace tests")).toMatchObject({ + kind: "command", + args: ["run", "--recursive", "--concurrency-limit", "1", "test", "--", "--maxWorkers=1"], + }); }); it("checks the built preload bridge contract", () => { diff --git a/scripts/lastcode-local-ci.ts b/scripts/lastcode-local-ci.ts index aaa25abc9ca8..07173e443b5f 100644 --- a/scripts/lastcode-local-ci.ts +++ b/scripts/lastcode-local-ci.ts @@ -89,7 +89,7 @@ const QUICK_STEPS: ReadonlyArray = [ kind: "command", label: "Workspace tests", command: "vp", - args: ["run", "test"], + args: ["run", "--recursive", "--concurrency-limit", "1", "test", "--", "--maxWorkers=1"], isolatedGitConfig: true, transferBudgetOutput: true, }, From 8b6b5da2b2a77825e055724818994ab7f06e1c2c Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 18:13:27 -0700 Subject: [PATCH 19/25] fix(lastcode): preserve local artifact safety --- docs/lastcode/local-nightly-updates.md | 2 ++ docs/lastcode/release.md | 6 ++-- scripts/lastcode-install.mjs | 6 ++-- scripts/lastcode-install.test.mjs | 12 ++++++++ scripts/lastcode-local-ci.test.ts | 28 ++++++++++++++++++ scripts/lastcode-local-ci.ts | 41 +++++++++++++++++++++++--- 6 files changed, 86 insertions(+), 9 deletions(-) diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index baf1d215a142..26e5d31bc55e 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -42,6 +42,8 @@ the replacement before quitting LastCode, then replaces picker. An install-wide lock prevents overlapping commands from racing the app replacement. This manual bootstrap path does not require the currently installed app to have local nightly updates enabled. +Quarantined `.incomplete-*` artifact directories are never offered in the +picker. ## User flow diff --git a/docs/lastcode/release.md b/docs/lastcode/release.md index 46cc83f7113a..6846b8bc274b 100644 --- a/docs/lastcode/release.md +++ b/docs/lastcode/release.md @@ -38,9 +38,9 @@ Local CI has three independent Git-safety boundaries: shared Git config and snapshots its repository-wide settings. It checks the value, protected settings, and common Git directory again on every exit, including failed CI runs. Any protected change fails the gate with the config - path to inspect. Per-branch sections are excluded because T3 and GitHub CLI - legitimately add branch/worktree bookkeeping while CI runs in another - worktree. + path to inspect. Existing per-branch settings are preserved too, while new + branch keys are allowed because T3 and GitHub CLI legitimately add + branch/worktree bookkeeping while CI runs in another worktree. These guards protect the primary checkout and every linked worktree, which all share the same Git config. They specifically prevent temporary-repository tests diff --git a/scripts/lastcode-install.mjs b/scripts/lastcode-install.mjs index 1065d2ea0527..b57a6d4e431b 100644 --- a/scripts/lastcode-install.mjs +++ b/scripts/lastcode-install.mjs @@ -47,8 +47,10 @@ export function parseOptions(argv) { function walkDmgs(directory, results) { for (const entry of NodeFS.readdirSync(directory, { withFileTypes: true })) { const path = NodePath.join(directory, entry.name); - if (entry.isDirectory()) walkDmgs(path, results); - else if (entry.isFile() && entry.name.toLowerCase().endsWith(".dmg")) { + if (entry.isDirectory()) { + if (entry.name.includes(".incomplete-")) continue; + walkDmgs(path, results); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".dmg")) { const stat = NodeFS.statSync(path); results.push({ modifiedAt: stat.mtime, path, size: stat.size }); } diff --git a/scripts/lastcode-install.test.mjs b/scripts/lastcode-install.test.mjs index 6929c425e3f7..b242ba7ae421 100644 --- a/scripts/lastcode-install.test.mjs +++ b/scripts/lastcode-install.test.mjs @@ -50,6 +50,18 @@ describe("LastCode userland install command", () => { expect(discoverDmgs(root).map((entry) => entry.path)).toEqual([newer, older]); }); + it("excludes quarantined incomplete builds from the DMG picker", () => { + const root = temporaryDirectory(); + const complete = NodePath.join(root, "1104", "complete.dmg"); + const quarantined = NodePath.join(root, "1105.incomplete-123", "quarantined.dmg"); + NodeFS.mkdirSync(NodePath.dirname(complete), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(quarantined), { recursive: true }); + NodeFS.writeFileSync(complete, "complete"); + NodeFS.writeFileSync(quarantined, "incomplete"); + + expect(discoverDmgs(root).map((entry) => entry.path)).toEqual([complete]); + }); + it("keeps the newest DMG first and round-trips its hidden path through fzf", () => { const choices = renderDmgChoices( [ diff --git a/scripts/lastcode-local-ci.test.ts b/scripts/lastcode-local-ci.test.ts index aa311d2d7897..fcf48dc25fff 100644 --- a/scripts/lastcode-local-ci.test.ts +++ b/scripts/lastcode-local-ci.test.ts @@ -136,6 +136,34 @@ describe("lastcode-local-ci", () => { NodeFS.rmSync(repository, { recursive: true, force: true }); }); + it("rejects changes to branch settings that existed when CI started", () => { + const repository = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "lastcode-integrity-existing-branch-test-"), + ); + NodeChildProcess.execFileSync( + "git", + ["init", "--quiet", "--initial-branch", "lastcode/userland-build"], + { cwd: repository }, + ); + NodeChildProcess.execFileSync( + "git", + ["config", "branch.lastcode/userland-build.remote", "origin"], + { cwd: repository }, + ); + const snapshot = captureRepositoryIntegrity(repository); + + NodeChildProcess.execFileSync( + "git", + ["config", "branch.lastcode/userland-build.remote", "upstream"], + { cwd: repository }, + ); + + expect(() => assertRepositoryIntegrity(repository, snapshot)).toThrow( + "existing branch setting branch.lastcode/userland-build.remote", + ); + NodeFS.rmSync(repository, { recursive: true, force: true }); + }); + it("diagnoses a damaged shared config before resolving the worktree root", () => { const repository = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "lastcode-integrity-entry-test-"), diff --git a/scripts/lastcode-local-ci.ts b/scripts/lastcode-local-ci.ts index 07173e443b5f..127b0f131807 100644 --- a/scripts/lastcode-local-ci.ts +++ b/scripts/lastcode-local-ci.ts @@ -56,6 +56,7 @@ export interface LocalCiOptions { } export interface RepositoryIntegritySnapshot { + readonly branchConfig: Readonly>>; readonly commonGitDir: string; readonly configPath: string; readonly protectedConfig: string; @@ -324,16 +325,37 @@ function readCoreBare(repoRoot: string, configPath: string): string { ); } -function readProtectedConfig(repoRoot: string, configPath: string): string { +function readConfigEntries(repoRoot: string, configPath: string): ReadonlyArray { return runProcess(repoRoot, "git", ["config", "--file", configPath, "--null", "--list"], { capture: true, }) .split("\0") - .filter((entry) => entry.length > 0 && !entry.startsWith("branch.")) + .filter((entry) => entry.length > 0); +} + +function readProtectedConfig(entries: ReadonlyArray): string { + return entries + .filter((entry) => !entry.startsWith("branch.")) .sort() .join("\0"); } +function readBranchConfig( + entries: ReadonlyArray, +): Readonly>> { + const config: Record> = {}; + for (const entry of entries) { + if (!entry.startsWith("branch.")) continue; + const separator = entry.indexOf("\n"); + const key = separator < 0 ? entry : entry.slice(0, separator); + const value = separator < 0 ? "" : entry.slice(separator + 1); + (config[key] ??= []).push(value); + } + return Object.fromEntries( + Object.entries(config).map(([key, values]) => [key, values.toSorted()]), + ); +} + export function captureRepositoryIntegrity(repoRoot: string): RepositoryIntegritySnapshot { const commonGitDir = resolveCommonGitDir(repoRoot); const configPath = NodePath.join(commonGitDir, "config"); @@ -343,10 +365,12 @@ export function captureRepositoryIntegrity(repoRoot: string): RepositoryIntegrit `Refusing local CI because the shared repository config reports core.bare=${coreBare || "unset"}. Inspect ${configPath} before continuing.`, ); } + const configEntries = readConfigEntries(repoRoot, configPath); return { + branchConfig: readBranchConfig(configEntries), commonGitDir, configPath, - protectedConfig: readProtectedConfig(repoRoot, configPath), + protectedConfig: readProtectedConfig(configEntries), }; } @@ -367,12 +391,21 @@ export function assertRepositoryIntegrity( `Shared repository integrity changed during local CI: core.bare=${coreBare || "unset"}. Stop and inspect ${before.configPath}.`, ); } - const protectedConfig = readProtectedConfig(repoRoot, before.configPath); + const configEntries = readConfigEntries(repoRoot, before.configPath); + const protectedConfig = readProtectedConfig(configEntries); if (protectedConfig !== before.protectedConfig) { throw new Error( `Shared repository integrity changed during local CI: protected settings in ${before.configPath} were modified. Stop and inspect the config before continuing.`, ); } + const branchConfig = readBranchConfig(configEntries); + for (const [key, values] of Object.entries(before.branchConfig)) { + if (JSON.stringify(branchConfig[key]) !== JSON.stringify(values)) { + throw new Error( + `Shared repository integrity changed during local CI: existing branch setting ${key} in ${before.configPath} was modified. Stop and inspect the config before continuing.`, + ); + } + } const commonGitDir = resolveCommonGitDir(repoRoot); if (commonGitDir !== before.commonGitDir) { throw new Error( From 097133d959421a902b5b69089b8b8eea203916fd Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 18:16:03 -0700 Subject: [PATCH 20/25] fix(lastcode): serialize local CI cases --- docs/lastcode/release.md | 8 ++++---- scripts/lastcode-local-ci.test.ts | 11 ++++++++++- scripts/lastcode-local-ci.ts | 11 ++++++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/lastcode/release.md b/docs/lastcode/release.md index 6846b8bc274b..e31ab7fa91c7 100644 --- a/docs/lastcode/release.md +++ b/docs/lastcode/release.md @@ -49,10 +49,10 @@ a Git hook. They do not automatically repair an integrity failure: stop, inspect the reported config and worktree state, and preserve evidence before changing anything. -Workspace test tasks run one package and one Vitest worker at a time. This takes -longer than the task-runner defaults but avoids cross-suite state leaks plus -memory and CPU contention between the web, mobile, desktop, and server suites on -the single local build machine. +Workspace test tasks run one package, one Vitest worker, and one concurrent test +case at a time. This takes longer than the task-runner defaults but avoids +cross-suite state leaks plus memory and CPU contention between the web, mobile, +desktop, and server suites on the single local build machine. Before merging a LastCode PR, run the full gate from a clean feature branch: diff --git a/scripts/lastcode-local-ci.test.ts b/scripts/lastcode-local-ci.test.ts index fcf48dc25fff..880c453c2d86 100644 --- a/scripts/lastcode-local-ci.test.ts +++ b/scripts/lastcode-local-ci.test.ts @@ -77,7 +77,16 @@ describe("lastcode-local-ci", () => { ); expect(quickSteps.find(({ label }) => label === "Workspace tests")).toMatchObject({ kind: "command", - args: ["run", "--recursive", "--concurrency-limit", "1", "test", "--", "--maxWorkers=1"], + args: [ + "run", + "--recursive", + "--concurrency-limit", + "1", + "test", + "--", + "--maxWorkers=1", + "--maxConcurrency=1", + ], }); }); diff --git a/scripts/lastcode-local-ci.ts b/scripts/lastcode-local-ci.ts index 127b0f131807..b754c0ede2f3 100644 --- a/scripts/lastcode-local-ci.ts +++ b/scripts/lastcode-local-ci.ts @@ -90,7 +90,16 @@ const QUICK_STEPS: ReadonlyArray = [ kind: "command", label: "Workspace tests", command: "vp", - args: ["run", "--recursive", "--concurrency-limit", "1", "test", "--", "--maxWorkers=1"], + args: [ + "run", + "--recursive", + "--concurrency-limit", + "1", + "test", + "--", + "--maxWorkers=1", + "--maxConcurrency=1", + ], isolatedGitConfig: true, transferBudgetOutput: true, }, From 627a59fb037f6a65141f9f4fab5f15a4b1254152 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 18:29:30 -0700 Subject: [PATCH 21/25] fix(lastcode): make local tools reversible --- docs/lastcode/local-nightly-updates.md | 4 +++ docs/lastcode/nightly-workflow.md | 11 +++++++ scripts/lastcode-build.mjs | 43 ++++++++++++++++++++++++-- scripts/lastcode-build.test.mjs | 33 ++++++++++++++++++++ scripts/lastcode-install.mjs | 40 ++++++++++++++++++++++-- scripts/lastcode-install.test.mjs | 23 ++++++++++++++ scripts/lastcode-local-ci.test.ts | 27 ++++++++++++++++ scripts/lastcode-local-ci.ts | 9 ++---- 8 files changed, 179 insertions(+), 11 deletions(-) diff --git a/docs/lastcode/local-nightly-updates.md b/docs/lastcode/local-nightly-updates.md index 26e5d31bc55e..a59e286e7daf 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -22,6 +22,10 @@ pnpm run lastcode:build -- --install pnpm run lastcode:install -- --install ``` +The two optional userland commands can be removed later with +`lastcode-build --uninstall` and `lastcode-install --uninstall`. Their shared +checkpoint configuration and build artifacts are preserved. + The dashboard installer records the dedicated automation worktree in `~/.lastcode/dashboard.json`. The desktop updater uses that worktree only to read checkpoint tags and launch the versioned helper; it never checks out or diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index e40c68c93df1..b728248b0f7d 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -265,6 +265,17 @@ in-app builds share a cross-process lock for the complete checkout, CI, packaging, and artifact-validation sequence; an overlapping request exits without touching the shared worktree. +Remove the optional userland commands with their reverse operations: + +```bash +lastcode-build --uninstall +lastcode-install --uninstall +``` + +Uninstall removes only each command's managed launcher, copied module, helper, +and matching PATH symlink. It preserves shared checkpoint configuration, build +artifacts, and any foreign file or symlink. + Use `lastcode-install` to install one of those retained DMGs. It presents every DMG under `~/.lastcode/local-updates/artifacts` in an `fzf` picker, ordered with the newest build selected. After selection it validates and mounts the image, diff --git a/scripts/lastcode-build.mjs b/scripts/lastcode-build.mjs index e949068b984f..57c4d8123006 100644 --- a/scripts/lastcode-build.mjs +++ b/scripts/lastcode-build.mjs @@ -225,10 +225,12 @@ export function parseOptions(argv) { let checkpoint; let install = false; let repoRoot; + let uninstall = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--") continue; if (arg === "--install") install = true; + else if (arg === "--uninstall") uninstall = true; else if (arg === "-c" || arg === "--checkpoint" || arg === "--repo") { const value = argv[index + 1]; if (!value) throw new Error(`Missing value for ${arg}.`); @@ -236,7 +238,7 @@ export function parseOptions(argv) { else checkpoint = value; index += 1; } else if (arg === "-h" || arg === "--help") { - return { help: true, checkpoint, install, repoRoot }; + return { help: true, checkpoint, install, repoRoot, uninstall }; } else if (arg.startsWith("-")) { throw new Error(`Unknown argument '${arg}'.`); } else if (checkpoint) { @@ -245,7 +247,10 @@ export function parseOptions(argv) { checkpoint = arg; } } - return { help: false, checkpoint, install, repoRoot }; + if (uninstall && (install || checkpoint || repoRoot)) { + throw new Error("--uninstall cannot be combined with build or install options."); + } + return { help: false, checkpoint, install, repoRoot, uninstall }; } export function resolveCheckpointTag(tags, selector) { @@ -350,6 +355,35 @@ function installCommand(repoRoot, home) { console.log(`Exposed on PATH as ${exposed}`); } +function assertManagedFile(path) { + const existing = NodeFS.lstatSync(path, { throwIfNoEntry: false }); + if (existing && !existing.isFile()) { + throw new Error(`Refusing to remove ${path} because it is not a LastCode-managed file.`); + } +} + +export function uninstallCommand(home) { + const binDirectory = NodePath.join(home, ".lastcode", "bin"); + const moduleTarget = NodePath.join(binDirectory, "lastcode-build.mjs"); + const helperTarget = NodePath.join(binDirectory, "lastcode-local-update.mjs"); + const target = NodePath.join(binDirectory, "lastcode-build"); + const exposed = NodePath.join(home, ".local", "bin", "lastcode-build"); + const exposedEntry = NodeFS.lstatSync(exposed, { throwIfNoEntry: false }); + if (exposedEntry && (!exposedEntry.isSymbolicLink() || NodeFS.readlinkSync(exposed) !== target)) { + throw new Error(`Refusing to remove ${exposed} because it is not managed by LastCode.`); + } + for (const path of [moduleTarget, helperTarget, target]) assertManagedFile(path); + + if (exposedEntry) NodeFS.unlinkSync(exposed); + for (const path of [moduleTarget, helperTarget, target]) NodeFS.rmSync(path, { force: true }); + try { + NodeFS.rmdirSync(binDirectory); + } catch (error) { + if (error?.code !== "ENOTEMPTY" && error?.code !== "ENOENT") throw error; + } + console.log("Uninstalled lastcode-build"); +} + export function parseBuildResult(stdout) { const line = splitLines(stdout).find((entry) => entry.startsWith(RESULT_PREFIX)); if (!line) throw new Error("The local build helper did not return a build result."); @@ -433,12 +467,17 @@ async function main(argv) { if (options.help) { console.log("Usage: lastcode-build [CHECKPOINT]"); console.log(" lastcode-build --checkpoint CHECKPOINT"); + console.log(" lastcode-build --uninstall"); console.log(""); console.log("CHECKPOINT may be 1090, a full nightly tag, or a lastcode/checkpoint tag."); console.log("Without CHECKPOINT, the newest local checkpoint is built."); return; } const home = NodeOS.homedir(); + if (options.uninstall) { + uninstallCommand(home); + return; + } const repoRoot = resolveConfiguredRepo(home, options.repoRoot); if (options.install) { installCommand(repoRoot, home); diff --git a/scripts/lastcode-build.test.mjs b/scripts/lastcode-build.test.mjs index afc5d26d87af..514e1a471c8e 100644 --- a/scripts/lastcode-build.test.mjs +++ b/scripts/lastcode-build.test.mjs @@ -1,3 +1,7 @@ +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import { describe, expect, it } from "vite-plus/test"; import { @@ -10,6 +14,7 @@ import { resolveBuildPhaseIndex, resolveCheckpointTag, sanitizeLogLine, + uninstallCommand, } from "./lastcode-build.mjs"; const tags = [ @@ -24,6 +29,34 @@ describe("LastCode userland build command", () => { expect(parseOptions(["--checkpoint", "1092"]).checkpoint).toBe("1092"); expect(parseOptions(["-c", "1095"]).checkpoint).toBe("1095"); expect(() => parseOptions(["1090", "1092"])).toThrow("Unexpected second checkpoint"); + expect(parseOptions(["--uninstall"]).uninstall).toBe(true); + expect(() => parseOptions(["--uninstall", "1090"])).toThrow("cannot be combined"); + }); + + it("uninstalls only the managed build command artifacts", () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-build-uninstall-")); + try { + const binDirectory = NodePath.join(home, ".lastcode", "bin"); + const exposedDirectory = NodePath.join(home, ".local", "bin"); + const target = NodePath.join(binDirectory, "lastcode-build"); + const exposed = NodePath.join(exposedDirectory, "lastcode-build"); + const dashboard = NodePath.join(home, ".lastcode", "dashboard.json"); + NodeFS.mkdirSync(binDirectory, { recursive: true }); + NodeFS.mkdirSync(exposedDirectory, { recursive: true }); + for (const name of ["lastcode-build", "lastcode-build.mjs", "lastcode-local-update.mjs"]) { + NodeFS.writeFileSync(NodePath.join(binDirectory, name), "managed"); + } + NodeFS.writeFileSync(dashboard, "shared config"); + NodeFS.symlinkSync(target, exposed); + + uninstallCommand(home); + + expect(NodeFS.existsSync(exposed)).toBe(false); + expect(NodeFS.existsSync(target)).toBe(false); + expect(NodeFS.existsSync(dashboard)).toBe(true); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } }); it("selects the newest checkpoint by default", () => { diff --git a/scripts/lastcode-install.mjs b/scripts/lastcode-install.mjs index b57a6d4e431b..5551f115e6a2 100644 --- a/scripts/lastcode-install.mjs +++ b/scripts/lastcode-install.mjs @@ -23,16 +23,18 @@ export function parseOptions(argv) { let artifactsDirectory; let dmgPath; let install = false; + let uninstall = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--") continue; if (arg === "--install") install = true; + else if (arg === "--uninstall") uninstall = true; else if (arg === "--artifacts") { artifactsDirectory = argv[index + 1]; if (!artifactsDirectory) throw new Error("Missing value for --artifacts."); index += 1; } else if (arg === "-h" || arg === "--help") { - return { artifactsDirectory, dmgPath, help: true, install }; + return { artifactsDirectory, dmgPath, help: true, install, uninstall }; } else if (arg.startsWith("-")) { throw new Error(`Unknown argument '${arg}'.`); } else if (dmgPath) { @@ -41,7 +43,10 @@ export function parseOptions(argv) { dmgPath = arg; } } - return { artifactsDirectory, dmgPath, help: false, install }; + if (uninstall && (install || artifactsDirectory || dmgPath)) { + throw new Error("--uninstall cannot be combined with DMG or install options."); + } + return { artifactsDirectory, dmgPath, help: false, install, uninstall }; } function walkDmgs(directory, results) { @@ -338,16 +343,47 @@ function installCommand(home) { console.log(`Exposed on PATH as ${exposed}`); } +export function uninstallCommand(home) { + const binDirectory = NodePath.join(home, ".lastcode", "bin"); + const moduleTarget = NodePath.join(binDirectory, "lastcode-install.mjs"); + const target = NodePath.join(binDirectory, "lastcode-install"); + const exposed = NodePath.join(home, ".local", "bin", "lastcode-install"); + const exposedEntry = NodeFS.lstatSync(exposed, { throwIfNoEntry: false }); + if (exposedEntry && (!exposedEntry.isSymbolicLink() || NodeFS.readlinkSync(exposed) !== target)) { + throw new Error(`Refusing to remove ${exposed} because it is not managed by LastCode.`); + } + for (const path of [moduleTarget, target]) { + const existing = NodeFS.lstatSync(path, { throwIfNoEntry: false }); + if (existing && !existing.isFile()) { + throw new Error(`Refusing to remove ${path} because it is not a LastCode-managed file.`); + } + } + + if (exposedEntry) NodeFS.unlinkSync(exposed); + for (const path of [moduleTarget, target]) NodeFS.rmSync(path, { force: true }); + try { + NodeFS.rmdirSync(binDirectory); + } catch (error) { + if (error?.code !== "ENOTEMPTY" && error?.code !== "ENOENT") throw error; + } + console.log("Uninstalled lastcode-install"); +} + async function main(argv) { const options = parseOptions(argv); if (options.help) { console.log("Usage: lastcode-install [DMG]"); console.log(" lastcode-install --artifacts PATH"); + console.log(" lastcode-install --uninstall"); console.log(""); console.log("Without DMG, choose from ~/.lastcode/local-updates/artifacts using fzf."); return; } const home = NodeOS.homedir(); + if (options.uninstall) { + uninstallCommand(home); + return; + } if (options.install) { installCommand(home); return; diff --git a/scripts/lastcode-install.test.mjs b/scripts/lastcode-install.test.mjs index b242ba7ae421..92beec94d87d 100644 --- a/scripts/lastcode-install.test.mjs +++ b/scripts/lastcode-install.test.mjs @@ -12,6 +12,7 @@ import { renderDmgChoices, renderLauncher, temporaryAppPaths, + uninstallCommand, } from "./lastcode-install.mjs"; const temporaryDirectories = []; @@ -34,6 +35,8 @@ describe("LastCode userland install command", () => { expect(parseOptions(["/tmp/LastCode.dmg"]).dmgPath).toBe("/tmp/LastCode.dmg"); expect(parseOptions(["--artifacts", "/tmp/builds"]).artifactsDirectory).toBe("/tmp/builds"); expect(() => parseOptions(["one.dmg", "two.dmg"])).toThrow("Unexpected second DMG"); + expect(parseOptions(["--uninstall"]).uninstall).toBe(true); + expect(() => parseOptions(["--uninstall", "one.dmg"])).toThrow("cannot be combined"); }); it("discovers DMGs recursively with the newest first", () => { @@ -108,4 +111,24 @@ describe("LastCode userland install command", () => { releaseRecovered(); expect(NodeFS.existsSync(lockPath)).toBe(false); }); + + it("uninstalls the managed installer and refuses a foreign PATH entry", () => { + const home = temporaryDirectory(); + const binDirectory = NodePath.join(home, ".lastcode", "bin"); + const exposedDirectory = NodePath.join(home, ".local", "bin"); + const target = NodePath.join(binDirectory, "lastcode-install"); + const exposed = NodePath.join(exposedDirectory, "lastcode-install"); + NodeFS.mkdirSync(binDirectory, { recursive: true }); + NodeFS.mkdirSync(exposedDirectory, { recursive: true }); + NodeFS.writeFileSync(target, "managed"); + NodeFS.writeFileSync(NodePath.join(binDirectory, "lastcode-install.mjs"), "managed"); + NodeFS.symlinkSync(target, exposed); + + uninstallCommand(home); + expect(NodeFS.existsSync(exposed)).toBe(false); + expect(NodeFS.existsSync(target)).toBe(false); + + NodeFS.writeFileSync(exposed, "mine"); + expect(() => uninstallCommand(home)).toThrow("not managed by LastCode"); + }); }); diff --git a/scripts/lastcode-local-ci.test.ts b/scripts/lastcode-local-ci.test.ts index 880c453c2d86..05efa96fa15c 100644 --- a/scripts/lastcode-local-ci.test.ts +++ b/scripts/lastcode-local-ci.test.ts @@ -173,6 +173,33 @@ describe("lastcode-local-ci", () => { NodeFS.rmSync(repository, { recursive: true, force: true }); }); + it("rejects reordering protected multivalue settings", () => { + const repository = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "lastcode-integrity-config-order-test-"), + ); + NodeChildProcess.execFileSync("git", ["init", "--quiet"], { cwd: repository }); + NodeChildProcess.execFileSync("git", ["config", "--add", "include.path", "first.inc"], { + cwd: repository, + }); + NodeChildProcess.execFileSync("git", ["config", "--add", "include.path", "second.inc"], { + cwd: repository, + }); + const snapshot = captureRepositoryIntegrity(repository); + + NodeChildProcess.execFileSync("git", ["config", "--unset-all", "include.path"], { + cwd: repository, + }); + NodeChildProcess.execFileSync("git", ["config", "--add", "include.path", "second.inc"], { + cwd: repository, + }); + NodeChildProcess.execFileSync("git", ["config", "--add", "include.path", "first.inc"], { + cwd: repository, + }); + + expect(() => assertRepositoryIntegrity(repository, snapshot)).toThrow("protected settings"); + NodeFS.rmSync(repository, { recursive: true, force: true }); + }); + it("diagnoses a damaged shared config before resolving the worktree root", () => { const repository = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "lastcode-integrity-entry-test-"), diff --git a/scripts/lastcode-local-ci.ts b/scripts/lastcode-local-ci.ts index b754c0ede2f3..b42c1948fc77 100644 --- a/scripts/lastcode-local-ci.ts +++ b/scripts/lastcode-local-ci.ts @@ -343,10 +343,7 @@ function readConfigEntries(repoRoot: string, configPath: string): ReadonlyArray< } function readProtectedConfig(entries: ReadonlyArray): string { - return entries - .filter((entry) => !entry.startsWith("branch.")) - .sort() - .join("\0"); + return entries.filter((entry) => !entry.startsWith("branch.")).join("\0"); } function readBranchConfig( @@ -360,9 +357,7 @@ function readBranchConfig( const value = separator < 0 ? "" : entry.slice(separator + 1); (config[key] ??= []).push(value); } - return Object.fromEntries( - Object.entries(config).map(([key, values]) => [key, values.toSorted()]), - ); + return config; } export function captureRepositoryIntegrity(repoRoot: string): RepositoryIntegritySnapshot { From 80ca6dc61f03f202b071d6acd576528f2e7977e6 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 18:57:01 -0700 Subject: [PATCH 22/25] fix(lastcode): verify local command ownership --- scripts/lastcode-build.mjs | 13 +++- scripts/lastcode-build.test.mjs | 35 ++++++++- scripts/lastcode-install.mjs | 101 +++++++++++++------------ scripts/lastcode-install.test.mjs | 27 ++++--- scripts/lastcode-local-update.d.mts | 1 - scripts/lastcode-local-update.mjs | 102 ++++++++++++-------------- scripts/lastcode-local-update.test.ts | 22 +----- 7 files changed, 163 insertions(+), 138 deletions(-) diff --git a/scripts/lastcode-build.mjs b/scripts/lastcode-build.mjs index 57c4d8123006..f1ef2c16a656 100644 --- a/scripts/lastcode-build.mjs +++ b/scripts/lastcode-build.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// LastCode managed command: lastcode-build import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; @@ -10,6 +11,8 @@ import * as NodeUtil from "node:util"; const CHECKPOINT_PREFIX = "lastcode/checkpoint/"; const RESULT_PREFIX = "LASTCODE_LOCAL_UPDATE_RESULT="; const LOG_POLL_INTERVAL_MS = 400; +const BUILD_MANAGED_MARKER = "LastCode managed command: lastcode-build"; +const UPDATE_HELPER_MANAGED_MARKER = "LastCode managed helper: lastcode-local-update"; export const BUILD_PHASES = [ { marker: "Building lastcode/checkpoint/", start: 0, estimateMs: 10_000 }, @@ -182,7 +185,7 @@ function shellQuote(value) { } export function renderLauncher(modulePath) { - return `#!/bin/sh\nexec mise exec node@24.13.1 -- node ${shellQuote(modulePath)} "$@"\n`; + return `#!/bin/sh\n# ${BUILD_MANAGED_MARKER}\nexec mise exec node@24.13.1 -- node ${shellQuote(modulePath)} "$@"\n`; } function runGit(repoRoot, args) { @@ -355,9 +358,9 @@ function installCommand(repoRoot, home) { console.log(`Exposed on PATH as ${exposed}`); } -function assertManagedFile(path) { +function assertManagedFile(path, marker) { const existing = NodeFS.lstatSync(path, { throwIfNoEntry: false }); - if (existing && !existing.isFile()) { + if (existing && (!existing.isFile() || !NodeFS.readFileSync(path, "utf8").includes(marker))) { throw new Error(`Refusing to remove ${path} because it is not a LastCode-managed file.`); } } @@ -372,7 +375,9 @@ export function uninstallCommand(home) { if (exposedEntry && (!exposedEntry.isSymbolicLink() || NodeFS.readlinkSync(exposed) !== target)) { throw new Error(`Refusing to remove ${exposed} because it is not managed by LastCode.`); } - for (const path of [moduleTarget, helperTarget, target]) assertManagedFile(path); + assertManagedFile(moduleTarget, BUILD_MANAGED_MARKER); + assertManagedFile(helperTarget, UPDATE_HELPER_MANAGED_MARKER); + assertManagedFile(target, BUILD_MANAGED_MARKER); if (exposedEntry) NodeFS.unlinkSync(exposed); for (const path of [moduleTarget, helperTarget, target]) NodeFS.rmSync(path, { force: true }); diff --git a/scripts/lastcode-build.test.mjs b/scripts/lastcode-build.test.mjs index 514e1a471c8e..5714081a0719 100644 --- a/scripts/lastcode-build.test.mjs +++ b/scripts/lastcode-build.test.mjs @@ -43,9 +43,18 @@ describe("LastCode userland build command", () => { const dashboard = NodePath.join(home, ".lastcode", "dashboard.json"); NodeFS.mkdirSync(binDirectory, { recursive: true }); NodeFS.mkdirSync(exposedDirectory, { recursive: true }); - for (const name of ["lastcode-build", "lastcode-build.mjs", "lastcode-local-update.mjs"]) { - NodeFS.writeFileSync(NodePath.join(binDirectory, name), "managed"); - } + NodeFS.writeFileSync( + NodePath.join(binDirectory, "lastcode-build"), + "# LastCode managed command: lastcode-build\n", + ); + NodeFS.writeFileSync( + NodePath.join(binDirectory, "lastcode-build.mjs"), + "// LastCode managed command: lastcode-build\n", + ); + NodeFS.writeFileSync( + NodePath.join(binDirectory, "lastcode-local-update.mjs"), + "// LastCode managed helper: lastcode-local-update\n", + ); NodeFS.writeFileSync(dashboard, "shared config"); NodeFS.symlinkSync(target, exposed); @@ -59,6 +68,26 @@ describe("LastCode userland build command", () => { } }); + it("refuses to uninstall a foreign file at a managed build path", () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-build-foreign-")); + try { + const binDirectory = NodePath.join(home, ".lastcode", "bin"); + const exposedDirectory = NodePath.join(home, ".local", "bin"); + const target = NodePath.join(binDirectory, "lastcode-build"); + const exposed = NodePath.join(exposedDirectory, "lastcode-build"); + NodeFS.mkdirSync(binDirectory, { recursive: true }); + NodeFS.mkdirSync(exposedDirectory, { recursive: true }); + NodeFS.writeFileSync(target, "mine\n"); + NodeFS.symlinkSync(target, exposed); + + expect(() => uninstallCommand(home)).toThrow("not a LastCode-managed file"); + expect(NodeFS.existsSync(target)).toBe(true); + expect(NodeFS.existsSync(exposed)).toBe(true); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } + }); + it("selects the newest checkpoint by default", () => { expect(resolveCheckpointTag(tags)).toBe("lastcode/checkpoint/v0.0.34-nightly.20260814.1095"); }); diff --git a/scripts/lastcode-install.mjs b/scripts/lastcode-install.mjs index 5551f115e6a2..dcd3b2d20eec 100644 --- a/scripts/lastcode-install.mjs +++ b/scripts/lastcode-install.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// LastCode managed command: lastcode-install import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; @@ -10,13 +11,14 @@ import * as NodeURL from "node:url"; const APP_BUNDLE_ID = "codes.lastobelus.lastcode"; const DEFAULT_APP_PATH = "/Applications/LastCode.app"; const INSTALL_LOCK_NAME = "install.lock"; +const INSTALL_MANAGED_MARKER = "LastCode managed command: lastcode-install"; function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; } export function renderLauncher(modulePath) { - return `#!/bin/sh\nexec mise exec node@24.13.1 -- node ${shellQuote(modulePath)} "$@"\n`; + return `#!/bin/sh\n# ${INSTALL_MANAGED_MARKER}\nexec mise exec node@24.13.1 -- node ${shellQuote(modulePath)} "$@"\n`; } export function parseOptions(argv) { @@ -189,64 +191,64 @@ export function temporaryAppPaths(targetPath, processId = process.pid) { }; } -function processIsAlive(pid) { +function readLockOwner(path) { try { - process.kill(pid, 0); - return true; - } catch (error) { - return error?.code === "EPERM"; + return JSON.parse(NodeFS.readFileSync(path, "utf8")); + } catch { + return undefined; } } export function acquireInstallLock(lockDirectory, options = {}) { const pid = options.pid ?? process.pid; - const isAlive = options.isAlive ?? processIsAlive; const lockPath = NodePath.join(lockDirectory, INSTALL_LOCK_NAME); const token = `${pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; NodeFS.mkdirSync(lockDirectory, { recursive: true }); - for (let attempt = 0; attempt < 2; attempt += 1) { - const owner = { schemaVersion: 1, pid, token, startedAt: new Date().toISOString() }; - try { - // A symlink publishes the complete owner record atomically, so another - // installer can never observe a lock without its owner metadata. - NodeFS.symlinkSync(JSON.stringify(owner), lockPath); - let released = false; - return () => { - if (released) return; - released = true; - const currentOwner = JSON.parse(NodeFS.readlinkSync(lockPath)); - if (currentOwner.token !== token) { - throw new Error( - "Refusing to release a LastCode install lock now owned by another process.", - ); - } - NodeFS.unlinkSync(lockPath); - }; - } catch (error) { - if (error?.code !== "EEXIST" || attempt > 0) throw error; - let currentOwner; - try { - currentOwner = JSON.parse(NodeFS.readlinkSync(lockPath)); - } catch { - currentOwner = undefined; - } - if (Number.isSafeInteger(currentOwner?.pid) && isAlive(currentOwner.pid)) { - throw new Error( - `Another LastCode install is already running (PID ${currentOwner.pid}, started ${currentOwner.startedAt ?? "at an unknown time"}).`, - { cause: error }, - ); - } - const stalePath = `${lockPath}.stale-${pid}-${Date.now()}`; - try { - NodeFS.renameSync(lockPath, stalePath); - } catch { - throw new Error(`Another LastCode install acquired ${lockPath}.`); - } - NodeFS.rmSync(stalePath, { force: true, recursive: true }); + const descriptor = NodeFS.openSync(lockPath, "a+", 0o600); + const result = NodeChildProcess.spawnSync("/usr/bin/lockf", ["-s", "-t", "0", "3"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe", descriptor], + }); + if (result.error || result.status !== 0) { + const owner = readLockOwner(lockPath); + NodeFS.closeSync(descriptor); + if (result.error) throw result.error; + if (result.status !== 75) { + throw new Error(result.stderr.trim() || `lockf failed with exit code ${result.status}.`); } + throw new Error( + `Another LastCode install is already running (PID ${owner?.pid ?? "unknown"}, started ${owner?.startedAt ?? "at an unknown time"}).`, + ); } - throw new Error(`Could not acquire the LastCode install lock at ${lockPath}.`); + const owner = { schemaVersion: 1, pid, token, startedAt: new Date().toISOString() }; + try { + NodeFS.ftruncateSync(descriptor, 0); + NodeFS.writeSync(descriptor, `${JSON.stringify(owner)}\n`); + NodeFS.fsyncSync(descriptor); + } catch (error) { + NodeFS.closeSync(descriptor); + throw error; + } + const lockIdentity = NodeFS.fstatSync(descriptor); + let released = false; + return () => { + if (released) return; + const currentIdentity = NodeFS.statSync(lockPath, { throwIfNoEntry: false }); + const currentOwner = readLockOwner(lockPath); + if ( + currentIdentity?.dev !== lockIdentity.dev || + currentIdentity?.ino !== lockIdentity.ino || + currentOwner?.token !== token + ) { + NodeFS.closeSync(descriptor); + released = true; + throw new Error("Refusing to release a LastCode install lock now owned by another process."); + } + NodeFS.ftruncateSync(descriptor, 0); + NodeFS.closeSync(descriptor); + released = true; + }; } async function installDmg(dmgPath, targetPath = DEFAULT_APP_PATH) { @@ -354,7 +356,10 @@ export function uninstallCommand(home) { } for (const path of [moduleTarget, target]) { const existing = NodeFS.lstatSync(path, { throwIfNoEntry: false }); - if (existing && !existing.isFile()) { + if ( + existing && + (!existing.isFile() || !NodeFS.readFileSync(path, "utf8").includes(INSTALL_MANAGED_MARKER)) + ) { throw new Error(`Refusing to remove ${path} because it is not a LastCode-managed file.`); } } diff --git a/scripts/lastcode-install.test.mjs b/scripts/lastcode-install.test.mjs index 92beec94d87d..2477be0c57b4 100644 --- a/scripts/lastcode-install.test.mjs +++ b/scripts/lastcode-install.test.mjs @@ -98,21 +98,19 @@ describe("LastCode userland install command", () => { ); }); - it("serializes installers and recovers an abandoned lock", () => { + it("serializes installers and releases the kernel lock", () => { const root = temporaryDirectory(); const release = acquireInstallLock(root); expect(() => acquireInstallLock(root)).toThrow("already running"); release(); const lockPath = NodePath.join(root, "install.lock"); - NodeFS.symlinkSync(JSON.stringify({ schemaVersion: 1, pid: 12345 }), lockPath); - const releaseRecovered = acquireInstallLock(root, { isAlive: () => false }); - expect(NodeFS.lstatSync(lockPath).isSymbolicLink()).toBe(true); - releaseRecovered(); - expect(NodeFS.existsSync(lockPath)).toBe(false); + expect(NodeFS.readFileSync(lockPath, "utf8")).toBe(""); + const releaseAgain = acquireInstallLock(root); + releaseAgain(); }); - it("uninstalls the managed installer and refuses a foreign PATH entry", () => { + it("uninstalls the managed installer and refuses foreign files", () => { const home = temporaryDirectory(); const binDirectory = NodePath.join(home, ".lastcode", "bin"); const exposedDirectory = NodePath.join(home, ".local", "bin"); @@ -120,8 +118,11 @@ describe("LastCode userland install command", () => { const exposed = NodePath.join(exposedDirectory, "lastcode-install"); NodeFS.mkdirSync(binDirectory, { recursive: true }); NodeFS.mkdirSync(exposedDirectory, { recursive: true }); - NodeFS.writeFileSync(target, "managed"); - NodeFS.writeFileSync(NodePath.join(binDirectory, "lastcode-install.mjs"), "managed"); + NodeFS.writeFileSync(target, "# LastCode managed command: lastcode-install\n"); + NodeFS.writeFileSync( + NodePath.join(binDirectory, "lastcode-install.mjs"), + "// LastCode managed command: lastcode-install\n", + ); NodeFS.symlinkSync(target, exposed); uninstallCommand(home); @@ -130,5 +131,13 @@ describe("LastCode userland install command", () => { NodeFS.writeFileSync(exposed, "mine"); expect(() => uninstallCommand(home)).toThrow("not managed by LastCode"); + + NodeFS.rmSync(exposed); + NodeFS.mkdirSync(binDirectory, { recursive: true }); + NodeFS.writeFileSync(target, "mine"); + NodeFS.symlinkSync(target, exposed); + expect(() => uninstallCommand(home)).toThrow("not a LastCode-managed file"); + expect(NodeFS.existsSync(target)).toBe(true); + expect(NodeFS.existsSync(exposed)).toBe(true); }); }); diff --git a/scripts/lastcode-local-update.d.mts b/scripts/lastcode-local-update.d.mts index b4e98a198512..ba0341b462ba 100644 --- a/scripts/lastcode-local-update.d.mts +++ b/scripts/lastcode-local-update.d.mts @@ -25,7 +25,6 @@ export interface ExistingBuildOptions { export interface LocalBuildLockOptions { readonly pid?: number; - readonly isAlive?: (pid: number) => boolean; } export function resolveDeterministicBuildEnvironment( diff --git a/scripts/lastcode-local-update.mjs b/scripts/lastcode-local-update.mjs index de1e1c71dd9a..c457629d7598 100644 --- a/scripts/lastcode-local-update.mjs +++ b/scripts/lastcode-local-update.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// LastCode managed helper: lastcode-local-update // The desktop app runs this file with Electron's bundled Node runtime. Keep it // dependency-free so an older LastCode build can inspect and build a newer @@ -251,73 +252,64 @@ export function prepareBuildWorktree(repoRoot, worktreePath, checkpointTag, logF if (status) throw new Error(`Dedicated local-update worktree is not clean:\n${status}`); } -function processIsAlive(pid) { +function readLockOwner(path) { try { - process.kill(pid, 0); - return true; - } catch (error) { - return error?.code === "EPERM"; + return JSON.parse(NodeFS.readFileSync(path, "utf8")); + } catch { + return undefined; } } -const BUILD_LOCK_INITIALIZATION_GRACE_MS = 5_000; - export function acquireBuildLock(updateRoot, options = {}) { const pid = options.pid ?? process.pid; - const isAlive = options.isAlive ?? processIsAlive; const lockPath = NodePath.join(updateRoot, "build.lock"); - const ownerPath = NodePath.join(lockPath, "owner.json"); const token = `${pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; NodeFS.mkdirSync(updateRoot, { recursive: true }); - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - NodeFS.mkdirSync(lockPath); - NodeFS.writeFileSync( - ownerPath, - `${JSON.stringify({ schemaVersion: 1, pid, token, startedAt: new Date().toISOString() })}\n`, - { encoding: "utf8", mode: 0o600 }, - ); - let released = false; - return () => { - if (released) return; - released = true; - const owner = JSON.parse(NodeFS.readFileSync(ownerPath, "utf8")); - if (owner.token !== token) { - throw new Error("Refusing to release a local build lock now owned by another process."); - } - NodeFS.unlinkSync(ownerPath); - NodeFS.rmdirSync(lockPath); - }; - } catch (error) { - if (error?.code !== "EEXIST" || attempt > 0) throw error; - let owner; - try { - owner = JSON.parse(NodeFS.readFileSync(ownerPath, "utf8")); - } catch { - const lockAgeMs = Date.now() - NodeFS.statSync(lockPath).mtimeMs; - if (lockAgeMs < BUILD_LOCK_INITIALIZATION_GRACE_MS) { - throw new Error( - `Another LastCode build is initializing ${lockPath}. Retry in a few seconds.`, - ); - } - } - if (Number.isSafeInteger(owner?.pid) && isAlive(owner.pid)) { - throw new Error( - `Another LastCode build is already running (PID ${owner.pid}, started ${owner.startedAt ?? "at an unknown time"}).`, - { cause: error }, - ); - } - const stalePath = `${lockPath}.stale-${pid}-${Date.now()}`; - try { - NodeFS.renameSync(lockPath, stalePath); - } catch { - throw new Error(`Another LastCode build acquired ${lockPath}.`); - } - NodeFS.rmSync(stalePath, { recursive: true, force: true }); + const descriptor = NodeFS.openSync(lockPath, "a+", 0o600); + const result = NodeChildProcess.spawnSync("/usr/bin/lockf", ["-s", "-t", "0", "3"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe", descriptor], + }); + if (result.error || result.status !== 0) { + const owner = readLockOwner(lockPath); + NodeFS.closeSync(descriptor); + if (result.error) throw result.error; + if (result.status !== 75) { + throw new Error(result.stderr.trim() || `lockf failed with exit code ${result.status}.`); } + throw new Error( + `Another LastCode build is already running (PID ${owner?.pid ?? "unknown"}, started ${owner?.startedAt ?? "at an unknown time"}).`, + ); } - throw new Error(`Could not acquire the LastCode build lock at ${lockPath}.`); + const owner = { schemaVersion: 1, pid, token, startedAt: new Date().toISOString() }; + try { + NodeFS.ftruncateSync(descriptor, 0); + NodeFS.writeSync(descriptor, `${JSON.stringify(owner)}\n`); + NodeFS.fsyncSync(descriptor); + } catch (error) { + NodeFS.closeSync(descriptor); + throw error; + } + const lockIdentity = NodeFS.fstatSync(descriptor); + let released = false; + return () => { + if (released) return; + const currentIdentity = NodeFS.statSync(lockPath, { throwIfNoEntry: false }); + const currentOwner = readLockOwner(lockPath); + if ( + currentIdentity?.dev !== lockIdentity.dev || + currentIdentity?.ino !== lockIdentity.ino || + currentOwner?.token !== token + ) { + NodeFS.closeSync(descriptor); + released = true; + throw new Error("Refusing to release a local build lock now owned by another process."); + } + NodeFS.ftruncateSync(descriptor, 0); + NodeFS.closeSync(descriptor); + released = true; + }; } function buildUnlocked(options, updateRoot) { diff --git a/scripts/lastcode-local-update.test.ts b/scripts/lastcode-local-update.test.ts index 2a6e5fb2f39c..c624ab7e3fda 100644 --- a/scripts/lastcode-local-update.test.ts +++ b/scripts/lastcode-local-update.test.ts @@ -20,7 +20,7 @@ import { } from "./lastcode-local-update.mjs"; describe("lastcode-local-update", () => { - it("serializes manual and in-app builds and recovers a stale lock", () => { + it("serializes manual and in-app builds and releases the kernel lock", () => { const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-build-lock-")); try { const release = acquireBuildLock(root); @@ -28,23 +28,9 @@ describe("lastcode-local-update", () => { release(); const lockPath = NodePath.join(root, "build.lock"); - NodeFS.mkdirSync(lockPath); - NodeFS.writeFileSync( - NodePath.join(lockPath, "owner.json"), - `${JSON.stringify({ schemaVersion: 1, pid: 12345, startedAt: "earlier" })}\n`, - ); - const releaseRecovered = acquireBuildLock(root, { isAlive: () => false }); - assert.isTrue(NodeFS.existsSync(NodePath.join(lockPath, "owner.json"))); - releaseRecovered(); - assert.isFalse(NodeFS.existsSync(lockPath)); - - NodeFS.mkdirSync(lockPath); - assert.throws(() => acquireBuildLock(root, { isAlive: () => false }), /initializing/); - NodeFS.utimesSync(lockPath, 0, 0); - const releaseOwnerless = acquireBuildLock(root, { isAlive: () => false }); - assert.isTrue(NodeFS.existsSync(NodePath.join(lockPath, "owner.json"))); - releaseOwnerless(); - assert.isFalse(NodeFS.existsSync(lockPath)); + assert.strictEqual(NodeFS.readFileSync(lockPath, "utf8"), ""); + const releaseAgain = acquireBuildLock(root); + releaseAgain(); } finally { NodeFS.rmSync(root, { recursive: true, force: true }); } From 2ae743882159fd056033008cb90dbeb98adc3551 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 19:10:16 -0700 Subject: [PATCH 23/25] fix(lastcode): preflight command installs --- scripts/lastcode-build.mjs | 34 +++++++++++++++++++-------- scripts/lastcode-build.test.mjs | 32 ++++++++++++++++++++++++++ scripts/lastcode-install.mjs | 38 ++++++++++++++++++++----------- scripts/lastcode-install.test.mjs | 26 +++++++++++++++++++++ 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/scripts/lastcode-build.mjs b/scripts/lastcode-build.mjs index f1ef2c16a656..d7e39b1cd388 100644 --- a/scripts/lastcode-build.mjs +++ b/scripts/lastcode-build.mjs @@ -310,23 +310,22 @@ function selectAutomationWorktree(repoRoot) { } function replaceManagedSymlink(exposed, target) { + assertManagedSymlink(exposed, target); const existing = NodeFS.lstatSync(exposed, { throwIfNoEntry: false }); if (existing) { - if (!existing.isSymbolicLink() || NodeFS.readlinkSync(exposed) !== target) { - throw new Error(`${exposed} already exists and is not managed by LastCode.`); - } NodeFS.unlinkSync(exposed); } NodeFS.symlinkSync(target, exposed); } -function installCommand(repoRoot, home) { - const automationWorktree = selectAutomationWorktree(repoRoot); - if (!automationWorktree) { - throw new Error( - "LastCode automation worktree is not installed. Run pnpm lastcode:checkpoint:service install first.", - ); +function assertManagedSymlink(exposed, target) { + const existing = NodeFS.lstatSync(exposed, { throwIfNoEntry: false }); + if (existing && (!existing.isSymbolicLink() || NodeFS.readlinkSync(exposed) !== target)) { + throw new Error(`${exposed} already exists and is not managed by LastCode.`); } +} + +export function installCommandAssets(automationWorktree, home) { const binDirectory = NodePath.join(home, ".lastcode", "bin"); const moduleTarget = NodePath.join(binDirectory, "lastcode-build.mjs"); const helperTarget = NodePath.join(binDirectory, "lastcode-local-update.mjs"); @@ -335,6 +334,11 @@ function installCommand(repoRoot, home) { const exposed = NodePath.join(exposedDirectory, "lastcode-build"); const configPath = NodePath.join(home, ".lastcode", "dashboard.json"); + assertManagedFile(moduleTarget, BUILD_MANAGED_MARKER); + assertManagedFile(helperTarget, UPDATE_HELPER_MANAGED_MARKER); + assertManagedFile(target, BUILD_MANAGED_MARKER); + assertManagedSymlink(exposed, target); + NodeFS.mkdirSync(binDirectory, { recursive: true }); NodeFS.mkdirSync(exposedDirectory, { recursive: true }); NodeFS.copyFileSync(NodeURL.fileURLToPath(import.meta.url), moduleTarget); @@ -358,10 +362,20 @@ function installCommand(repoRoot, home) { console.log(`Exposed on PATH as ${exposed}`); } +function installCommand(repoRoot, home) { + const automationWorktree = selectAutomationWorktree(repoRoot); + if (!automationWorktree) { + throw new Error( + "LastCode automation worktree is not installed. Run pnpm lastcode:checkpoint:service install first.", + ); + } + installCommandAssets(automationWorktree, home); +} + function assertManagedFile(path, marker) { const existing = NodeFS.lstatSync(path, { throwIfNoEntry: false }); if (existing && (!existing.isFile() || !NodeFS.readFileSync(path, "utf8").includes(marker))) { - throw new Error(`Refusing to remove ${path} because it is not a LastCode-managed file.`); + throw new Error(`Refusing to modify ${path} because it is not a LastCode-managed file.`); } } diff --git a/scripts/lastcode-build.test.mjs b/scripts/lastcode-build.test.mjs index 5714081a0719..9706832b2af3 100644 --- a/scripts/lastcode-build.test.mjs +++ b/scripts/lastcode-build.test.mjs @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; import { BUILD_PHASES, estimateBuildProgress, + installCommandAssets, parseBuildResult, parseOptions, renderProgressBar, @@ -88,6 +89,37 @@ describe("LastCode userland build command", () => { } }); + it("preflights every build-command destination before installing", () => { + for (const relativePath of [ + ".lastcode/bin/lastcode-build.mjs", + ".lastcode/bin/lastcode-local-update.mjs", + ".lastcode/bin/lastcode-build", + ".local/bin/lastcode-build", + ]) { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-build-install-")); + try { + const foreignPath = NodePath.join(home, relativePath); + NodeFS.mkdirSync(NodePath.dirname(foreignPath), { recursive: true }); + NodeFS.writeFileSync(foreignPath, "foreign content\n"); + + expect(() => installCommandAssets("/tmp/lastcode-automation", home)).toThrow( + /not (?:a LastCode-managed file|managed by LastCode)/, + ); + expect(NodeFS.readFileSync(foreignPath, "utf8")).toBe("foreign content\n"); + for (const candidate of [ + ".lastcode/bin/lastcode-build.mjs", + ".lastcode/bin/lastcode-local-update.mjs", + ".lastcode/bin/lastcode-build", + ]) { + const candidatePath = NodePath.join(home, candidate); + if (candidatePath !== foreignPath) expect(NodeFS.existsSync(candidatePath)).toBe(false); + } + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } + } + }); + it("selects the newest checkpoint by default", () => { expect(resolveCheckpointTag(tags)).toBe("lastcode/checkpoint/v0.0.34-nightly.20260814.1095"); }); diff --git a/scripts/lastcode-install.mjs b/scripts/lastcode-install.mjs index dcd3b2d20eec..c3c1a9c312b4 100644 --- a/scripts/lastcode-install.mjs +++ b/scripts/lastcode-install.mjs @@ -319,22 +319,42 @@ async function installDmg(dmgPath, targetPath = DEFAULT_APP_PATH) { } function replaceManagedSymlink(exposed, target) { + assertManagedSymlink(exposed, target); const existing = NodeFS.lstatSync(exposed, { throwIfNoEntry: false }); if (existing) { - if (!existing.isSymbolicLink() || NodeFS.readlinkSync(exposed) !== target) { - throw new Error(`${exposed} already exists and is not managed by LastCode.`); - } NodeFS.unlinkSync(exposed); } NodeFS.symlinkSync(target, exposed); } -function installCommand(home) { +function assertManagedSymlink(exposed, target) { + const existing = NodeFS.lstatSync(exposed, { throwIfNoEntry: false }); + if (existing && (!existing.isSymbolicLink() || NodeFS.readlinkSync(exposed) !== target)) { + throw new Error(`${exposed} already exists and is not managed by LastCode.`); + } +} + +function assertManagedInstallerFile(path) { + const existing = NodeFS.lstatSync(path, { throwIfNoEntry: false }); + if ( + existing && + (!existing.isFile() || !NodeFS.readFileSync(path, "utf8").includes(INSTALL_MANAGED_MARKER)) + ) { + throw new Error(`Refusing to modify ${path} because it is not a LastCode-managed file.`); + } +} + +export function installCommand(home) { const binDirectory = NodePath.join(home, ".lastcode", "bin"); const moduleTarget = NodePath.join(binDirectory, "lastcode-install.mjs"); const target = NodePath.join(binDirectory, "lastcode-install"); const exposedDirectory = NodePath.join(home, ".local", "bin"); const exposed = NodePath.join(exposedDirectory, "lastcode-install"); + + assertManagedInstallerFile(moduleTarget); + assertManagedInstallerFile(target); + assertManagedSymlink(exposed, target); + NodeFS.mkdirSync(binDirectory, { recursive: true }); NodeFS.mkdirSync(exposedDirectory, { recursive: true }); NodeFS.copyFileSync(NodeURL.fileURLToPath(import.meta.url), moduleTarget); @@ -354,15 +374,7 @@ export function uninstallCommand(home) { if (exposedEntry && (!exposedEntry.isSymbolicLink() || NodeFS.readlinkSync(exposed) !== target)) { throw new Error(`Refusing to remove ${exposed} because it is not managed by LastCode.`); } - for (const path of [moduleTarget, target]) { - const existing = NodeFS.lstatSync(path, { throwIfNoEntry: false }); - if ( - existing && - (!existing.isFile() || !NodeFS.readFileSync(path, "utf8").includes(INSTALL_MANAGED_MARKER)) - ) { - throw new Error(`Refusing to remove ${path} because it is not a LastCode-managed file.`); - } - } + for (const path of [moduleTarget, target]) assertManagedInstallerFile(path); if (exposedEntry) NodeFS.unlinkSync(exposed); for (const path of [moduleTarget, target]) NodeFS.rmSync(path, { force: true }); diff --git a/scripts/lastcode-install.test.mjs b/scripts/lastcode-install.test.mjs index 2477be0c57b4..15da869eaa9c 100644 --- a/scripts/lastcode-install.test.mjs +++ b/scripts/lastcode-install.test.mjs @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; import { acquireInstallLock, discoverDmgs, + installCommand, parseDmgChoice, parseOptions, renderDmgChoices, @@ -140,4 +141,29 @@ describe("LastCode userland install command", () => { expect(NodeFS.existsSync(target)).toBe(true); expect(NodeFS.existsSync(exposed)).toBe(true); }); + + it("preflights every installer-command destination before installing", () => { + for (const relativePath of [ + ".lastcode/bin/lastcode-install.mjs", + ".lastcode/bin/lastcode-install", + ".local/bin/lastcode-install", + ]) { + const home = temporaryDirectory(); + const foreignPath = NodePath.join(home, relativePath); + NodeFS.mkdirSync(NodePath.dirname(foreignPath), { recursive: true }); + NodeFS.writeFileSync(foreignPath, "foreign content\n"); + + expect(() => installCommand(home)).toThrow( + /not (?:a LastCode-managed file|managed by LastCode)/, + ); + expect(NodeFS.readFileSync(foreignPath, "utf8")).toBe("foreign content\n"); + for (const candidate of [ + ".lastcode/bin/lastcode-install.mjs", + ".lastcode/bin/lastcode-install", + ]) { + const candidatePath = NodePath.join(home, candidate); + if (candidatePath !== foreignPath) expect(NodeFS.existsSync(candidatePath)).toBe(false); + } + } + }); }); From 1a16bc172fbead146ff750775344bb7396c33321 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 19:22:13 -0700 Subject: [PATCH 24/25] fix(lastcode): record checkpoint failure phases --- scripts/lastcode-checkpoint-history.test.ts | 2 ++ scripts/lastcode-checkpoint-history.ts | 3 +++ scripts/lastcode-checkpoint.ts | 7 +++++++ scripts/lastcode-checkpoints.mjs | 10 +++++++++- scripts/lastcode-checkpoints.test.mjs | 13 +++++++++++++ 5 files changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/lastcode-checkpoint-history.test.ts b/scripts/lastcode-checkpoint-history.test.ts index 747a851a1181..10a63bbb9c02 100644 --- a/scripts/lastcode-checkpoint-history.test.ts +++ b/scripts/lastcode-checkpoint-history.test.ts @@ -48,6 +48,7 @@ describe("checkpoint run history", () => { { commitsRebased: 3, error: new Error("push failed"), + failurePhase: "publication", localTagRetained: true, startedAtMs: 1_000, upstreamTag: "v0.0.1-nightly.20260812.1", @@ -63,6 +64,7 @@ describe("checkpoint run history", () => { durationMs: 3_000, commitsRebased: 3, error: "push failed", + failurePhase: "publication", localTagRetained: true, }); }); diff --git a/scripts/lastcode-checkpoint-history.ts b/scripts/lastcode-checkpoint-history.ts index 96608fb828b2..6beef765c7a6 100644 --- a/scripts/lastcode-checkpoint-history.ts +++ b/scripts/lastcode-checkpoint-history.ts @@ -14,6 +14,7 @@ export interface CheckpointRunRecord { readonly checkpointCommit?: string; readonly checkpointTag?: string; readonly error?: string; + readonly failurePhase?: "publication" | "rebase" | "smoke"; readonly localTagRetained?: boolean; readonly recoveryBranch?: string; } @@ -22,6 +23,7 @@ export function checkpointFailureRecord( input: { readonly commitsRebased: number; readonly error: unknown; + readonly failurePhase?: "publication" | "rebase" | "smoke"; readonly localTagRetained?: boolean; readonly recoveryBranch?: string; readonly startedAtMs: number; @@ -38,6 +40,7 @@ export function checkpointFailureRecord( durationMs: finishedAtMs - input.startedAtMs, commitsRebased: input.commitsRebased, error: input.error instanceof Error ? input.error.message : String(input.error), + ...(input.failurePhase ? { failurePhase: input.failurePhase } : {}), ...(input.localTagRetained ? { localTagRetained: true } : {}), ...(input.recoveryBranch ? { recoveryBranch: input.recoveryBranch } : {}), }; diff --git a/scripts/lastcode-checkpoint.ts b/scripts/lastcode-checkpoint.ts index 16cdceddde49..30f1c33f64db 100644 --- a/scripts/lastcode-checkpoint.ts +++ b/scripts/lastcode-checkpoint.ts @@ -764,6 +764,7 @@ function main(argv: ReadonlyArray): void { { commitsRebased, error, + failurePhase: "publication", ...(localTagRetained ? { localTagRetained: true } : {}), startedAtMs, upstreamTag: plan.baseNightly.tag, @@ -805,6 +806,7 @@ function main(argv: ReadonlyArray): void { readonly startedAtMs: number; } | undefined; + let failurePhase: "publication" | "rebase" | "smoke" | undefined; try { let baseTag = plan.baseNightly.tag; for (const nightly of plan.missingNightlies) { @@ -821,9 +823,12 @@ function main(argv: ReadonlyArray): void { startedAtMs: Date.now(), }; console.log(`[lastcode:checkpoint] Rebasing LastCode from ${baseTag} onto ${nightly.tag}...`); + failurePhase = "rebase"; rebaseOnto(worktree, nightly.tag, baseTag); candidateCommit = git(repoRoot, ["rev-parse", "HEAD"], { cwd: worktree }); + failurePhase = "smoke"; if (options.smoke) runSmokeGate(repoRoot, worktree); + failurePhase = "publication"; const finishedAtMs = Date.now(); const timing = { commitsRebased: attempt.commitsRebased, @@ -869,6 +874,7 @@ function main(argv: ReadonlyArray): void { baseTag = nightly.tag; candidateRef = checkpointTag; attempt = undefined; + failurePhase = undefined; console.log(`[lastcode:checkpoint] Created ${checkpointTag} at ${candidateCommit}.`); } completed = true; @@ -885,6 +891,7 @@ function main(argv: ReadonlyArray): void { { commitsRebased: attempt.commitsRebased, error, + ...(failurePhase ? { failurePhase } : {}), ...(!tagDeleted ? { localTagRetained: true } : {}), ...(disposition.recoveryBranch ? { recoveryBranch: disposition.recoveryBranch } : {}), startedAtMs: attempt.startedAtMs, diff --git a/scripts/lastcode-checkpoints.mjs b/scripts/lastcode-checkpoints.mjs index efc8ac9fbea8..6c625a024338 100644 --- a/scripts/lastcode-checkpoints.mjs +++ b/scripts/lastcode-checkpoints.mjs @@ -174,6 +174,13 @@ export function failureDetailLines(rows, verbose) { }); } +export function failureWasDuringRebase(record) { + if (record?.failurePhase !== undefined) return record.failurePhase === "rebase"; + return ( + typeof record?.error === "string" && /^git(?:\s+-c\s+\S+)*\s+rebase(?:\s|$)/.test(record.error) + ); +} + export function checkpointTagsWithoutUnpublishedFailures(tags, publishedTags, records) { const published = new Set(publishedTags); const latestRuns = latestRunsByUpstreamTag(records); @@ -430,6 +437,7 @@ function checkpointRows(repoRoot, home, count, remoteState) { main: "—", build: "—", error: record.error, + failurePhase: record.failurePhase, recoveryBranch: record.recoveryBranch, }), ); @@ -520,7 +528,7 @@ function printDashboard(repoRoot, home, count, verbose) { automationWorktree, recoveryBranch: recoveryFailure?.recoveryBranch, isRebaseInProgress: rebaseInProgress(recoveryWorktree), - failedDuringRebase: recoveryFailure?.error?.includes("git rebase") ?? false, + failedDuringRebase: failureWasDuringRebase(recoveryFailure), })) { console.log(style(ansi.lavender, line)); } diff --git a/scripts/lastcode-checkpoints.test.mjs b/scripts/lastcode-checkpoints.test.mjs index 0d0940f94bab..d6f76bc3bd83 100644 --- a/scripts/lastcode-checkpoints.test.mjs +++ b/scripts/lastcode-checkpoints.test.mjs @@ -4,6 +4,7 @@ import { checkpointTagsWithoutUnpublishedFailures, checkpointFreshness, failureDetailLines, + failureWasDuringRebase, failedRunsWithoutPublishedTags, formatDuration, parseOptions, @@ -118,6 +119,18 @@ describe("LastCode checkpoint dashboard", () => { ); }); + it("uses explicit failure phases and recognizes historical rebase commands", () => { + expect(failureWasDuringRebase({ failurePhase: "rebase", error: "anything" })).toBe(true); + expect(failureWasDuringRebase({ failurePhase: "smoke", error: "git rebase failed" })).toBe( + false, + ); + expect( + failureWasDuringRebase({ + error: "git -c core.editor=true rebase --continue failed with exit code 1.", + }), + ).toBe(true); + }); + it("lets a published checkpoint tag reconcile an ambiguous failed push record", () => { const publishedTag = "lastcode/checkpoint/v0.0.1-nightly.20260812.2"; const failedRecord = { From 011c47c1df3b7f89abfdf60374e5a094b4a3346d Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 15 Aug 2026 19:33:24 -0700 Subject: [PATCH 25/25] test(lastcode): gate macOS file locks --- scripts/lastcode-install.mjs | 7 +++++++ scripts/lastcode-install.test.mjs | 5 ++++- scripts/lastcode-local-update.mjs | 7 +++++++ scripts/lastcode-local-update.test.ts | 5 ++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/lastcode-install.mjs b/scripts/lastcode-install.mjs index c3c1a9c312b4..52975352a271 100644 --- a/scripts/lastcode-install.mjs +++ b/scripts/lastcode-install.mjs @@ -200,12 +200,19 @@ function readLockOwner(path) { } export function acquireInstallLock(lockDirectory, options = {}) { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone installed script has no Effect runtime. + if (process.platform !== "darwin") { + throw new Error("LastCode install locking is only available on macOS."); + } const pid = options.pid ?? process.pid; const lockPath = NodePath.join(lockDirectory, INSTALL_LOCK_NAME); const token = `${pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; NodeFS.mkdirSync(lockDirectory, { recursive: true }); const descriptor = NodeFS.openSync(lockPath, "a+", 0o600); + // macOS lockf's fd form locks inherited child fd 3. The BSD lock remains on + // the shared open-file description held by this parent descriptor after + // lockf exits, and the kernel releases it if this process dies. const result = NodeChildProcess.spawnSync("/usr/bin/lockf", ["-s", "-t", "0", "3"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe", descriptor], diff --git a/scripts/lastcode-install.test.mjs b/scripts/lastcode-install.test.mjs index 15da869eaa9c..e003910025c5 100644 --- a/scripts/lastcode-install.test.mjs +++ b/scripts/lastcode-install.test.mjs @@ -30,6 +30,9 @@ function temporaryDirectory() { return directory; } +// oxlint-disable-next-line t3code/no-global-process-runtime -- This integration test exercises a macOS-only kernel lock. +const itMacOnly = process.platform === "darwin" ? it : it.skip; + describe("LastCode userland install command", () => { it("parses an optional DMG or artifacts directory", () => { expect(parseOptions([])).toMatchObject({ dmgPath: undefined, install: false }); @@ -99,7 +102,7 @@ describe("LastCode userland install command", () => { ); }); - it("serializes installers and releases the kernel lock", () => { + itMacOnly("serializes installers and releases the kernel lock", () => { const root = temporaryDirectory(); const release = acquireInstallLock(root); expect(() => acquireInstallLock(root)).toThrow("already running"); diff --git a/scripts/lastcode-local-update.mjs b/scripts/lastcode-local-update.mjs index c457629d7598..87c8ac187595 100644 --- a/scripts/lastcode-local-update.mjs +++ b/scripts/lastcode-local-update.mjs @@ -261,12 +261,19 @@ function readLockOwner(path) { } export function acquireBuildLock(updateRoot, options = {}) { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Dependency-free desktop helper has no Effect runtime. + if (process.platform !== "darwin") { + throw new Error("Local LastCode build locking is only available on macOS."); + } const pid = options.pid ?? process.pid; const lockPath = NodePath.join(updateRoot, "build.lock"); const token = `${pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; NodeFS.mkdirSync(updateRoot, { recursive: true }); const descriptor = NodeFS.openSync(lockPath, "a+", 0o600); + // macOS lockf's fd form locks inherited child fd 3. The BSD lock remains on + // the shared open-file description held by this parent descriptor after + // lockf exits, and the kernel releases it if this process dies. const result = NodeChildProcess.spawnSync("/usr/bin/lockf", ["-s", "-t", "0", "3"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe", descriptor], diff --git a/scripts/lastcode-local-update.test.ts b/scripts/lastcode-local-update.test.ts index c624ab7e3fda..d4695a2b7eb5 100644 --- a/scripts/lastcode-local-update.test.ts +++ b/scripts/lastcode-local-update.test.ts @@ -19,8 +19,11 @@ import { resolveLocalBuildEnvironment, } from "./lastcode-local-update.mjs"; +// oxlint-disable-next-line t3code/no-global-process-runtime -- This integration test exercises a macOS-only kernel lock. +const itMacOnly = process.platform === "darwin" ? it : it.skip; + describe("lastcode-local-update", () => { - it("serializes manual and in-app builds and releases the kernel lock", () => { + itMacOnly("serializes manual and in-app builds and releases the kernel lock", () => { const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-build-lock-")); try { const release = acquireBuildLock(root);