diff --git a/docs/lastcode/release.md b/docs/lastcode/release.md index 51ced7c3ea89..2b3e3383cab3 100644 --- a/docs/lastcode/release.md +++ b/docs/lastcode/release.md @@ -184,6 +184,37 @@ ceiling used to keep htulo from moving ahead of airy. The SSH read is non-interactive and requires key-based access; it never opens a password prompt. +### Daily htulo updates + +Htulo's external updater can be installed as a login LaunchAgent: + +```bash +pnpm lastcode:daily-update install +pnpm lastcode:daily-update status +pnpm lastcode:daily-update run-now +``` + +At 04:00 each day it prepares and validates the newest eligible Intel app before +interrupting work. If an update is ready, it uses htulo's local +`lastcode-thread` command to ask every working thread to pause, waits for the +explicit `PAUSED FOR LASTCODE UPDATE` replies, checks once for a newly started +thread, quits LastCode once, swaps the app, launches it once, and tells the +paused threads to resume. A missing reply leaves the prepared update in place +for the next run and does not quit LastCode. Threads that may already have +paused are always queued for a resume; undelivered resumes are kept in +`~/.lastcode/daily-update/pending-resumes.json` and retried before the next +daily update check. + +The first update from a LastCode version older than the thread command is a +manual bootstrap after the user has paused work: + +```bash +pnpm lastcode:daily-update run --bootstrap +``` + +`--bootstrap` is never present in the scheduled LaunchAgent. Remove the schedule +with `pnpm lastcode:daily-update uninstall`. + State defaults to `~/.lastcode/intel-updates`. `pending.json` is the narrow, credential-free handoff contract for later drain and activation work. It names the immutable tag and commit, expected version and DMG hash, and one candidate diff --git a/package.json b/package.json index 9769ae5564aa..704456074cc5 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "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:intel-stage": "mise exec node@24.13.1 -- node scripts/lastcode-intel-stage.mjs", + "lastcode:daily-update": "mise exec node@24.13.1 -- node scripts/lastcode-daily-update.mjs", "lastcode:setup": "mise exec node@24.13.1 -- node scripts/lastcode-setup.mjs", "lastcode:checkpoint:service": "mise exec node@24.13.1 -- node scripts/lastcode-nightly-service.ts", "lastcode:build:mac:arm64": "mise exec node@24.13.1 -- node scripts/lastcode-build-mac.ts --arch arm64", diff --git a/scripts/lastcode-daily-update.mjs b/scripts/lastcode-daily-update.mjs new file mode 100644 index 000000000000..75a537acef3a --- /dev/null +++ b/scripts/lastcode-daily-update.mjs @@ -0,0 +1,369 @@ +#!/usr/bin/env node + +// LastCode-only daily updater for the trusted airy + htulo setup. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeTimersPromises from "node:timers/promises"; +import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; + +import { + cleanupPreparedInstall, + launchApp, + prepareDmgInstall, + quitApp, + replacePreparedApp, +} from "./lastcode-install.mjs"; +import { stageIntelUpdate } from "./lastcode-intel-stage.mjs"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); +const LABEL = "codes.lastobelus.lastcode-daily-update"; +const PAUSE_ACKNOWLEDGEMENT = "PAUSED FOR LASTCODE UPDATE"; +const PAUSE_MESSAGE = + `Pause safely for the prepared LastCode update. Finish your current operation, ` + + `reply with a line containing exactly '${PAUSE_ACKNOWLEDGEMENT}', then stop this turn.`; +const RESUME_MESSAGE = + "LastCode has been updated and restarted. Resume from your paused checkpoint."; +const COPIED_MODULES = [ + "lastcode-daily-update.mjs", + "lastcode-install.mjs", + "lastcode-intel-release.mjs", + "lastcode-intel-stage.mjs", + "lastcode-lock.mjs", +]; + +function fail(message) { + throw new Error(message); +} + +function xml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function run(command, args, options = {}) { + const result = NodeChildProcess.spawnSync(command, args, { + encoding: "utf8", + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0 && !options.allowFailure) { + throw new Error( + result.stderr?.trim() || `${command} ${args.join(" ")} failed with ${result.status}.`, + ); + } + return result; +} + +function parseJson(raw, label) { + try { + return JSON.parse(raw); + } catch (error) { + fail(`Could not parse ${label}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export function parseDailyUpdateOptions(argv) { + const command = argv[0]; + if (!command || !["install", "run", "run-now", "status", "uninstall"].includes(command)) { + fail("Usage: lastcode:daily-update [--bootstrap]"); + } + const bootstrap = argv.slice(1).includes("--bootstrap"); + if (argv.slice(1).some((arg) => arg !== "--bootstrap") || (bootstrap && command !== "run")) { + fail("--bootstrap is accepted only by the run command."); + } + return { bootstrap, command }; +} + +export function renderDailyUpdatePlist({ logDirectory, modulePath, nodePath }) { + return ` + + + + Label + ${LABEL} + ProgramArguments + + ${xml(nodePath)} + ${xml(modulePath)} + run + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + StartCalendarInterval + + Hour + 4 + Minute + 0 + + ProcessType + Background + StandardOutPath + ${xml(NodePath.join(logDirectory, "daily-update.stdout.log"))} + StandardErrorPath + ${xml(NodePath.join(logDirectory, "daily-update.stderr.log"))} + + +`; +} + +function workingThreads(list) { + if (list?.kind !== "list" || !Array.isArray(list.threads)) fail("Thread list is invalid."); + return list.threads.filter((thread) => thread.lifecycle === "working"); +} + +function hasPauseAcknowledgement(result) { + return ( + result?.kind === "completed" && + result.response.split(/\r?\n/u).some((line) => line.trim() === PAUSE_ACKNOWLEDGEMENT) + ); +} + +async function pauseBatch(threads, paused, dependencies) { + for (const thread of threads) paused.set(thread.threadId, thread); + dependencies.savePendingResumes([...paused.values()]); + const results = await Promise.allSettled( + threads.map(async (thread) => { + const result = await dependencies.sendAndWait(thread.threadId, PAUSE_MESSAGE); + if (!hasPauseAcknowledgement(result)) { + if (result?.kind === "completed") paused.delete(thread.threadId); + fail(`Thread '${thread.title}' did not confirm that it paused.`); + } + }), + ); + dependencies.savePendingResumes([...paused.values()]); + const failure = results.find((result) => result.status === "rejected"); + if (failure) throw failure.reason; +} + +async function resumePaused(paused, dependencies) { + const results = await Promise.allSettled( + [...paused.values()].map(async (thread) => { + try { + await dependencies.resumeThread(thread.threadId, RESUME_MESSAGE); + } catch (error) { + if (!dependencies.isMissingThreadError(error, thread.threadId)) throw error; + } + paused.delete(thread.threadId); + }), + ); + dependencies.savePendingResumes([...paused.values()]); + const failure = results.find((result) => result.status === "rejected"); + if (failure) throw failure.reason; +} + +export async function runDailyUpdate(options = {}, dependencies) { + const paused = new Map( + dependencies.loadPendingResumes().map((thread) => [thread.threadId, thread]), + ); + if (paused.size > 0) await resumePaused(paused, dependencies); + + const staged = await dependencies.stageUpdate({ maximumVersionHost: "airy" }); + if (!staged.pending) return { status: "up-to-date" }; + + const prepared = await dependencies.prepareInstall(staged.pending.dmgPath, { + expectedSha256: staged.pending.dmgSha256, + expectedVersion: staged.pending.version, + }); + try { + if (!options.bootstrap) { + const first = workingThreads(await dependencies.listThreads()); + await pauseBatch(first, paused, dependencies); + + const newcomers = workingThreads(await dependencies.listThreads()).filter( + (thread) => !paused.has(thread.threadId), + ); + await pauseBatch(newcomers, paused, dependencies); + } + + let updateError; + try { + await dependencies.quitApp(); + await dependencies.replaceApp(prepared); + } catch (error) { + updateError = error; + } + + if (updateError) throw updateError; + return { status: "updated", version: staged.pending.version }; + } finally { + try { + if (paused.size > 0) await resumePaused(paused, dependencies); + } finally { + dependencies.cleanupInstall(prepared); + } + } +} + +async function threadCommand(threadTool, args) { + const { stdout } = await execFile(threadTool, [...args, "--json"], { + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + return parseJson(stdout, `lastcode-thread ${args[0]} output`); +} + +async function resumeThread(threadTool, threadId, message) { + let lastError; + for (let attempt = 0; attempt < 15; attempt += 1) { + try { + return await threadCommand(threadTool, ["send", threadId, "--message", message]); + } catch (error) { + lastError = error; + if (attempt < 14) await NodeTimersPromises.setTimeout(2_000); + } + } + throw lastError; +} + +export function isMissingThreadError(error, threadId) { + return ( + error !== null && + typeof error === "object" && + "stderr" in error && + typeof error.stderr === "string" && + error.stderr.includes(`LastCode thread '${threadId}' was not found.`) + ); +} + +function defaultDependencies(home) { + const threadTool = NodePath.join(home, ".lastcode", "userdata", "bin", "lastcode-thread"); + const pendingResumesPath = NodePath.join( + home, + ".lastcode", + "daily-update", + "pending-resumes.json", + ); + return { + cleanupInstall: cleanupPreparedInstall, + isMissingThreadError, + listThreads: () => threadCommand(threadTool, ["list"]), + loadPendingResumes: () => { + try { + const pending = parseJson( + NodeFS.readFileSync(pendingResumesPath, "utf8"), + "pending resume queue", + ); + if (!Array.isArray(pending)) fail("Pending resume queue is invalid."); + return pending; + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") return []; + throw error; + } + }, + prepareInstall: prepareDmgInstall, + quitApp, + replaceApp: (prepared) => + replacePreparedApp(prepared, { + launchApp: (appPath) => launchApp(appPath, { maxLaunchAttempts: 1 }), + }), + resumeThread: (threadId, message) => resumeThread(threadTool, threadId, message), + savePendingResumes: (pending) => { + NodeFS.mkdirSync(NodePath.dirname(pendingResumesPath), { recursive: true, mode: 0o700 }); + const temporaryPath = `${pendingResumesPath}.tmp`; + NodeFS.writeFileSync(temporaryPath, `${JSON.stringify(pending)}\n`, { mode: 0o600 }); + NodeFS.renameSync(temporaryPath, pendingResumesPath); + }, + sendAndWait: (threadId, message) => + threadCommand(threadTool, [ + "send", + threadId, + "--message", + message, + "--wait", + "--timeout", + "10 minutes", + ]), + stageUpdate: (stageOptions) => stageIntelUpdate(stageOptions), + }; +} + +function servicePaths(home) { + const rootDirectory = NodePath.join(home, ".lastcode", "daily-update"); + const moduleDirectory = NodePath.join(rootDirectory, "bin"); + return { + logDirectory: NodePath.join(rootDirectory, "logs"), + moduleDirectory, + modulePath: NodePath.join(moduleDirectory, "lastcode-daily-update.mjs"), + plistPath: NodePath.join(home, "Library", "LaunchAgents", `${LABEL}.plist`), + }; +} + +export function installDailyUpdateService(options = {}) { + const home = options.home ?? NodeOS.homedir(); + const sourceDirectory = + options.sourceDirectory ?? NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); + const nodePath = options.nodePath ?? process.execPath; + const paths = servicePaths(home); + NodeFS.mkdirSync(paths.moduleDirectory, { recursive: true, mode: 0o700 }); + NodeFS.mkdirSync(paths.logDirectory, { recursive: true, mode: 0o700 }); + NodeFS.mkdirSync(NodePath.dirname(paths.plistPath), { recursive: true }); + for (const name of COPIED_MODULES) { + NodeFS.copyFileSync( + NodePath.join(sourceDirectory, name), + NodePath.join(paths.moduleDirectory, name), + ); + } + NodeFS.writeFileSync( + paths.plistPath, + renderDailyUpdatePlist({ + logDirectory: paths.logDirectory, + modulePath: paths.modulePath, + nodePath, + }), + ); + const uid = process.getuid?.(); + if (!Number.isSafeInteger(uid)) fail("Could not determine the current user ID."); + const domain = `gui/${uid}`; + const service = `${domain}/${LABEL}`; + const runCommand = options.runCommand ?? run; + runCommand("plutil", ["-lint", paths.plistPath]); + runCommand("launchctl", ["bootout", service], { allowFailure: true }); + runCommand("launchctl", ["bootstrap", domain, paths.plistPath]); + return { ...paths, service }; +} + +async function main(argv) { + const options = parseDailyUpdateOptions(argv); + const home = NodeOS.homedir(); + const uid = process.getuid?.(); + if (!Number.isSafeInteger(uid)) fail("Could not determine the current user ID."); + const service = `gui/${uid}/${LABEL}`; + if (options.command === "run") { + const result = await runDailyUpdate(options, defaultDependencies(home)); + console.log(JSON.stringify(result)); + return; + } + if (options.command === "install") { + const installed = installDailyUpdateService({ home }); + console.log(`Installed ${installed.service}; it runs daily at 04:00.`); + return; + } + if (options.command === "uninstall") { + run("launchctl", ["bootout", service], { allowFailure: true }); + const paths = servicePaths(home); + NodeFS.rmSync(paths.plistPath, { force: true }); + NodeFS.rmSync(paths.moduleDirectory, { force: true, recursive: true }); + return; + } + run("launchctl", [options.command === "status" ? "print" : "kickstart", service]); +} + +if (import.meta.main) { + main(process.argv.slice(2)).catch((error) => { + console.error( + `[lastcode:daily-update] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/lastcode-daily-update.test.mjs b/scripts/lastcode-daily-update.test.mjs new file mode 100644 index 000000000000..f7668da8c23f --- /dev/null +++ b/scripts/lastcode-daily-update.test.mjs @@ -0,0 +1,253 @@ +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + installDailyUpdateService, + isMissingThreadError, + parseDailyUpdateOptions, + renderDailyUpdatePlist, + runDailyUpdate, +} from "./lastcode-daily-update.mjs"; + +const temporaryDirectories = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + NodeFS.rmSync(directory, { force: true, recursive: true }); + } +}); + +function temporaryDirectory() { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-daily-update-")); + temporaryDirectories.push(directory); + return directory; +} + +function fixture(overrides = {}) { + const calls = []; + let pendingResumes = []; + const working = (threadId, title = threadId) => ({ lifecycle: "working", threadId, title }); + return { + calls, + dependencies: { + cleanupInstall: () => calls.push("cleanup"), + isMissingThreadError: () => false, + listThreads: async () => ({ kind: "list", threads: [] }), + loadPendingResumes: () => pendingResumes, + prepareInstall: async () => { + calls.push("prepare"); + return { prepared: true }; + }, + quitApp: async () => calls.push("quit"), + replaceApp: async () => calls.push("replace"), + resumeThread: async (threadId) => calls.push(`resume:${threadId}`), + savePendingResumes: (pending) => { + pendingResumes = pending; + }, + sendAndWait: async (threadId) => { + calls.push(`pause:${threadId}`); + return { kind: "completed", response: `Ready\nPAUSED FOR LASTCODE UPDATE` }; + }, + stageUpdate: async () => { + calls.push("stage"); + return { + pending: { + dmgPath: "/tmp/LastCode.dmg", + dmgSha256: "a".repeat(64), + version: "1.2.3-nightly.20260824.1", + }, + }; + }, + ...overrides, + }, + working, + }; +} + +describe("LastCode daily updater", () => { + it("prepares before pausing, checks once for newcomers, and swaps once", async () => { + const test = fixture(); + let listing = 0; + test.dependencies.listThreads = async () => { + test.calls.push(`list:${listing}`); + listing += 1; + return { + kind: "list", + threads: + listing === 1 + ? [test.working("one"), { lifecycle: "active", threadId: "idle", title: "idle" }] + : [test.working("one"), test.working("two")], + }; + }; + + await expect(runDailyUpdate({}, test.dependencies)).resolves.toEqual({ + status: "updated", + version: "1.2.3-nightly.20260824.1", + }); + expect(test.calls).toEqual([ + "stage", + "prepare", + "list:0", + "pause:one", + "list:1", + "pause:two", + "quit", + "replace", + "resume:one", + "resume:two", + "cleanup", + ]); + }); + + it("does not quit when a working thread does not confirm it paused", async () => { + let send = 0; + const test = fixture({ + listThreads: async () => ({ + kind: "list", + threads: [ + { lifecycle: "working", threadId: "one", title: "one" }, + { lifecycle: "working", threadId: "two", title: "two" }, + ], + }), + sendAndWait: async () => { + send += 1; + return { + kind: "completed", + response: send === 1 ? "PAUSED FOR LASTCODE UPDATE" : "Not ready", + }; + }, + }); + + await expect(runDailyUpdate({}, test.dependencies)).rejects.toThrow("did not confirm"); + expect(test.calls).toEqual(["stage", "prepare", "resume:one", "cleanup"]); + }); + + it("does nothing when no eligible update is pending", async () => { + const test = fixture({ stageUpdate: async () => ({ status: "up-to-date" }) }); + await expect(runDailyUpdate({}, test.dependencies)).resolves.toEqual({ + status: "up-to-date", + }); + expect(test.calls).toEqual([]); + }); + + it("resumes an indeterminate timed-out pause request before aborting", async () => { + const test = fixture({ + listThreads: async () => ({ + kind: "list", + threads: [test.working("one")], + }), + sendAndWait: async () => ({ kind: "timed-out", waitHandle: { requestId: "request" } }), + }); + + await expect(runDailyUpdate({}, test.dependencies)).rejects.toThrow("did not confirm"); + expect(test.calls).toEqual(["stage", "prepare", "resume:one", "cleanup"]); + }); + + it("retries a durable resume before an up-to-date early return", async () => { + const test = fixture({ stageUpdate: async () => ({ status: "up-to-date" }) }); + test.dependencies.loadPendingResumes = () => [test.working("one")]; + + await expect(runDailyUpdate({}, test.dependencies)).resolves.toEqual({ + status: "up-to-date", + }); + expect(test.calls).toEqual(["resume:one"]); + }); + + it("keeps a failed post-update resume for the next daily run", async () => { + const test = fixture(); + let listing = 0; + let resumeFails = true; + test.dependencies.listThreads = async () => ({ + kind: "list", + threads: listing++ === 0 ? [test.working("one")] : [], + }); + test.dependencies.resumeThread = async (threadId) => { + test.calls.push(`resume:${threadId}`); + if (resumeFails) throw new Error("server still starting"); + }; + + await expect(runDailyUpdate({}, test.dependencies)).rejects.toThrow("server still starting"); + + resumeFails = false; + test.dependencies.stageUpdate = async () => ({ status: "up-to-date" }); + await expect(runDailyUpdate({}, test.dependencies)).resolves.toEqual({ + status: "up-to-date", + }); + expect(test.calls.filter((call) => call === "resume:one")).toHaveLength(2); + }); + + it("discards a queued resume when the thread no longer exists", async () => { + const test = fixture({ stageUpdate: async () => ({ status: "up-to-date" }) }); + test.dependencies.loadPendingResumes = () => [test.working("gone")]; + test.dependencies.resumeThread = async () => { + throw Object.assign(new Error("missing"), { + stderr: "LastCode thread 'gone' was not found.", + }); + }; + test.dependencies.isMissingThreadError = isMissingThreadError; + + await expect(runDailyUpdate({}, test.dependencies)).resolves.toEqual({ + status: "up-to-date", + }); + }); + + it("supports one explicit bootstrap without installing it into the schedule", async () => { + expect(parseDailyUpdateOptions(["run", "--bootstrap"])).toEqual({ + bootstrap: true, + command: "run", + }); + expect(() => parseDailyUpdateOptions(["install", "--bootstrap"])).toThrow( + "accepted only by the run command", + ); + const plist = renderDailyUpdatePlist({ + logDirectory: "/Users/me/Logs & More", + modulePath: "/Users/me/lastcode-daily-update.mjs", + nodePath: "/Users/me/node", + }); + expect(plist).toContain("4"); + expect(plist).toContain("0"); + expect(plist).not.toContain("--bootstrap"); + expect(plist).toContain(NodePath.join("Logs & More", "daily-update.stderr.log")); + + const test = fixture({ + listThreads: async () => { + throw new Error("bootstrap must not inspect threads"); + }, + }); + await expect(runDailyUpdate({ bootstrap: true }, test.dependencies)).resolves.toMatchObject({ + status: "updated", + }); + expect(test.calls).toEqual(["stage", "prepare", "quit", "replace", "cleanup"]); + }); + + it("installs a daily LaunchAgent backed by standalone copied modules", () => { + const home = temporaryDirectory(); + const calls = []; + const installed = installDailyUpdateService({ + home, + nodePath: "/managed/node", + runCommand: (command, args, options) => calls.push({ args, command, options }), + }); + + expect(NodeFS.readFileSync(installed.plistPath, "utf8")).toContain("/managed/node"); + expect( + NodeFS.existsSync(NodePath.join(installed.moduleDirectory, "lastcode-intel-stage.mjs")), + ).toBe(true); + expect(calls).toEqual([ + { command: "plutil", args: ["-lint", installed.plistPath], options: undefined }, + { + command: "launchctl", + args: ["bootout", installed.service], + options: { allowFailure: true }, + }, + { + command: "launchctl", + args: ["bootstrap", `gui/${process.getuid()}`, installed.plistPath], + options: undefined, + }, + ]); + }); +}); diff --git a/scripts/lastcode-install.mjs b/scripts/lastcode-install.mjs index c36a9037383a..d82a68b488d0 100644 --- a/scripts/lastcode-install.mjs +++ b/scripts/lastcode-install.mjs @@ -260,6 +260,7 @@ export async function launchApp(appPath, options = {}) { const retryIntervalMs = options.retryIntervalMs ?? LAUNCH_RETRY_INTERVAL_MS; const stabilityMs = options.stabilityMs ?? LAUNCH_STABILITY_MS; const timeoutMs = options.timeoutMs ?? LAUNCH_TIMEOUT_MS; + const maxLaunchAttempts = options.maxLaunchAttempts ?? Number.POSITIVE_INFINITY; const deadline = now() + timeoutMs; const environment = cleanLaunchEnvironment(options.environment ?? process.env); let launchAttempts = 0; @@ -277,7 +278,7 @@ export async function launchApp(appPath, options = {}) { } } else { runningSince = undefined; - if (currentTime >= nextLaunchAt) { + if (currentTime >= nextLaunchAt && launchAttempts < maxLaunchAttempts) { launchAttempts += 1; console.log(`Launching LastCode (attempt ${launchAttempts})…`); try { @@ -440,7 +441,7 @@ async function readHandoffCommand(stream = process.stdin) { throw new Error("Install handoff closed before COMMIT or CANCEL."); } -async function prepareDmgInstall(dmgPath, options = {}) { +export async function prepareDmgInstall(dmgPath, 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 only supports macOS."); const resolvedDmg = NodePath.resolve(dmgPath); @@ -532,7 +533,7 @@ export async function replacePreparedApp(prepared, options = {}) { prepared.oldAppMoved = false; } -function cleanupPreparedInstall(prepared) { +export function cleanupPreparedInstall(prepared) { NodeFS.rmSync(prepared.staging, { force: true, recursive: true }); if ( prepared.oldAppMoved && diff --git a/scripts/lastcode-install.test.mjs b/scripts/lastcode-install.test.mjs index b4b088d5a1da..bc3060a80f1b 100644 --- a/scripts/lastcode-install.test.mjs +++ b/scripts/lastcode-install.test.mjs @@ -349,6 +349,29 @@ describe("LastCode userland install command", () => { ).rejects.toThrow("did not remain running"); }); + it("can wait for startup without relaunching the app", async () => { + let elapsed = 0; + let launches = 0; + await expect( + launchApp("/Applications/LastCode.app", { + isRunning: () => false, + maxLaunchAttempts: 1, + now: () => elapsed, + pollIntervalMs: 250, + retryIntervalMs: 100, + runCommand: () => { + launches += 1; + }, + stabilityMs: 500, + timeoutMs: 1_000, + wait: async (delay) => { + elapsed += delay; + }, + }), + ).rejects.toThrow("did not remain running"); + expect(launches).toBe(1); + }); + it("restores the previous app when launch fails after the swap", async () => { const root = temporaryDirectory(); const targetPath = NodePath.join(root, "LastCode.app");