diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb03..dcf657a3820f 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, @@ -269,6 +270,90 @@ describe("resolveInitialServerAuthGateState", () => { expect(attempts).toBe(4); }); + it("keeps retrying desktop session bootstrap across delayed credential prompts", 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 < 3) { + 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(41_000); + + await expect(sessionPromise).resolves.toEqual(unauthenticatedSession(DESKTOP_AUTH)); + 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("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 f9381bcad714..f18d5db322a0 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -187,6 +187,14 @@ function getDesktopBootstrapCredential(): string | null { } export async function fetchSessionState(): Promise { + const isDesktop = window.desktopBridge !== undefined; + const retryOptions = isDesktop + ? { + retryError: (error: unknown, attemptElapsedMs: number) => + isDelayedDesktopCredentialProtocolError(error, attemptElapsedMs), + retryErrorTimeoutMs: DESKTOP_BOOTSTRAP_RETRY_TIMEOUT_MS, + } + : {}; return retryTransientBootstrap(async () => { try { return await runPrimaryHttp( @@ -200,7 +208,7 @@ export async function fetchSessionState(): Promise { cause: error, }); } - }); + }, retryOptions); } function readHttpApiStatus(error: unknown): number | null { @@ -278,19 +286,36 @@ async function waitForAuthenticatedSessionAfterBootstrap(): Promise(operation: () => Promise): Promise { - const startedAt = Date.now(); +export async function retryTransientBootstrap( + operation: () => Promise, + options: { + readonly retryError?: (error: unknown, attemptElapsedMs: number) => boolean; + readonly retryErrorTimeoutMs?: number; + readonly timeoutMs?: number; + } = {}, +): Promise { + let retryStartedAt: number | null = null; while (true) { + const attemptStartedAt = Date.now(); try { return await operation(); } catch (error) { - if (!isTransientBootstrapError(error)) { + const matchesAdditionalRetry = + options.retryError?.(error, Date.now() - attemptStartedAt) ?? false; + if (!isTransientBootstrapError(error, matchesAdditionalRetry)) { throw error; } - if (Date.now() - startedAt >= BOOTSTRAP_RETRY_TIMEOUT_MS) { + const now = Date.now(); + retryStartedAt ??= now; + const timeoutMs = matchesAdditionalRetry + ? (options.retryErrorTimeoutMs ?? options.timeoutMs ?? BOOTSTRAP_RETRY_TIMEOUT_MS) + : (options.timeoutMs ?? BOOTSTRAP_RETRY_TIMEOUT_MS); + if (now - retryStartedAt >= timeoutMs) { throw error; } @@ -305,9 +330,22 @@ function waitForBootstrapRetry(delayMs: number): Promise { }); } -function isTransientBootstrapError(error: unknown): 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); + return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status) || retryError; } if (error instanceof TypeError) { diff --git a/docs/lastcode/README.md b/docs/lastcode/README.md index 5ca078deafaa..c6dd6c66dc6a 100644 --- a/docs/lastcode/README.md +++ b/docs/lastcode/README.md @@ -28,22 +28,23 @@ 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 +lastcode-checkpoints --verbose # 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 823304632b63..a59e286e7daf 100644 --- a/docs/lastcode/local-nightly-updates.md +++ b/docs/lastcode/local-nightly-updates.md @@ -17,14 +17,38 @@ Before enabling it, install the checkpoint service and dashboard: ```bash pnpm lastcode:checkpoint:service install -pnpm lastcode:checkpoints --install +pnpm run lastcode:checkpoints -- --install +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 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 +the replacement before quitting LastCode, then replaces +`/Applications/LastCode.app` and relaunches it. Passing a DMG path skips the +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 1. The desktop checks the local repository at startup, every four minutes, and @@ -35,6 +59,8 @@ cleans a human development worktree. 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. @@ -52,6 +78,15 @@ 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. +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, 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 A failed check or build leaves the current app installed and changes the @@ -72,6 +107,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/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index 089c0b835805..b728248b0f7d 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -40,13 +40,16 @@ 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 --push-tags --promote-if-no-open-prs +pnpm run lastcode:checkpoint -- \ + --push-tags \ + --promote-if-no-open-prs \ + --mirror-upstream-main ``` The command: @@ -98,8 +101,20 @@ 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 +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. @@ -119,9 +134,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 run 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 @@ -137,7 +162,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` @@ -153,13 +178,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 @@ -206,13 +235,68 @@ 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 run lastcode:build -- --install +pnpm run lastcode:install -- --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. 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. 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. + +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, +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: ```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..e31ab7fa91c7 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. 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 @@ -47,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, 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: ```bash @@ -75,7 +82,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 diff --git a/package.json b/package.json index e611be167fd4..6d2181611099 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "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: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-build.mjs b/scripts/lastcode-build.mjs new file mode 100644 index 000000000000..d7e39b1cd388 --- /dev/null +++ b/scripts/lastcode-build.mjs @@ -0,0 +1,525 @@ +#!/usr/bin/env node +// LastCode managed command: lastcode-build + +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"; +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 }, + { 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"; +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; +} + +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("'", `'\\''`)}'`; +} + +export function renderLauncher(modulePath) { + return `#!/bin/sh\n# ${BUILD_MANAGED_MARKER}\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; + 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}.`); + if (arg === "--repo") repoRoot = value; + else checkpoint = value; + index += 1; + } else if (arg === "-h" || arg === "--help") { + return { help: true, checkpoint, install, repoRoot, uninstall }; + } else if (arg.startsWith("-")) { + throw new Error(`Unknown argument '${arg}'.`); + } else if (checkpoint) { + throw new Error(`Unexpected second checkpoint selector '${arg}'.`); + } else { + checkpoint = arg; + } + } + 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) { + 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) { + assertManagedSymlink(exposed, target); + const existing = NodeFS.lstatSync(exposed, { throwIfNoEntry: false }); + if (existing) { + NodeFS.unlinkSync(exposed); + } + NodeFS.symlinkSync(target, exposed); +} + +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"); + 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"); + + 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); + 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( + 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}`); +} + +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 modify ${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.`); + } + 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 }); + 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."); + 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; +} + +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}.`); + } + 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 display = new BuildProgressDisplay(logPath); + const child = NodeChildProcess.spawn( + process.execPath, + [helperPath, "build", "--repo", repoRoot, "--home", home, "--checkpoint", checkpointTag], + { + 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"], + }, + ); + 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); + } +} + +async function main(argv) { + const options = parseOptions(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); + return; + } + const tags = splitLines(runGit(repoRoot, ["tag", "--list", `${CHECKPOINT_PREFIX}v*-nightly.*`])); + await buildCheckpoint(repoRoot, home, resolveCheckpointTag(tags, options.checkpoint)); +} + +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( + 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..9706832b2af3 --- /dev/null +++ b/scripts/lastcode-build.test.mjs @@ -0,0 +1,186 @@ +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 { + BUILD_PHASES, + estimateBuildProgress, + installCommandAssets, + parseBuildResult, + parseOptions, + renderProgressBar, + renderLauncher, + resolveBuildPhaseIndex, + resolveCheckpointTag, + sanitizeLogLine, + uninstallCommand, +} 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"); + 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 }); + 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); + + 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("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("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"); + }); + + 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("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-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.test.ts b/scripts/lastcode-checkpoint.test.ts index cb85aa7dc555..a643035776b0 100644 --- a/scripts/lastcode-checkpoint.test.ts +++ b/scripts/lastcode-checkpoint.test.ts @@ -3,12 +3,17 @@ import { assert, expect, it } from "@effect/vitest"; import { checkpointFailureDisposition, checkpointMessage, + checkpointSmokeEnvironment, checkpointSourceCommit, checkpointTagPushArgs, checkpointVpPaths, promotionNeeded, + rerereRebaseMadeProgress, resolveCheckpointPlan, + resolveUpstreamMainMirror, + shouldContinueRerereRebase, unpublishedCheckpointTags, + upstreamMainMirrorPushArgs, worktreeAddArgs, worktreeVp, } from "./lastcode-checkpoint.ts"; @@ -31,10 +36,34 @@ 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("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"); }); +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", @@ -106,6 +135,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..30f1c33f64db 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; @@ -55,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; @@ -75,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, @@ -86,6 +100,71 @@ function git( }); } +export function shouldContinueRerereRebase(input: { + readonly rebaseInProgress: boolean; + readonly unmergedPaths: ReadonlyArray; +}): boolean { + 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) => + NodeFS.existsSync(NodePath.join(gitDirectory, name)), + ); +} + +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 { + run(worktree, "git", ["rebase", "--onto", upstreamTag, baseTag]); + return; + } catch (error) { + failure = error; + } + + while ( + shouldContinueRerereRebase({ + rebaseInProgress: rebaseInProgress(worktree), + 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; + } + } + + throw failure; +} + function splitLines(value: string): ReadonlyArray { return value .split(/\r?\n/) @@ -235,6 +314,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 +372,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 +385,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 +402,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: { @@ -382,19 +499,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 { @@ -490,6 +615,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 +664,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); } @@ -605,6 +764,7 @@ function main(argv: ReadonlyArray): void { { commitsRebased, error, + failurePhase: "publication", ...(localTagRetained ? { localTagRetained: true } : {}), startedAtMs, upstreamTag: plan.baseNightly.tag, @@ -646,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) { @@ -662,9 +823,12 @@ 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]); + 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, @@ -710,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; @@ -726,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 b7764550a3d2..6c625a024338 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,23 @@ 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 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); @@ -229,14 +248,76 @@ 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 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`; } @@ -356,6 +437,7 @@ function checkpointRows(repoRoot, home, count, remoteState) { main: "—", build: "—", error: record.error, + failurePhase: record.failurePhase, recoveryBranch: record.recoveryBranch, }), ); @@ -384,9 +466,10 @@ 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 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 }, @@ -421,15 +504,34 @@ function printDashboard(repoRoot, home, count) { console.log(padded.join(" ").trimEnd()); } - const selectedFailures = rows.filter((row) => row.status === "failed"); - for (const failure of selectedFailures) { - const recovery = failure.recoveryBranch ? ` · Recovery: ${failure.recoveryBranch}` : ""; + const selectedFailures = allRows.filter((row) => row.status === "failed"); + 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.error, - `Failure ${failure.upstreamTag}: ${failure.error ?? "unknown error"}${recovery}`, + 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`)); + const automationWorktree = findAutomationWorktree(repoRoot); + for (const line of recoveryActionLines({ + repoRoot, + worktree: recoveryWorktree, + automationWorktree, + recoveryBranch: recoveryFailure?.recoveryBranch, + isRebaseInProgress: rebaseInProgress(recoveryWorktree), + failedDuringRebase: failureWasDuringRebase(recoveryFailure), + })) { + console.log(style(ansi.lavender, line)); + } } const upstreamTags = splitLines(git(repoRoot, ["tag", "--list", "v*-nightly.*"])).sort( @@ -463,7 +565,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 +574,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..d6f76bc3bd83 100644 --- a/scripts/lastcode-checkpoints.test.mjs +++ b/scripts/lastcode-checkpoints.test.mjs @@ -3,20 +3,25 @@ import { describe, expect, it } from "vite-plus/test"; import { checkpointTagsWithoutUnpublishedFailures, checkpointFreshness, + failureDetailLines, + failureWasDuringRebase, failedRunsWithoutPublishedTags, formatDuration, parseOptions, parseRemotePublicationState, parseTrailers, + recoveryActionLines, 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 +77,60 @@ 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("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("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 = { @@ -82,6 +141,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 = { diff --git a/scripts/lastcode-install.mjs b/scripts/lastcode-install.mjs new file mode 100644 index 000000000000..52975352a271 --- /dev/null +++ b/scripts/lastcode-install.mjs @@ -0,0 +1,441 @@ +#!/usr/bin/env node +// LastCode managed command: lastcode-install + +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"; +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\n# ${INSTALL_MANAGED_MARKER}\nexec mise exec node@24.13.1 -- node ${shellQuote(modulePath)} "$@"\n`; +} + +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, uninstall }; + } else if (arg.startsWith("-")) { + throw new Error(`Unknown argument '${arg}'.`); + } else if (dmgPath) { + throw new Error(`Unexpected second DMG path '${arg}'.`); + } else { + dmgPath = arg; + } + } + 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) { + for (const entry of NodeFS.readdirSync(directory, { withFileTypes: true })) { + const path = NodePath.join(directory, entry.name); + 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 }); + } + } +} + +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`), + }; +} + +function readLockOwner(path) { + try { + return JSON.parse(NodeFS.readFileSync(path, "utf8")); + } catch { + return undefined; + } +} + +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], + }); + 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"}).`, + ); + } + 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) { + // 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 releaseInstallLock = acquireInstallLock( + NodePath.join(NodeOS.homedir(), ".lastcode", "local-updates"), + ); + try { + 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 }); + } + } finally { + releaseInstallLock(); + } +} + +function replaceManagedSymlink(exposed, target) { + assertManagedSymlink(exposed, target); + const existing = NodeFS.lstatSync(exposed, { throwIfNoEntry: false }); + if (existing) { + NodeFS.unlinkSync(exposed); + } + NodeFS.symlinkSync(target, exposed); +} + +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); + 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}`); +} + +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]) assertManagedInstallerFile(path); + + 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; + } + 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..e003910025c5 --- /dev/null +++ b/scripts/lastcode-install.test.mjs @@ -0,0 +1,172 @@ +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 { + acquireInstallLock, + discoverDmgs, + installCommand, + parseDmgChoice, + parseOptions, + renderDmgChoices, + renderLauncher, + temporaryAppPaths, + uninstallCommand, +} 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; +} + +// 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 }); + 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", () => { + 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("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( + [ + { + 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' \"$@\"", + ); + }); + + itMacOnly("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"); + expect(NodeFS.readFileSync(lockPath, "utf8")).toBe(""); + const releaseAgain = acquireInstallLock(root); + releaseAgain(); + }); + + 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"); + 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, "# 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); + expect(NodeFS.existsSync(exposed)).toBe(false); + expect(NodeFS.existsSync(target)).toBe(false); + + 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); + }); + + 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); + } + } + }); +}); diff --git a/scripts/lastcode-local-ci.test.ts b/scripts/lastcode-local-ci.test.ts index 41fc841d9ae0..05efa96fa15c 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,19 @@ 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", + "--maxConcurrency=1", + ], + }); }); it("checks the built preload bridge contract", () => { @@ -91,7 +105,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 +128,78 @@ 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("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("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 8b68f888c70d..b42c1948fc77 100644 --- a/scripts/lastcode-local-ci.ts +++ b/scripts/lastcode-local-ci.ts @@ -56,9 +56,10 @@ export interface LocalCiOptions { } export interface RepositoryIntegritySnapshot { + readonly branchConfig: Readonly>>; readonly commonGitDir: string; - readonly configContents: Buffer; readonly configPath: string; + readonly protectedConfig: string; } export interface PreparedLocalCiRepository { @@ -89,7 +90,16 @@ const QUICK_STEPS: ReadonlyArray = [ kind: "command", label: "Workspace tests", command: "vp", - args: ["run", "test"], + args: [ + "run", + "--recursive", + "--concurrency-limit", + "1", + "test", + "--", + "--maxWorkers=1", + "--maxConcurrency=1", + ], isolatedGitConfig: true, transferBudgetOutput: true, }, @@ -324,6 +334,32 @@ function readCoreBare(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); +} + +function readProtectedConfig(entries: ReadonlyArray): string { + return entries.filter((entry) => !entry.startsWith("branch.")).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 config; +} + export function captureRepositoryIntegrity(repoRoot: string): RepositoryIntegritySnapshot { const commonGitDir = resolveCommonGitDir(repoRoot); const configPath = NodePath.join(commonGitDir, "config"); @@ -333,10 +369,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, - configContents: NodeFS.readFileSync(configPath), configPath, + protectedConfig: readProtectedConfig(configEntries), }; } @@ -357,12 +395,21 @@ 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 configEntries = readConfigEntries(repoRoot, before.configPath); + const protectedConfig = readProtectedConfig(configEntries); + 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 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( diff --git a/scripts/lastcode-local-update.d.mts b/scripts/lastcode-local-update.d.mts index f0d8dd3bc641..ba0341b462ba 100644 --- a/scripts/lastcode-local-update.d.mts +++ b/scripts/lastcode-local-update.d.mts @@ -23,6 +23,24 @@ export interface ExistingBuildOptions { readonly checkpointCommit: string; } +export interface LocalBuildLockOptions { + readonly pid?: number; +} + +export function resolveDeterministicBuildEnvironment( + environment?: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv; +export function resolveLocalBuildEnvironment( + worktreePath: 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; @@ -40,5 +58,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 98c4237da91d..87c8ac187595 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 @@ -11,6 +12,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, @@ -224,15 +252,78 @@ 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 readLockOwner(path) { + try { + return JSON.parse(NodeFS.readFileSync(path, "utf8")); + } catch { + return undefined; + } +} + +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], + }); + 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"}).`, + ); + } + 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) { 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; @@ -270,6 +361,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 +369,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 +416,7 @@ function build(options) { "--output-root", outputRoot, ], - { logFd }, + { logFd, env: buildEnvironment }, ); } catch (error) { throw new Error( @@ -327,6 +443,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 b5cb385f1884..d4695a2b7eb5 100644 --- a/scripts/lastcode-local-update.test.ts +++ b/scripts/lastcode-local-update.test.ts @@ -6,16 +6,79 @@ import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import { + acquireBuildLock, compareNightlyVersions, + isReusableCheckpointCiStamp, parseNightlyVersion, parseOptions, prepareBuildWorktree, quarantineIncompleteBuild, + resolveDeterministicBuildEnvironment, resolveExistingBuild, resolveLatestCheckpointTag, + 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", () => { + 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); + assert.throws(() => acquireBuildLock(root), /already running/); + release(); + + const lockPath = NodePath.join(root, "build.lock"); + assert.strictEqual(NodeFS.readFileSync(lockPath, "utf8"), ""); + const releaseAgain = acquireBuildLock(root); + releaseAgain(); + } 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", + 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, 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 `