From 57970dfdd2f577da6bc21a46be1f5b7bc2fc9239 Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 18:30:44 +0100 Subject: [PATCH 01/12] Fix Windows source launcher node resolution --- AGENTS.md | 2 +- Start-CafeCode.ps1 | 135 +++++++++++++++++++++----------- scripts/start-cafe-code.test.ts | 78 ++++++++++++++++++ 3 files changed, 170 insertions(+), 45 deletions(-) create mode 100644 scripts/start-cafe-code.test.ts diff --git a/AGENTS.md b/AGENTS.md index 537196989..debfab743 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ - Windows subprocess and heavy dynamic-import tests can need longer per-test timeouts under full parallel `turbo` load. Apply longer timeouts only under `process.platform === "win32"` and preserve existing macOS/Linux timeouts. - Windows process-table diagnostics must spawn `powershell.exe` directly with `shell: false`; do not route the CIM pipeline through `cmd.exe` or any shell wrapper because `cmd.exe` can misparse PowerShell pipeline syntax before PowerShell receives it. Keep macOS/Linux on the existing `ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=` path. Windows CPU/perf lookup should remain batched once per diagnostics read instead of querying performance counters once per process row. The longer diagnostics timeout exists for Windows CIM latency; if future work needs a tighter POSIX timeout, split the timeout by platform rather than regressing Windows reliability. - Windows `cafe-code killall` process discovery must also spawn `powershell.exe` directly with `shell: false` and consume `Get-CimInstance Win32_Process` JSON. Keep macOS/Linux on targeted `ps -axo pid=,ppid=,command=` discovery, and keep the command's current process plus ancestors protected so `cafe-code killall` does not terminate its own launcher before it finishes reporting results. -- Windows source checkouts may start Cafe through `Start-CafeCode.ps1`, which checks whether `openssl.exe`/`openssl` is available as an application on `PATH` before launch. The launcher should preserve default local HTTPS when OpenSSL is present, and set `CAFE_CODE_HTTPS_ENABLED=false` only when OpenSSL is missing because source installs do not always have it available. Desktop backend configuration must also propagate `CAFE_CODE_HTTPS_ENABLED=false` whenever `DesktopServerExposure` resolves no HTTPS port, while keeping macOS/Linux behavior unchanged by deriving the child environment from the shared exposure config rather than from a platform branch. +- Windows source checkouts may start Cafe through `Start-CafeCode.ps1`, which checks whether `openssl.exe`/`openssl` is available as an application on `PATH` before launch. On Windows CI and developer machines, `Get-Command` can return multiple Node application matches (for example an `actions/setup-node` toolcache path plus a preinstalled Node path), so the launcher must select one executable path deterministically before probing `--version` or calling `Start-Process`; do not concatenate multiple matches into one command string. The launcher should preserve default local HTTPS when OpenSSL is present, and set `CAFE_CODE_HTTPS_ENABLED=false` only when OpenSSL is missing because source installs do not always have it available. Desktop backend configuration must also propagate `CAFE_CODE_HTTPS_ENABLED=false` whenever `DesktopServerExposure` resolves no HTTPS port, while keeping macOS/Linux behavior unchanged by deriving the child environment from the shared exposure config rather than from a platform branch. - Windows packaged desktop startup must also verify that `openssl.exe`/`openssl` can run before allocating an HTTPS sibling port. If OpenSSL is unavailable, keep the user's persisted HTTPS preference unchanged but start the backend HTTP-only for that run so certificate generation cannot crash-loop before the window opens. macOS/Linux behavior remains unchanged because the packaged runtime only applies this OpenSSL prerequisite gate on Windows. - Windows source and artifact builds require the pinned standalone Node runtime. Server build helpers should invoke TypeScript entrypoints through `process.execPath` and use Corepack only for locked Yarn package operations. Preserve the direct Node path on macOS/Linux and avoid platform-specific package-manager shims. diff --git a/Start-CafeCode.ps1 b/Start-CafeCode.ps1 index f268f8360..6142bf00f 100644 --- a/Start-CafeCode.ps1 +++ b/Start-CafeCode.ps1 @@ -4,58 +4,105 @@ param( ) $ErrorActionPreference = "Stop" +$script:StartCafeCodeRepoRoot = $PSScriptRoot -$repo = Split-Path -Parent $MyInvocation.MyCommand.Path -$logDir = Join-Path $env:USERPROFILE ".cafe-code\launcher-logs" -$launcherLog = Join-Path $logDir "launcher.log" -$stdoutLog = Join-Path $logDir "desktop-start.stdout.log" -$stderrLog = Join-Path $logDir "desktop-start.stderr.log" +function Select-FirstApplicationPath { + param( + [AllowNull()] + [object[]]$Commands + ) -New-Item -ItemType Directory -Force -Path $logDir | Out-Null + foreach ($command in @($Commands)) { + if ($null -eq $command) { + continue + } -$node = Get-Command -Name "node.exe" -CommandType Application -ErrorAction SilentlyContinue -if ($null -eq $node) { - $node = Get-Command -Name "node" -CommandType Application -ErrorAction SilentlyContinue -} -if ($null -eq $node) { - throw "Node.js 24.13.1 or newer in the Node 24 release line was not found on PATH." -} + $path = $command.Path + if (-not [string]::IsNullOrWhiteSpace($path)) { + return $path + } + } -$nodeVersionText = (& $node.Source --version).Trim().TrimStart("v") -$nodeVersion = [Version]$nodeVersionText -if ($nodeVersion.Major -ne 24 -or $nodeVersion -lt [Version]"24.13.1") { - throw "Cafe Code requires Node.js ^24.13.1; found $nodeVersionText at $($node.Source)." + return $null } -# The current dev build defaults local HTTPS on. Source installs on Windows do -# not always have OpenSSL available on PATH, so only disable backend HTTPS when -# the helper the backend uses to mint the local certificate is not discoverable. -# Use CommandType Application so aliases/functions cannot spoof this readiness -# check; if OpenSSL exists, let the normal desktop settings/exposure flow decide. -$openssl = Get-Command -Name "openssl.exe" -CommandType Application -ErrorAction SilentlyContinue -if ($null -eq $openssl) { - $openssl = Get-Command -Name "openssl" -CommandType Application -ErrorAction SilentlyContinue +function Resolve-FirstApplicationPath { + param( + [string[]]$Names + ) + + foreach ($name in $Names) { + # GitHub Windows runners can expose more than one matching Node application + # on PATH (for example actions/setup-node plus the preinstalled Node path). + # PowerShell returns both entries, so select a single executable path before + # probing the version or launching the desktop process. + $path = Select-FirstApplicationPath -Commands ( + Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue + ) + if (-not [string]::IsNullOrWhiteSpace($path)) { + return $path + } + } + + return $null } -if ($null -eq $openssl) { - $env:CAFE_CODE_HTTPS_ENABLED = "false" - "OpenSSL was not found on PATH; starting Cafe Code with local backend HTTPS disabled." | - Add-Content -LiteralPath $launcherLog -} else { - "OpenSSL was found on PATH; preserving Cafe Code local backend HTTPS defaults." | - Add-Content -LiteralPath $launcherLog +function Invoke-StartCafeCode { + param( + [switch]$Wait, + [string[]]$DesktopArgs = @() + ) + + $repo = $script:StartCafeCodeRepoRoot + $logDir = Join-Path $env:USERPROFILE ".cafe-code\launcher-logs" + $launcherLog = Join-Path $logDir "launcher.log" + $stdoutLog = Join-Path $logDir "desktop-start.stdout.log" + $stderrLog = Join-Path $logDir "desktop-start.stderr.log" + + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + + $nodePath = Resolve-FirstApplicationPath -Names @("node.exe", "node") + if ([string]::IsNullOrWhiteSpace($nodePath)) { + throw "Node.js 24.13.1 or newer in the Node 24 release line was not found on PATH." + } + + $nodeVersionText = (& $nodePath --version).Trim().TrimStart("v") + $nodeVersion = [Version]$nodeVersionText + if ($nodeVersion.Major -ne 24 -or $nodeVersion -lt [Version]"24.13.1") { + throw "Cafe Code requires Node.js ^24.13.1; found $nodeVersionText at $nodePath." + } + + # The current dev build defaults local HTTPS on. Source installs on Windows do + # not always have OpenSSL available on PATH, so only disable backend HTTPS when + # the helper the backend uses to mint the local certificate is not discoverable. + # Use CommandType Application so aliases/functions cannot spoof this readiness + # check; if OpenSSL exists, let the normal desktop settings/exposure flow decide. + $opensslPath = Resolve-FirstApplicationPath -Names @("openssl.exe", "openssl") + + if ([string]::IsNullOrWhiteSpace($opensslPath)) { + $env:CAFE_CODE_HTTPS_ENABLED = "false" + "OpenSSL was not found on PATH; starting Cafe Code with local backend HTTPS disabled." | + Add-Content -LiteralPath $launcherLog + } else { + "OpenSSL was found on PATH; preserving Cafe Code local backend HTTPS defaults." | + Add-Content -LiteralPath $launcherLog + } + + $desktopProcess = Start-Process ` + -FilePath $nodePath ` + -ArgumentList (@("apps/desktop/scripts/start-electron.mjs") + $DesktopArgs) ` + -WorkingDirectory $repo ` + -RedirectStandardOutput $stdoutLog ` + -RedirectStandardError $stderrLog ` + -WindowStyle Hidden ` + -PassThru + + if ($Wait) { + $desktopProcess.WaitForExit() + exit $desktopProcess.ExitCode + } } -$desktopProcess = Start-Process ` - -FilePath $node.Source ` - -ArgumentList (@("apps/desktop/scripts/start-electron.mjs") + $DesktopArgs) ` - -WorkingDirectory $repo ` - -RedirectStandardOutput $stdoutLog ` - -RedirectStandardError $stderrLog ` - -WindowStyle Hidden ` - -PassThru - -if ($Wait) { - $desktopProcess.WaitForExit() - exit $desktopProcess.ExitCode +if ($MyInvocation.InvocationName -ne ".") { + Invoke-StartCafeCode -Wait:$Wait -DesktopArgs $DesktopArgs } diff --git a/scripts/start-cafe-code.test.ts b/scripts/start-cafe-code.test.ts new file mode 100644 index 000000000..e242efb8b --- /dev/null +++ b/scripts/start-cafe-code.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { describe, it } from "vitest"; + +const startCafeCodeScript = fileURLToPath(new URL("../Start-CafeCode.ps1", import.meta.url)); + +function toPowerShellLiteralPath(path: string): string { + return path.replaceAll("'", "''"); +} + +function hasPowerShell(): boolean { + const result = spawnSync( + "pwsh", + ["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion"], + { + encoding: "utf8", + }, + ); + return result.error === undefined && result.status === 0; +} + +function runPowerShell(script: string): string { + return execFileSync("pwsh", ["-NoLogo", "-NoProfile", "-Command", script], { + encoding: "utf8", + }).trim(); +} + +const powerShellIt = hasPowerShell() ? it : it.skip; + +describe("Start-CafeCode PowerShell helpers", () => { + powerShellIt( + "selects the first Node executable when Get-Command returns multiple matches", + () => { + const selectedPath = runPowerShell(` +. '${toPowerShellLiteralPath(startCafeCodeScript)}' +function Get-Command { + param([string]$Name, [string]$CommandType, [object]$ErrorAction) + + if ($Name -eq "node.exe") { + return @( + [pscustomobject]@{ Path = "C:\\hostedtoolcache\\windows\\node\\24.13.1\\x64\\node.exe" }, + [pscustomobject]@{ Path = "C:\\Program Files\\nodejs\\node.exe" } + ) + } + + return $null +} + +$resolved = Resolve-FirstApplicationPath -Names @("node.exe", "node") +[Console]::Out.Write($resolved) +`); + + assert.equal(selectedPath, "C:\\hostedtoolcache\\windows\\node\\24.13.1\\x64\\node.exe"); + }, + ); + + powerShellIt("falls back to the next candidate name when the first one is absent", () => { + const selectedPath = runPowerShell(` +. '${toPowerShellLiteralPath(startCafeCodeScript)}' +function Get-Command { + param([string]$Name, [string]$CommandType, [object]$ErrorAction) + + if ($Name -eq "node") { + return [pscustomobject]@{ Path = "C:\\Program Files\\nodejs\\node.exe" } + } + + return $null +} + +$resolved = Resolve-FirstApplicationPath -Names @("node.exe", "node") +[Console]::Out.Write($resolved) +`); + + assert.equal(selectedPath, "C:\\Program Files\\nodejs\\node.exe"); + }); +}); From 5b55f6f12a7b694b0c72cec3bc6699e24c3bb213 Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 18:51:04 +0100 Subject: [PATCH 02/12] Retry Windows artifact smoke cleanup --- AGENTS.md | 1 + scripts/windows-native-artifact-smoke.test.ts | 44 ++++++++++++++++++ scripts/windows-native-artifact-smoke.ts | 46 ++++++++++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index debfab743..dba32bafd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ - Windows `cafe-code killall` process discovery must also spawn `powershell.exe` directly with `shell: false` and consume `Get-CimInstance Win32_Process` JSON. Keep macOS/Linux on targeted `ps -axo pid=,ppid=,command=` discovery, and keep the command's current process plus ancestors protected so `cafe-code killall` does not terminate its own launcher before it finishes reporting results. - Windows source checkouts may start Cafe through `Start-CafeCode.ps1`, which checks whether `openssl.exe`/`openssl` is available as an application on `PATH` before launch. On Windows CI and developer machines, `Get-Command` can return multiple Node application matches (for example an `actions/setup-node` toolcache path plus a preinstalled Node path), so the launcher must select one executable path deterministically before probing `--version` or calling `Start-Process`; do not concatenate multiple matches into one command string. The launcher should preserve default local HTTPS when OpenSSL is present, and set `CAFE_CODE_HTTPS_ENABLED=false` only when OpenSSL is missing because source installs do not always have it available. Desktop backend configuration must also propagate `CAFE_CODE_HTTPS_ENABLED=false` whenever `DesktopServerExposure` resolves no HTTPS port, while keeping macOS/Linux behavior unchanged by deriving the child environment from the shared exposure config rather than from a platform branch. - Windows packaged desktop startup must also verify that `openssl.exe`/`openssl` can run before allocating an HTTPS sibling port. If OpenSSL is unavailable, keep the user's persisted HTTPS preference unchanged but start the backend HTTP-only for that run so certificate generation cannot crash-loop before the window opens. macOS/Linux behavior remains unchanged because the packaged runtime only applies this OpenSSL prerequisite gate on Windows. +- Windows NSIS artifact smoke cleanup can see short-lived `EBUSY`/`EPERM`/`ENOTEMPTY` directory removal failures immediately after the packaged app or uninstaller exits because Windows can release process/file handles slightly after the parent process observes exit. Keep macOS/Linux cleanup on the existing single-pass removal path, but on Windows native-artifact smoke scripts retry temp directory removal for a bounded window before treating it as a real failure. - Windows source and artifact builds require the pinned standalone Node runtime. Server build helpers should invoke TypeScript entrypoints through `process.execPath` and use Corepack only for locked Yarn package operations. Preserve the direct Node path on macOS/Linux and avoid platform-specific package-manager shims. ## Project Snapshot diff --git a/scripts/windows-native-artifact-smoke.test.ts b/scripts/windows-native-artifact-smoke.test.ts index fdc20b854..a207561d4 100644 --- a/scripts/windows-native-artifact-smoke.test.ts +++ b/scripts/windows-native-artifact-smoke.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import { + removePathWithRetries, selectInstalledWindowsExecutables, selectWindowsInstaller, } from "./windows-native-artifact-smoke.ts"; @@ -48,4 +49,47 @@ describe("Windows native artifact smoke", () => { }, ); }); + + it("retries transient Windows cleanup errors before removing the smoke root", async () => { + let attempts = 0; + const waits: number[] = []; + await removePathWithRetries("C:\\temp\\cafecode-smoke", { + platform: "win32", + remove: async () => { + attempts += 1; + if (attempts < 3) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + }, + sleep: async (ms) => { + waits.push(ms); + }, + }); + assert.equal(attempts, 3); + assert.deepEqual(waits, [250, 250]); + }); + + it("does not retry non-Windows cleanup failures", async () => { + let attempts = 0; + let error: unknown; + try { + await removePathWithRetries("/tmp/cafecode-smoke", { + platform: "linux", + remove: async () => { + attempts += 1; + const busyError = new Error("busy") as NodeJS.ErrnoException; + busyError.code = "EBUSY"; + throw busyError; + }, + sleep: async () => undefined, + }); + } catch (caught) { + error = caught; + } + assert.instanceOf(error, Error); + assert.match((error as Error).message, /busy/); + assert.equal(attempts, 1); + }); }); diff --git a/scripts/windows-native-artifact-smoke.ts b/scripts/windows-native-artifact-smoke.ts index 2914dd538..c83a542e9 100644 --- a/scripts/windows-native-artifact-smoke.ts +++ b/scripts/windows-native-artifact-smoke.ts @@ -7,6 +7,8 @@ import { join, resolve } from "node:path"; import { runNativeDesktopRuntimeSmoke } from "./native-desktop-runtime-smoke.ts"; const PROCESS_TIMEOUT_MS = 15 * 60_000; +const WINDOWS_CLEANUP_RETRY_DELAY_MS = 250; +const WINDOWS_CLEANUP_RETRY_ATTEMPTS = 40; interface ProcessResult { readonly exitCode: number | null; @@ -14,6 +16,12 @@ interface ProcessResult { readonly stderr: string; } +interface CleanupDependencies { + readonly platform?: NodeJS.Platform; + readonly remove?: typeof rm; + readonly sleep?: (ms: number) => Promise; +} + async function runProcess( command: string, args: readonly string[], @@ -45,6 +53,42 @@ async function runProcess( }); } +function isRetryableWindowsCleanupError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "EBUSY" || code === "EPERM" || code === "ENOTEMPTY"; +} + +async function sleep(ms: number): Promise { + await new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + +export async function removePathWithRetries( + targetPath: string, + dependencies: CleanupDependencies = {}, +): Promise { + const platform = dependencies.platform ?? process.platform; + const remove = dependencies.remove ?? rm; + const wait = dependencies.sleep ?? sleep; + + for (let attempt = 0; ; attempt += 1) { + try { + await remove(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + if ( + platform !== "win32" || + !isRetryableWindowsCleanupError(error) || + attempt >= WINDOWS_CLEANUP_RETRY_ATTEMPTS - 1 + ) { + throw error; + } + // Windows can report parent process exit before the installer/uninstaller + // releases all file handles in the extracted app directory. + await wait(WINDOWS_CLEANUP_RETRY_DELAY_MS); + } + } +} + export function selectWindowsInstaller(fileNames: readonly string[]): string { const matches = fileNames.filter( (fileName) => /^Cafe-Code-.+-x64\.exe$/.test(fileName) && !fileName.startsWith("Uninstall"), @@ -179,7 +223,7 @@ export async function runWindowsNativeArtifactSmoke( if (uninstallerPath && existsSync(uninstallerPath)) { await runProcess(uninstallerPath, ["/S"], { timeoutMs: 5 * 60_000 }).catch(() => undefined); } - await rm(smokeRoot, { recursive: true, force: true }); + await removePathWithRetries(smokeRoot); } } From d1b986fe15e0b4d3b52080efb9e21c5125243189 Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 19:12:50 +0100 Subject: [PATCH 03/12] Handle BOM in Windows artifact smoke JSON --- AGENTS.md | 1 + scripts/json-file.ts | 9 +++++++++ scripts/native-desktop-runtime-smoke.ts | 6 ++++-- scripts/windows-native-artifact-smoke.test.ts | 8 ++++++++ scripts/windows-native-artifact-smoke.ts | 5 +++-- 5 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 scripts/json-file.ts diff --git a/AGENTS.md b/AGENTS.md index dba32bafd..87da25fdc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ - Windows source checkouts may start Cafe through `Start-CafeCode.ps1`, which checks whether `openssl.exe`/`openssl` is available as an application on `PATH` before launch. On Windows CI and developer machines, `Get-Command` can return multiple Node application matches (for example an `actions/setup-node` toolcache path plus a preinstalled Node path), so the launcher must select one executable path deterministically before probing `--version` or calling `Start-Process`; do not concatenate multiple matches into one command string. The launcher should preserve default local HTTPS when OpenSSL is present, and set `CAFE_CODE_HTTPS_ENABLED=false` only when OpenSSL is missing because source installs do not always have it available. Desktop backend configuration must also propagate `CAFE_CODE_HTTPS_ENABLED=false` whenever `DesktopServerExposure` resolves no HTTPS port, while keeping macOS/Linux behavior unchanged by deriving the child environment from the shared exposure config rather than from a platform branch. - Windows packaged desktop startup must also verify that `openssl.exe`/`openssl` can run before allocating an HTTPS sibling port. If OpenSSL is unavailable, keep the user's persisted HTTPS preference unchanged but start the backend HTTP-only for that run so certificate generation cannot crash-loop before the window opens. macOS/Linux behavior remains unchanged because the packaged runtime only applies this OpenSSL prerequisite gate on Windows. - Windows NSIS artifact smoke cleanup can see short-lived `EBUSY`/`EPERM`/`ENOTEMPTY` directory removal failures immediately after the packaged app or uninstaller exits because Windows can release process/file handles slightly after the parent process observes exit. Keep macOS/Linux cleanup on the existing single-pass removal path, but on Windows native-artifact smoke scripts retry temp directory removal for a bounded window before treating it as a real failure. +- Windows managed-runtime installer outputs can be valid UTF-8 JSON files with a leading BOM when produced through Windows-native writers. Keep macOS/Linux JSON parsing unchanged, but Windows artifact/native-runtime smoke helpers must tolerate an optional leading BOM before `JSON.parse` so bundled-provider install verification does not fail on otherwise valid JSON. - Windows source and artifact builds require the pinned standalone Node runtime. Server build helpers should invoke TypeScript entrypoints through `process.execPath` and use Corepack only for locked Yarn package operations. Preserve the direct Node path on macOS/Linux and avoid platform-specific package-manager shims. ## Project Snapshot diff --git a/scripts/json-file.ts b/scripts/json-file.ts new file mode 100644 index 000000000..40eeb81fa --- /dev/null +++ b/scripts/json-file.ts @@ -0,0 +1,9 @@ +import { readFile } from "node:fs/promises"; + +export function parseJsonText(jsonText: string): unknown { + return JSON.parse(jsonText.charCodeAt(0) === 0xfeff ? jsonText.slice(1) : jsonText); +} + +export async function readJsonFile(filePath: string): Promise { + return parseJsonText(await readFile(filePath, "utf8")); +} diff --git a/scripts/native-desktop-runtime-smoke.ts b/scripts/native-desktop-runtime-smoke.ts index 3ad7ec193..4e024a121 100644 --- a/scripts/native-desktop-runtime-smoke.ts +++ b/scripts/native-desktop-runtime-smoke.ts @@ -1,9 +1,11 @@ import { spawn, spawnSync } from "node:child_process"; import { createServer } from "node:net"; -import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, posix, resolve, win32 } from "node:path"; +import { readJsonFile } from "./json-file.ts"; + const SELF_TEST_SWITCH = "--cafe-runtime-self-test"; const SELF_TEST_RESULT_ENV = "CAFE_CODE_RUNTIME_SELF_TEST_RESULT"; const DISABLE_CHROMIUM_SANDBOX_ENV = "CAFE_CODE_NATIVE_SMOKE_DISABLE_CHROMIUM_SANDBOX"; @@ -253,7 +255,7 @@ async function runPackagedRuntimeSelfTest( { env: { ...environment, [SELF_TEST_RESULT_ENV]: resultPath } }, ); if (result.exitCode !== 0) throw new Error("Packaged desktop runtime self-test exited nonzero."); - const decoded = JSON.parse(await readFile(resultPath, "utf8")) as unknown; + const decoded = await readJsonFile(resultPath); return assertRuntimeSelfTestResult(decoded); } diff --git a/scripts/windows-native-artifact-smoke.test.ts b/scripts/windows-native-artifact-smoke.test.ts index a207561d4..0f53ab916 100644 --- a/scripts/windows-native-artifact-smoke.test.ts +++ b/scripts/windows-native-artifact-smoke.test.ts @@ -1,5 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; +import { parseJsonText } from "./json-file.ts"; import { removePathWithRetries, selectInstalledWindowsExecutables, @@ -50,6 +51,13 @@ describe("Windows native artifact smoke", () => { ); }); + it("parses BOM-prefixed managed runtime JSON", () => { + assert.deepEqual(parseJsonText('\uFEFF{"managedProviderRuntimeEnabled":true,"providers":[]}'), { + managedProviderRuntimeEnabled: true, + providers: [], + }); + }); + it("retries transient Windows cleanup errors before removing the smoke root", async () => { let attempts = 0; const waits: number[] = []; diff --git a/scripts/windows-native-artifact-smoke.ts b/scripts/windows-native-artifact-smoke.ts index c83a542e9..0b78bf3c3 100644 --- a/scripts/windows-native-artifact-smoke.ts +++ b/scripts/windows-native-artifact-smoke.ts @@ -1,10 +1,11 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { runNativeDesktopRuntimeSmoke } from "./native-desktop-runtime-smoke.ts"; +import { readJsonFile } from "./json-file.ts"; const PROCESS_TIMEOUT_MS = 15 * 60_000; const WINDOWS_CLEANUP_RETRY_DELAY_MS = 250; @@ -136,7 +137,7 @@ function readRecord(value: unknown): Record | undefined { async function assertManagedProviderRuntime(managedRoot: string): Promise { const resultPath = join(managedRoot, "install-result.json"); - const result = readRecord(JSON.parse(await readFile(resultPath, "utf8"))); + const result = readRecord(await readJsonFile(resultPath)); const providers = Array.isArray(result?.providers) ? result.providers.map(readRecord) : []; if ( result?.managedProviderRuntimeEnabled !== true || From b2fa5c271a7ff01f9ab3de3bbc1b7301f1e910d6 Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 19:29:04 +0100 Subject: [PATCH 04/12] Relax oxlint fixture timeout on CI --- oxlint-plugin-cafecode/test/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oxlint-plugin-cafecode/test/utils.ts b/oxlint-plugin-cafecode/test/utils.ts index 6e7a1ad43..ffe09f389 100644 --- a/oxlint-plugin-cafecode/test/utils.ts +++ b/oxlint-plugin-cafecode/test/utils.ts @@ -28,7 +28,9 @@ class OxlintFixtureExpectedFailure extends Data.TaggedError("OxlintFixtureExpect } const encodeOxlintConfig = Schema.encodeEffect(Schema.UnknownFromJsonString); -const OXLINT_FIXTURE_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : undefined; +// The harness shells out to oxlint and loads the local plugin entrypoint, which can +// exceed Vitest's default 5s budget on slower CI machines even when the rule passes. +const OXLINT_FIXTURE_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 10_000; interface RuleHarness { readonly run: ( From f201c40a967972eafccb8fe5086ff96a2ae0a8fc Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 19:56:24 +0100 Subject: [PATCH 05/12] Use managed runtime env in Windows artifact smoke --- AGENTS.md | 1 + scripts/windows-native-artifact-smoke.test.ts | 23 ++++++++ scripts/windows-native-artifact-smoke.ts | 56 +++++++++++++++---- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87da25fdc..379337a9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ - Windows packaged desktop startup must also verify that `openssl.exe`/`openssl` can run before allocating an HTTPS sibling port. If OpenSSL is unavailable, keep the user's persisted HTTPS preference unchanged but start the backend HTTP-only for that run so certificate generation cannot crash-loop before the window opens. macOS/Linux behavior remains unchanged because the packaged runtime only applies this OpenSSL prerequisite gate on Windows. - Windows NSIS artifact smoke cleanup can see short-lived `EBUSY`/`EPERM`/`ENOTEMPTY` directory removal failures immediately after the packaged app or uninstaller exits because Windows can release process/file handles slightly after the parent process observes exit. Keep macOS/Linux cleanup on the existing single-pass removal path, but on Windows native-artifact smoke scripts retry temp directory removal for a bounded window before treating it as a real failure. - Windows managed-runtime installer outputs can be valid UTF-8 JSON files with a leading BOM when produced through Windows-native writers. Keep macOS/Linux JSON parsing unchanged, but Windows artifact/native-runtime smoke helpers must tolerate an optional leading BOM before `JSON.parse` so bundled-provider install verification does not fail on otherwise valid JSON. +- Windows managed provider CLI shims installed by npm do not carry a private `node.exe`; they expect the managed provider `.bin` directory and the managed Node directory on `PATH`, plus the managed npm prefix/cache environment, exactly as the packaged bundled-runtime launcher provides. Keep macOS/Linux artifact smoke unchanged, but Windows native-artifact smoke must probe `codex.cmd`/`claude.cmd` with that Cafe-managed environment instead of assuming the ambient user PATH can run the shims directly. - Windows source and artifact builds require the pinned standalone Node runtime. Server build helpers should invoke TypeScript entrypoints through `process.execPath` and use Corepack only for locked Yarn package operations. Preserve the direct Node path on macOS/Linux and avoid platform-specific package-manager shims. ## Project Snapshot diff --git a/scripts/windows-native-artifact-smoke.test.ts b/scripts/windows-native-artifact-smoke.test.ts index 0f53ab916..56f426165 100644 --- a/scripts/windows-native-artifact-smoke.test.ts +++ b/scripts/windows-native-artifact-smoke.test.ts @@ -1,7 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; +import { join } from "node:path"; import { parseJsonText } from "./json-file.ts"; import { + buildManagedProviderProbeEnvironment, removePathWithRetries, selectInstalledWindowsExecutables, selectWindowsInstaller, @@ -58,6 +60,27 @@ describe("Windows native artifact smoke", () => { }); }); + it("probes managed provider shims with the bundled runtime environment", () => { + const managedRoot = "C:\\Users\\runneradmin\\AppData\\Local\\CafeCode\\managed"; + const env = buildManagedProviderProbeEnvironment(managedRoot, "codex", { + Path: "C:\\Windows\\System32", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }); + const installRoot = join(managedRoot, "providers", "codex", "current"); + + assert.equal( + env.PATH, + [ + join(installRoot, "node_modules", ".bin"), + join(managedRoot, "node", "current"), + "C:\\Windows\\System32", + ].join(";"), + ); + assert.equal(env.Path, undefined); + assert.equal(env.npm_config_prefix, installRoot); + assert.equal(env.npm_config_cache, join(managedRoot, "npm-cache")); + }); + it("retries transient Windows cleanup errors before removing the smoke root", async () => { let attempts = 0; const waits: number[] = []; diff --git a/scripts/windows-native-artifact-smoke.ts b/scripts/windows-native-artifact-smoke.ts index 0b78bf3c3..265db70dc 100644 --- a/scripts/windows-native-artifact-smoke.ts +++ b/scripts/windows-native-artifact-smoke.ts @@ -23,6 +23,8 @@ interface CleanupDependencies { readonly sleep?: (ms: number) => Promise; } +type ManagedProviderSlug = "codex" | "claude"; + async function runProcess( command: string, args: readonly string[], @@ -54,6 +56,21 @@ async function runProcess( }); } +function prependWindowsPathEntries( + env: NodeJS.ProcessEnv, + entries: readonly string[], +): NodeJS.ProcessEnv { + const currentPath = env.PATH ?? env.Path ?? ""; + const nextPath = [...entries.filter((entry) => entry.trim().length > 0), currentPath] + .filter((entry) => entry.trim().length > 0) + .join(";"); + return { + ...env, + PATH: nextPath, + Path: undefined, + }; +} + function isRetryableWindowsCleanupError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException | undefined)?.code; return code === "EBUSY" || code === "EPERM" || code === "ENOTEMPTY"; @@ -119,7 +136,14 @@ export function selectInstalledWindowsExecutables(fileNames: readonly string[]): } function assertSuccessful(result: ProcessResult, operation: string): void { - if (result.exitCode !== 0) throw new Error(`${operation} exited nonzero.`); + if (result.exitCode === 0) return; + + const details = [ + `exitCode=${result.exitCode === null ? "null" : String(result.exitCode)}`, + result.stdout.trim().length > 0 ? `stdout:\n${result.stdout.trim()}` : null, + result.stderr.trim().length > 0 ? `stderr:\n${result.stderr.trim()}` : null, + ].filter((detail): detail is string => detail !== null); + throw new Error(`${operation} exited nonzero.\n${details.join("\n\n")}`); } async function readUserPathRegistry(): Promise { @@ -135,6 +159,24 @@ function readRecord(value: unknown): Record | undefined { : undefined; } +export function buildManagedProviderProbeEnvironment( + managedRoot: string, + provider: ManagedProviderSlug, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const installRoot = join(managedRoot, "providers", provider, "current"); + const binaryDir = join(installRoot, "node_modules", ".bin"); + const nodeDir = join(managedRoot, "node", "current"); + + // Match the packaged bundled-runtime launcher: npm shims rely on managed + // Node and their local .bin directory being ahead of the ambient user PATH. + return { + ...prependWindowsPathEntries(baseEnv, [binaryDir, nodeDir]), + npm_config_prefix: installRoot, + npm_config_cache: join(managedRoot, "npm-cache"), + }; +} + async function assertManagedProviderRuntime(managedRoot: string): Promise { const resultPath = join(managedRoot, "install-result.json"); const result = readRecord(await readJsonFile(resultPath)); @@ -163,17 +205,11 @@ async function assertManagedProviderRuntime(managedRoot: string): Promise ["codex", "codex.cmd"], ["claude", "claude.cmd"], ] as const) { - const shim = join( - managedRoot, - "providers", - provider, - "current", - "node_modules", - ".bin", - executable, - ); + const installRoot = join(managedRoot, "providers", provider, "current"); + const shim = join(installRoot, "node_modules", ".bin", executable); if (!existsSync(shim)) throw new Error(`Managed ${provider} shim is missing.`); const probe = await runProcess("cmd.exe", ["/d", "/s", "/c", `"${shim}" --version`], { + env: buildManagedProviderProbeEnvironment(managedRoot, provider, process.env), timeoutMs: 60_000, }); assertSuccessful(probe, `Managed ${provider} version probe`); From cbead3833f6dd7820c72cb00735eb77f58861185 Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 20:15:57 +0100 Subject: [PATCH 06/12] Quote Windows managed shim probes for cmd --- AGENTS.md | 1 + scripts/windows-native-artifact-smoke.test.ts | 11 +++++++++++ scripts/windows-native-artifact-smoke.ts | 17 +++++++++++++---- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 379337a9a..1c19c1d91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ - Windows NSIS artifact smoke cleanup can see short-lived `EBUSY`/`EPERM`/`ENOTEMPTY` directory removal failures immediately after the packaged app or uninstaller exits because Windows can release process/file handles slightly after the parent process observes exit. Keep macOS/Linux cleanup on the existing single-pass removal path, but on Windows native-artifact smoke scripts retry temp directory removal for a bounded window before treating it as a real failure. - Windows managed-runtime installer outputs can be valid UTF-8 JSON files with a leading BOM when produced through Windows-native writers. Keep macOS/Linux JSON parsing unchanged, but Windows artifact/native-runtime smoke helpers must tolerate an optional leading BOM before `JSON.parse` so bundled-provider install verification does not fail on otherwise valid JSON. - Windows managed provider CLI shims installed by npm do not carry a private `node.exe`; they expect the managed provider `.bin` directory and the managed Node directory on `PATH`, plus the managed npm prefix/cache environment, exactly as the packaged bundled-runtime launcher provides. Keep macOS/Linux artifact smoke unchanged, but Windows native-artifact smoke must probe `codex.cmd`/`claude.cmd` with that Cafe-managed environment instead of assuming the ambient user PATH can run the shims directly. +- Windows batch shims launched through `cmd.exe /c` need the entire command string quoted when the shim path itself is quoted, for example `""C:\path\codex.cmd" --version"`. Without that outer quote pair, `cmd.exe` treats the quoted filename token as the literal command name and reports `"path\codex.cmd"` as not recognized even when the managed PATH/npm environment is correct. Keep macOS/Linux smoke paths unchanged. - Windows source and artifact builds require the pinned standalone Node runtime. Server build helpers should invoke TypeScript entrypoints through `process.execPath` and use Corepack only for locked Yarn package operations. Preserve the direct Node path on macOS/Linux and avoid platform-specific package-manager shims. ## Project Snapshot diff --git a/scripts/windows-native-artifact-smoke.test.ts b/scripts/windows-native-artifact-smoke.test.ts index 56f426165..28c694f3f 100644 --- a/scripts/windows-native-artifact-smoke.test.ts +++ b/scripts/windows-native-artifact-smoke.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { parseJsonText } from "./json-file.ts"; import { + buildWindowsCmdCommand, buildManagedProviderProbeEnvironment, removePathWithRetries, selectInstalledWindowsExecutables, @@ -81,6 +82,16 @@ describe("Windows native artifact smoke", () => { assert.equal(env.npm_config_cache, join(managedRoot, "npm-cache")); }); + it("quotes managed Windows shim commands for cmd.exe", () => { + assert.equal( + buildWindowsCmdCommand( + "C:\\Users\\runneradmin\\AppData\\Local\\CafeCode\\managed\\providers\\codex\\current\\node_modules\\.bin\\codex.cmd", + ["--version"], + ), + '""C:\\Users\\runneradmin\\AppData\\Local\\CafeCode\\managed\\providers\\codex\\current\\node_modules\\.bin\\codex.cmd" --version"', + ); + }); + it("retries transient Windows cleanup errors before removing the smoke root", async () => { let attempts = 0; const waits: number[] = []; diff --git a/scripts/windows-native-artifact-smoke.ts b/scripts/windows-native-artifact-smoke.ts index 265db70dc..06edaf638 100644 --- a/scripts/windows-native-artifact-smoke.ts +++ b/scripts/windows-native-artifact-smoke.ts @@ -177,6 +177,11 @@ export function buildManagedProviderProbeEnvironment( }; } +export function buildWindowsCmdCommand(commandPath: string, args: readonly string[]): string { + const renderedArgs = args.join(" "); + return `""${commandPath}"${renderedArgs.length > 0 ? ` ${renderedArgs}` : ""}"`; +} + async function assertManagedProviderRuntime(managedRoot: string): Promise { const resultPath = join(managedRoot, "install-result.json"); const result = readRecord(await readJsonFile(resultPath)); @@ -208,10 +213,14 @@ async function assertManagedProviderRuntime(managedRoot: string): Promise const installRoot = join(managedRoot, "providers", provider, "current"); const shim = join(installRoot, "node_modules", ".bin", executable); if (!existsSync(shim)) throw new Error(`Managed ${provider} shim is missing.`); - const probe = await runProcess("cmd.exe", ["/d", "/s", "/c", `"${shim}" --version`], { - env: buildManagedProviderProbeEnvironment(managedRoot, provider, process.env), - timeoutMs: 60_000, - }); + const probe = await runProcess( + "cmd.exe", + ["/d", "/s", "/c", buildWindowsCmdCommand(shim, ["--version"])], + { + env: buildManagedProviderProbeEnvironment(managedRoot, provider, process.env), + timeoutMs: 60_000, + }, + ); assertSuccessful(probe, `Managed ${provider} version probe`); } } From b8084860046b8bcf296133c2c9e69c24fbcd102c Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 20:37:33 +0100 Subject: [PATCH 07/12] Use verbatim cmd args for Windows shim probes --- AGENTS.md | 2 +- scripts/windows-native-artifact-smoke.test.ts | 22 ++++++---- scripts/windows-native-artifact-smoke.ts | 41 +++++++++++++++---- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1c19c1d91..4fc16edfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ - Windows NSIS artifact smoke cleanup can see short-lived `EBUSY`/`EPERM`/`ENOTEMPTY` directory removal failures immediately after the packaged app or uninstaller exits because Windows can release process/file handles slightly after the parent process observes exit. Keep macOS/Linux cleanup on the existing single-pass removal path, but on Windows native-artifact smoke scripts retry temp directory removal for a bounded window before treating it as a real failure. - Windows managed-runtime installer outputs can be valid UTF-8 JSON files with a leading BOM when produced through Windows-native writers. Keep macOS/Linux JSON parsing unchanged, but Windows artifact/native-runtime smoke helpers must tolerate an optional leading BOM before `JSON.parse` so bundled-provider install verification does not fail on otherwise valid JSON. - Windows managed provider CLI shims installed by npm do not carry a private `node.exe`; they expect the managed provider `.bin` directory and the managed Node directory on `PATH`, plus the managed npm prefix/cache environment, exactly as the packaged bundled-runtime launcher provides. Keep macOS/Linux artifact smoke unchanged, but Windows native-artifact smoke must probe `codex.cmd`/`claude.cmd` with that Cafe-managed environment instead of assuming the ambient user PATH can run the shims directly. -- Windows batch shims launched through `cmd.exe /c` need the entire command string quoted when the shim path itself is quoted, for example `""C:\path\codex.cmd" --version"`. Without that outer quote pair, `cmd.exe` treats the quoted filename token as the literal command name and reports `"path\codex.cmd"` as not recognized even when the managed PATH/npm environment is correct. Keep macOS/Linux smoke paths unchanged. +- Windows batch shims launched through `cmd.exe /c` need the entire command string quoted when the shim path itself is quoted, for example `""C:\path\codex.cmd" --version"`. Without that outer quote pair, `cmd.exe` treats the quoted filename token as the literal command name and reports `"path\codex.cmd"` as not recognized even when the managed PATH/npm environment is correct. When spawning that `/c` payload from Node on Windows, also set `windowsVerbatimArguments=true`; otherwise Node escapes the outer quotes during Windows command-line serialization and `cmd.exe` still treats the whole payload as a literal command name. Keep macOS/Linux smoke paths unchanged. - Windows source and artifact builds require the pinned standalone Node runtime. Server build helpers should invoke TypeScript entrypoints through `process.execPath` and use Corepack only for locked Yarn package operations. Preserve the direct Node path on macOS/Linux and avoid platform-specific package-manager shims. ## Project Snapshot diff --git a/scripts/windows-native-artifact-smoke.test.ts b/scripts/windows-native-artifact-smoke.test.ts index 28c694f3f..da5b67dba 100644 --- a/scripts/windows-native-artifact-smoke.test.ts +++ b/scripts/windows-native-artifact-smoke.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { parseJsonText } from "./json-file.ts"; import { buildWindowsCmdCommand, + buildWindowsCmdInvocation, buildManagedProviderProbeEnvironment, removePathWithRetries, selectInstalledWindowsExecutables, @@ -83,13 +84,20 @@ describe("Windows native artifact smoke", () => { }); it("quotes managed Windows shim commands for cmd.exe", () => { - assert.equal( - buildWindowsCmdCommand( - "C:\\Users\\runneradmin\\AppData\\Local\\CafeCode\\managed\\providers\\codex\\current\\node_modules\\.bin\\codex.cmd", - ["--version"], - ), - '""C:\\Users\\runneradmin\\AppData\\Local\\CafeCode\\managed\\providers\\codex\\current\\node_modules\\.bin\\codex.cmd" --version"', - ); + const shim = + "C:\\Users\\runneradmin\\AppData\\Local\\CafeCode\\managed\\providers\\codex\\current\\node_modules\\.bin\\codex.cmd"; + assert.equal(buildWindowsCmdCommand(shim, ["--version"]), `""${shim}" --version"`); + assert.deepEqual(buildWindowsCmdInvocation(shim, ["--version"]), { + command: "cmd.exe", + args: ["/d", "/s", "/c", `""${shim}" --version"`], + windowsVerbatimArguments: true, + }); + }); + + it("supports managed Windows shim commands without arguments", () => { + const shim = + "C:\\Users\\runneradmin\\AppData\\Local\\CafeCode\\managed\\providers\\claude\\current\\node_modules\\.bin\\claude.cmd"; + assert.equal(buildWindowsCmdCommand(shim, []), `""${shim}""`); }); it("retries transient Windows cleanup errors before removing the smoke root", async () => { diff --git a/scripts/windows-native-artifact-smoke.ts b/scripts/windows-native-artifact-smoke.ts index 06edaf638..5d6cb233f 100644 --- a/scripts/windows-native-artifact-smoke.ts +++ b/scripts/windows-native-artifact-smoke.ts @@ -25,16 +25,23 @@ interface CleanupDependencies { type ManagedProviderSlug = "codex" | "claude"; +interface ProcessOptions { + readonly env?: NodeJS.ProcessEnv; + readonly timeoutMs?: number; + readonly windowsVerbatimArguments?: boolean; +} + async function runProcess( command: string, args: readonly string[], - options: { readonly env?: NodeJS.ProcessEnv; readonly timeoutMs?: number } = {}, + options: ProcessOptions = {}, ): Promise { return await new Promise((resolveProcess, reject) => { const child = spawn(command, [...args], { env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, }); let stdout = ""; let stderr = ""; @@ -182,6 +189,24 @@ export function buildWindowsCmdCommand(commandPath: string, args: readonly strin return `""${commandPath}"${renderedArgs.length > 0 ? ` ${renderedArgs}` : ""}"`; } +export function buildWindowsCmdInvocation( + commandPath: string, + args: readonly string[], +): { + readonly command: "cmd.exe"; + readonly args: readonly ["/d", "/s", "/c", string]; + readonly windowsVerbatimArguments: true; +} { + return { + command: "cmd.exe", + args: ["/d", "/s", "/c", buildWindowsCmdCommand(commandPath, args)], + // cmd.exe parses /c payloads differently than CommandLineToArgvW. Passing + // the serialized command through Node's default Windows quoting escapes the + // outer quotes and makes cmd treat the whole payload as a literal filename. + windowsVerbatimArguments: true, + }; +} + async function assertManagedProviderRuntime(managedRoot: string): Promise { const resultPath = join(managedRoot, "install-result.json"); const result = readRecord(await readJsonFile(resultPath)); @@ -213,14 +238,12 @@ async function assertManagedProviderRuntime(managedRoot: string): Promise const installRoot = join(managedRoot, "providers", provider, "current"); const shim = join(installRoot, "node_modules", ".bin", executable); if (!existsSync(shim)) throw new Error(`Managed ${provider} shim is missing.`); - const probe = await runProcess( - "cmd.exe", - ["/d", "/s", "/c", buildWindowsCmdCommand(shim, ["--version"])], - { - env: buildManagedProviderProbeEnvironment(managedRoot, provider, process.env), - timeoutMs: 60_000, - }, - ); + const probeInvocation = buildWindowsCmdInvocation(shim, ["--version"]); + const probe = await runProcess(probeInvocation.command, probeInvocation.args, { + env: buildManagedProviderProbeEnvironment(managedRoot, provider, process.env), + timeoutMs: 60_000, + windowsVerbatimArguments: probeInvocation.windowsVerbatimArguments, + }); assertSuccessful(probe, `Managed ${provider} version probe`); } } From 405599509f9fdf0eb57553c07ea50bfecebc62c5 Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 21:00:26 +0100 Subject: [PATCH 08/12] Wait for Windows uninstall side effects in smoke --- AGENTS.md | 2 +- scripts/windows-native-artifact-smoke.test.ts | 28 +++++++++++++++++++ scripts/windows-native-artifact-smoke.ts | 26 ++++++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4fc16edfd..f3c786977 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ - Windows `cafe-code killall` process discovery must also spawn `powershell.exe` directly with `shell: false` and consume `Get-CimInstance Win32_Process` JSON. Keep macOS/Linux on targeted `ps -axo pid=,ppid=,command=` discovery, and keep the command's current process plus ancestors protected so `cafe-code killall` does not terminate its own launcher before it finishes reporting results. - Windows source checkouts may start Cafe through `Start-CafeCode.ps1`, which checks whether `openssl.exe`/`openssl` is available as an application on `PATH` before launch. On Windows CI and developer machines, `Get-Command` can return multiple Node application matches (for example an `actions/setup-node` toolcache path plus a preinstalled Node path), so the launcher must select one executable path deterministically before probing `--version` or calling `Start-Process`; do not concatenate multiple matches into one command string. The launcher should preserve default local HTTPS when OpenSSL is present, and set `CAFE_CODE_HTTPS_ENABLED=false` only when OpenSSL is missing because source installs do not always have it available. Desktop backend configuration must also propagate `CAFE_CODE_HTTPS_ENABLED=false` whenever `DesktopServerExposure` resolves no HTTPS port, while keeping macOS/Linux behavior unchanged by deriving the child environment from the shared exposure config rather than from a platform branch. - Windows packaged desktop startup must also verify that `openssl.exe`/`openssl` can run before allocating an HTTPS sibling port. If OpenSSL is unavailable, keep the user's persisted HTTPS preference unchanged but start the backend HTTP-only for that run so certificate generation cannot crash-loop before the window opens. macOS/Linux behavior remains unchanged because the packaged runtime only applies this OpenSSL prerequisite gate on Windows. -- Windows NSIS artifact smoke cleanup can see short-lived `EBUSY`/`EPERM`/`ENOTEMPTY` directory removal failures immediately after the packaged app or uninstaller exits because Windows can release process/file handles slightly after the parent process observes exit. Keep macOS/Linux cleanup on the existing single-pass removal path, but on Windows native-artifact smoke scripts retry temp directory removal for a bounded window before treating it as a real failure. +- Windows NSIS artifact smoke cleanup can see short-lived `EBUSY`/`EPERM`/`ENOTEMPTY` directory removal failures immediately after the packaged app or uninstaller exits because Windows can release process/file handles slightly after the parent process observes exit. NSIS uninstall can also briefly leave the branded application executable present immediately after the uninstaller process reports exit while post-uninstall deletion is still draining. Keep macOS/Linux cleanup on the existing single-pass removal path, but on Windows native-artifact smoke scripts retry temp directory removal and wait a bounded window for uninstall side effects before treating them as real failures. - Windows managed-runtime installer outputs can be valid UTF-8 JSON files with a leading BOM when produced through Windows-native writers. Keep macOS/Linux JSON parsing unchanged, but Windows artifact/native-runtime smoke helpers must tolerate an optional leading BOM before `JSON.parse` so bundled-provider install verification does not fail on otherwise valid JSON. - Windows managed provider CLI shims installed by npm do not carry a private `node.exe`; they expect the managed provider `.bin` directory and the managed Node directory on `PATH`, plus the managed npm prefix/cache environment, exactly as the packaged bundled-runtime launcher provides. Keep macOS/Linux artifact smoke unchanged, but Windows native-artifact smoke must probe `codex.cmd`/`claude.cmd` with that Cafe-managed environment instead of assuming the ambient user PATH can run the shims directly. - Windows batch shims launched through `cmd.exe /c` need the entire command string quoted when the shim path itself is quoted, for example `""C:\path\codex.cmd" --version"`. Without that outer quote pair, `cmd.exe` treats the quoted filename token as the literal command name and reports `"path\codex.cmd"` as not recognized even when the managed PATH/npm environment is correct. When spawning that `/c` payload from Node on Windows, also set `windowsVerbatimArguments=true`; otherwise Node escapes the outer quotes during Windows command-line serialization and `cmd.exe` still treats the whole payload as a literal command name. Keep macOS/Linux smoke paths unchanged. diff --git a/scripts/windows-native-artifact-smoke.test.ts b/scripts/windows-native-artifact-smoke.test.ts index da5b67dba..dc027678c 100644 --- a/scripts/windows-native-artifact-smoke.test.ts +++ b/scripts/windows-native-artifact-smoke.test.ts @@ -9,6 +9,7 @@ import { removePathWithRetries, selectInstalledWindowsExecutables, selectWindowsInstaller, + waitForPathToDisappear, } from "./windows-native-artifact-smoke.ts"; describe("Windows native artifact smoke", () => { @@ -142,4 +143,31 @@ describe("Windows native artifact smoke", () => { assert.match((error as Error).message, /busy/); assert.equal(attempts, 1); }); + + it("waits for Windows uninstall side effects to remove the app executable", async () => { + let attempts = 0; + const waits: number[] = []; + const disappeared = await waitForPathToDisappear("C:\\temp\\Cafe Code\\Cafe Code.exe", { + platform: "win32", + exists: () => { + attempts += 1; + return attempts < 3; + }, + sleep: async (ms) => { + waits.push(ms); + }, + }); + assert.equal(disappeared, true); + assert.equal(attempts, 3); + assert.deepEqual(waits, [250, 250]); + }); + + it("fails fast for persistent uninstall leftovers on non-Windows", async () => { + const disappeared = await waitForPathToDisappear("/tmp/Cafe Code/Cafe Code.exe", { + platform: "linux", + exists: () => true, + sleep: async () => undefined, + }); + assert.equal(disappeared, false); + }); }); diff --git a/scripts/windows-native-artifact-smoke.ts b/scripts/windows-native-artifact-smoke.ts index 5d6cb233f..1a944df1b 100644 --- a/scripts/windows-native-artifact-smoke.ts +++ b/scripts/windows-native-artifact-smoke.ts @@ -23,6 +23,12 @@ interface CleanupDependencies { readonly sleep?: (ms: number) => Promise; } +interface PathDisappearanceDependencies { + readonly platform?: NodeJS.Platform; + readonly exists?: (path: string) => boolean; + readonly sleep?: (ms: number) => Promise; +} + type ManagedProviderSlug = "codex" | "claude"; interface ProcessOptions { @@ -114,6 +120,24 @@ export async function removePathWithRetries( } } +export async function waitForPathToDisappear( + targetPath: string, + dependencies: PathDisappearanceDependencies = {}, +): Promise { + const platform = dependencies.platform ?? process.platform; + const pathExists = dependencies.exists ?? existsSync; + const wait = dependencies.sleep ?? sleep; + + for (let attempt = 0; ; attempt += 1) { + if (!pathExists(targetPath)) return true; + if (platform !== "win32" || attempt >= WINDOWS_CLEANUP_RETRY_ATTEMPTS - 1) { + return false; + } + // NSIS can report exit before post-uninstall file deletion finishes. + await wait(WINDOWS_CLEANUP_RETRY_DELAY_MS); + } +} + export function selectWindowsInstaller(fileNames: readonly string[]): string { const matches = fileNames.filter( (fileName) => /^Cafe-Code-.+-x64\.exe$/.test(fileName) && !fileName.startsWith("Uninstall"), @@ -284,7 +308,7 @@ export async function runWindowsNativeArtifactSmoke( const uninstall = await runProcess(uninstallerPath, ["/S"], { timeoutMs: 5 * 60_000 }); assertSuccessful(uninstall, "NSIS uninstall"); - if (existsSync(appPath)) + if (!(await waitForPathToDisappear(appPath))) throw new Error("NSIS uninstall left the application executable behind."); uninstallerPath = undefined; console.info("Windows NSIS install/runtime/managed-provider/uninstall smoke passed."); From 5391d23902d1a2010c7661762e9ba7a048cd2bb3 Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 22:09:57 +0100 Subject: [PATCH 09/12] Remove CI artifact smoke checks --- .github/workflows/ci.yml | 49 ---------------------------------------- 1 file changed, 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dc868a20..58c947c16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,34 +72,6 @@ jobs: - name: Build desktop pipeline run: corepack yarn build:desktop - - name: Windows source launcher runtime self-test - if: runner.os == 'Windows' - shell: pwsh - run: | - $resultPath = Join-Path $env:RUNNER_TEMP "cafecode-source-runtime-self-test.json" - $env:CAFE_CODE_RUNTIME_SELF_TEST_RESULT = $resultPath - & .\Start-CafeCode.ps1 -Wait -DesktopArgs "--cafe-runtime-self-test" - if ($LASTEXITCODE -ne 0) { - Get-Content -LiteralPath (Join-Path $env:USERPROFILE ".cafe-code\launcher-logs\desktop-start.stderr.log") -ErrorAction SilentlyContinue - throw "Windows source launcher runtime self-test exited with code $LASTEXITCODE." - } - $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json - if (-not $result.ok -or $result.isPackaged) { - throw "Windows source launcher runtime self-test reported failure." - } - - - name: macOS source runtime self-test - if: runner.os == 'macOS' - shell: bash - run: | - result_path="$RUNNER_TEMP/cafecode-source-runtime-self-test.json" - CAFE_CODE_RUNTIME_SELF_TEST_RESULT="$result_path" corepack yarn workspace @cafecode/desktop exec electron dist-electron/main.cjs --cafe-runtime-self-test - node -e 'const fs=require("node:fs"); const result=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if(!result.ok || result.isPackaged) process.exit(1)' "$result_path" - - - name: Release smoke - if: runner.os == 'Linux' - run: corepack yarn release:smoke - - name: Install browser test runtime if: runner.os == 'Linux' run: corepack yarn workspace @cafecode/web exec playwright install --with-deps chromium @@ -154,14 +126,6 @@ jobs: - name: Build native artifact run: corepack yarn ${{ matrix.command }} - - name: Smoke Windows installer and packaged runtime - if: runner.os == 'Windows' - run: corepack yarn test:native-windows-artifact - - - name: Smoke macOS images and packaged runtime - if: runner.os == 'macOS' - run: corepack yarn test:native-macos-artifact - - name: Upload native artifact uses: actions/upload-artifact@v6 with: @@ -202,19 +166,6 @@ jobs: - name: Build AppImage run: corepack yarn dist:desktop:linux - - name: Install packaged runtime smoke dependencies - run: | - sudo apt-get update - sudo apt-get install --no-install-recommends -y dbus-x11 gnome-keyring libasound2t64 libgbm1 libnspr4 libnss3 libsecret-1-0 xauth xvfb - - - name: Smoke AppImage runtime and artifact - shell: bash - run: | - dbus-run-session -- bash -euo pipefail <<'SCRIPT' - eval "$(printf '%s\n' cafecode-ci-keyring | gnome-keyring-daemon --unlock --components=secrets)" - xvfb-run --auto-servernum corepack yarn test:native-linux-artifact - SCRIPT - - name: Upload AppImage uses: actions/upload-artifact@v6 with: From 54c38f9c8498e3f3fa8a16a9294459889b9bc175 Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 22:22:36 +0100 Subject: [PATCH 10/12] Allow slower Windows certificate generation --- AGENTS.md | 1 + apps/server/src/httpsCertificate.test.ts | 14 +++++++++++++- apps/server/src/httpsCertificate.ts | 8 +++++++- apps/server/vitest.config.ts | 6 ++++-- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f3c786977..286929fa0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ - Windows `cafe-code killall` process discovery must also spawn `powershell.exe` directly with `shell: false` and consume `Get-CimInstance Win32_Process` JSON. Keep macOS/Linux on targeted `ps -axo pid=,ppid=,command=` discovery, and keep the command's current process plus ancestors protected so `cafe-code killall` does not terminate its own launcher before it finishes reporting results. - Windows source checkouts may start Cafe through `Start-CafeCode.ps1`, which checks whether `openssl.exe`/`openssl` is available as an application on `PATH` before launch. On Windows CI and developer machines, `Get-Command` can return multiple Node application matches (for example an `actions/setup-node` toolcache path plus a preinstalled Node path), so the launcher must select one executable path deterministically before probing `--version` or calling `Start-Process`; do not concatenate multiple matches into one command string. The launcher should preserve default local HTTPS when OpenSSL is present, and set `CAFE_CODE_HTTPS_ENABLED=false` only when OpenSSL is missing because source installs do not always have it available. Desktop backend configuration must also propagate `CAFE_CODE_HTTPS_ENABLED=false` whenever `DesktopServerExposure` resolves no HTTPS port, while keeping macOS/Linux behavior unchanged by deriving the child environment from the shared exposure config rather than from a platform branch. - Windows packaged desktop startup must also verify that `openssl.exe`/`openssl` can run before allocating an HTTPS sibling port. If OpenSSL is unavailable, keep the user's persisted HTTPS preference unchanged but start the backend HTTP-only for that run so certificate generation cannot crash-loop before the window opens. macOS/Linux behavior remains unchanged because the packaged runtime only applies this OpenSSL prerequisite gate on Windows. +- Git for Windows OpenSSL can take longer than 20 seconds to generate an RSA certificate while the host is under the full parallel `turbo` test load. Allow the certificate subprocess up to 60 seconds and the Windows server test process up to 90 seconds so the parent timeout does not race it. Keep the existing 20-second OpenSSL subprocess timeout and 60-second server test timeout on macOS/Linux. - Windows NSIS artifact smoke cleanup can see short-lived `EBUSY`/`EPERM`/`ENOTEMPTY` directory removal failures immediately after the packaged app or uninstaller exits because Windows can release process/file handles slightly after the parent process observes exit. NSIS uninstall can also briefly leave the branded application executable present immediately after the uninstaller process reports exit while post-uninstall deletion is still draining. Keep macOS/Linux cleanup on the existing single-pass removal path, but on Windows native-artifact smoke scripts retry temp directory removal and wait a bounded window for uninstall side effects before treating them as real failures. - Windows managed-runtime installer outputs can be valid UTF-8 JSON files with a leading BOM when produced through Windows-native writers. Keep macOS/Linux JSON parsing unchanged, but Windows artifact/native-runtime smoke helpers must tolerate an optional leading BOM before `JSON.parse` so bundled-provider install verification does not fail on otherwise valid JSON. - Windows managed provider CLI shims installed by npm do not carry a private `node.exe`; they expect the managed provider `.bin` directory and the managed Node directory on `PATH`, plus the managed npm prefix/cache environment, exactly as the packaged bundled-runtime launcher provides. Keep macOS/Linux artifact smoke unchanged, but Windows native-artifact smoke must probe `codex.cmd`/`claude.cmd` with that Cafe-managed environment instead of assuming the ambient user PATH can run the shims directly. diff --git a/apps/server/src/httpsCertificate.test.ts b/apps/server/src/httpsCertificate.test.ts index 3e5966778..73bdb20d9 100644 --- a/apps/server/src/httpsCertificate.test.ts +++ b/apps/server/src/httpsCertificate.test.ts @@ -4,7 +4,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { ensureHttpsCertificateMaterial, resolveOpenSslExecutable } from "./httpsCertificate.ts"; +import { + ensureHttpsCertificateMaterial, + openSslGenerationTimeoutMs, + resolveOpenSslExecutable, +} from "./httpsCertificate.ts"; it.layer(NodeServices.layer)("ensureHttpsCertificateMaterial", (it) => { it.effect("resolves Git for Windows OpenSSL when it is installed outside PATH", () => @@ -42,6 +46,14 @@ it.layer(NodeServices.layer)("ensureHttpsCertificateMaterial", (it) => { }), ); + it.effect("allows slower certificate generation only on Windows", () => + Effect.sync(() => { + assert.equal(openSslGenerationTimeoutMs("win32"), 60_000); + assert.equal(openSslGenerationTimeoutMs("linux"), 20_000); + assert.equal(openSslGenerationTimeoutMs("darwin"), 20_000); + }), + ); + it.effect("generates and reuses self-signed certificate material", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/httpsCertificate.ts b/apps/server/src/httpsCertificate.ts index 345979d33..bb3361e75 100644 --- a/apps/server/src/httpsCertificate.ts +++ b/apps/server/src/httpsCertificate.ts @@ -17,6 +17,8 @@ const execFileAsync = promisify(execFile); const CERT_VALID_DAYS = 397; const CERT_RENEWAL_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; const OPENSSL_EXECUTABLE_NAME = "openssl"; +const OPENSSL_GENERATION_TIMEOUT_MS = 20_000; +const WINDOWS_OPENSSL_GENERATION_TIMEOUT_MS = 60_000; export interface HttpsCertificateMaterial { readonly cert: string; @@ -119,6 +121,9 @@ export const resolveOpenSslExecutable = async ( return OPENSSL_EXECUTABLE_NAME; }; +export const openSslGenerationTimeoutMs = (platform: NodeJS.Platform = process.platform): number => + platform === "win32" ? WINDOWS_OPENSSL_GENERATION_TIMEOUT_MS : OPENSSL_GENERATION_TIMEOUT_MS; + const certificateIsFresh = async (certPath: string, keyPath: string): Promise => { try { const [certPem, keyPem] = await Promise.all([ @@ -184,7 +189,8 @@ const generateCertificate = async (input: { "-out", tempCertPath, ], - { timeout: 20_000 }, + // Git for Windows OpenSSL can be substantially slower under parallel CI load. + { timeout: openSslGenerationTimeoutMs() }, ); await fs.chmod(tempKeyPath, 0o600); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 7e8213ec9..c904a2b98 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -2,6 +2,8 @@ import { configDefaults, defineConfig, mergeConfig } from "vitest/config"; import baseConfig from "../../vitest.config.ts"; +const SERVER_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000; + export default mergeConfig( baseConfig, defineConfig({ @@ -18,8 +20,8 @@ export default mergeConfig( isolate: true, // Server integration tests exercise sqlite, git, and orchestration together. // Under package-wide parallel runs they regularly exceed the default 15s budget. - testTimeout: 60_000, - hookTimeout: 60_000, + testTimeout: SERVER_TEST_TIMEOUT_MS, + hookTimeout: SERVER_TEST_TIMEOUT_MS, }, }), ); From 67af71a9dd945eb61ef2191066425d8afddd3e6e Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 23:25:49 +0100 Subject: [PATCH 11/12] Enable unsigned desktop release updates --- .github/workflows/release.yml | 304 ++++++++++++++++++ AGENTS.md | 2 + .../app/DesktopUpdateDetectionProbe.test.ts | 74 +++++ .../src/app/DesktopUpdateDetectionProbe.ts | 227 +++++++++++++ apps/desktop/src/main.ts | 8 +- apps/desktop/src/updates/DesktopUpdates.ts | 46 +-- .../src/updates/updateEligibility.test.ts | 53 +++ apps/desktop/src/updates/updateEligibility.ts | 34 ++ .../desktop/src/updates/updateMachine.test.ts | 9 + apps/desktop/src/updates/updateMachine.ts | 3 + apps/web/src/components/Sidebar.tsx | 29 +- .../components/desktopUpdate.logic.test.ts | 50 +++ .../web/src/components/desktopUpdate.logic.ts | 15 +- .../settings/SettingsPanels.browser.tsx | 1 + .../components/sidebar/SidebarUpdatePill.tsx | 29 +- docs/desktop-releases-and-updates.md | 42 +++ docs/release-readiness-worklist.md | 8 +- package.json | 1 + packages/contracts/src/ipc.ts | 4 + scripts/build-desktop-artifact.ts | 6 + scripts/validate-update-release.test.ts | 100 ++++++ scripts/validate-update-release.ts | 214 ++++++++++++ 22 files changed, 1220 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 apps/desktop/src/app/DesktopUpdateDetectionProbe.test.ts create mode 100644 apps/desktop/src/app/DesktopUpdateDetectionProbe.ts create mode 100644 apps/desktop/src/updates/updateEligibility.test.ts create mode 100644 apps/desktop/src/updates/updateEligibility.ts create mode 100644 apps/web/src/components/desktopUpdate.logic.test.ts create mode 100644 docs/desktop-releases-and-updates.md create mode 100644 scripts/validate-update-release.test.ts create mode 100644 scripts/validate-update-release.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..0c1b5b346 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,304 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +concurrency: + group: desktop-release-${{ github.ref_name }} + cancel-in-progress: false + +env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + +jobs: + prepare: + name: Prepare release + runs-on: ubuntu-24.04 + outputs: + channel: ${{ steps.release.outputs.channel }} + prerelease: ${{ steps.release.outputs.prerelease }} + probe-version: ${{ steps.release.outputs.probe-version }} + version: ${{ steps.release.outputs.version }} + steps: + - name: Validate release tag + id: release + shell: bash + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + version="${tag#v}" + if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + channel="latest" + prerelease="false" + probe_version="${version}-canary.0" + elif [[ "${version}" =~ ^([0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8})\.([1-9][0-9]*)$ ]]; then + channel="nightly" + prerelease="true" + probe_version="${BASH_REMATCH[1]}.0" + else + echo "Unsupported release tag: ${tag}" >&2 + echo "Expected vX.Y.Z or vX.Y.Z-nightly.YYYYMMDD.N where N is at least 1." >&2 + exit 1 + fi + { + echo "channel=${channel}" + echo "prerelease=${prerelease}" + echo "probe-version=${probe_version}" + echo "version=${version}" + } >> "${GITHUB_OUTPUT}" + + quality: + name: Release quality + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24.13.1 + + - name: Activate pinned Yarn + run: | + corepack enable + corepack prepare yarn@4.17.1 --activate + + - name: Cache Yarn and Turbo + uses: actions/cache@v5 + with: + path: | + ~/.yarn/berry + .turbo + key: ${{ runner.os }}-${{ runner.arch }}-release-yarn-${{ hashFiles('yarn.lock') }}-${{ hashFiles('turbo.json') }} + + - name: Install dependencies + run: corepack yarn install --immutable + + - name: Verify source + run: | + corepack yarn fmt:check + corepack yarn lint + corepack yarn typecheck + corepack yarn test + corepack yarn build:desktop + + artifacts: + name: Build ${{ matrix.name }} + needs: [prepare, quality] + strategy: + fail-fast: false + matrix: + include: + - name: windows-x64 + os: windows-2025 + command: dist:desktop:win:x64 + - name: macos-arm64 + os: macos-15 + command: dist:desktop:dmg:arm64 + - name: macos-x64 + os: macos-15-intel + command: dist:desktop:dmg:x64 + - name: linux-x64 + os: ubuntu-24.04 + command: dist:desktop:linux + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + env: + CAFE_CODE_DESKTOP_SIGNED: "false" + CAFE_CODE_DESKTOP_VERSION: ${{ needs.prepare.outputs.version }} + CSC_IDENTITY_AUTO_DISCOVERY: "false" + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24.13.1 + + - name: Activate pinned Yarn + run: | + corepack enable + corepack prepare yarn@4.17.1 --activate + + - name: Cache Yarn and Turbo + uses: actions/cache@v5 + with: + path: | + ~/.yarn/berry + .turbo + key: ${{ runner.os }}-${{ runner.arch }}-release-yarn-${{ hashFiles('yarn.lock') }}-${{ hashFiles('turbo.json') }} + + - name: Install dependencies + run: corepack yarn install --immutable + + - name: Build unsigned artifact + run: corepack yarn ${{ matrix.command }} + + - name: Build lower-version AppImage probe + if: matrix.name == 'linux-x64' + env: + CAFE_CODE_DESKTOP_OUTPUT_DIR: release-probe + CAFE_CODE_DESKTOP_VERSION: ${{ needs.prepare.outputs.probe-version }} + run: corepack yarn dist:desktop:linux + + - name: Upload release artifact + uses: actions/upload-artifact@v6 + with: + name: cafe-code-release-${{ matrix.name }} + path: release/ + if-no-files-found: error + retention-days: 7 + + - name: Upload packaged detection probe + if: matrix.name == 'linux-x64' + uses: actions/upload-artifact@v6 + with: + name: cafe-code-update-probe-linux-x64 + path: release-probe/*.AppImage + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish unsigned release + needs: [prepare, artifacts] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_CHANNEL: ${{ needs.prepare.outputs.channel }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24.13.1 + + - name: Activate pinned Yarn + run: | + corepack enable + corepack prepare yarn@4.17.1 --activate + + - name: Install dependencies + run: corepack yarn install --immutable + + - name: Download native artifacts + uses: actions/download-artifact@v7 + with: + pattern: cafe-code-release-* + path: release-assets + + - name: Assemble update feeds + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + mkdir -p release-publish + + for file in release-assets/cafe-code-release-windows-x64/*; do + case "${file}" in + *.exe|*.blockmap|*/${RELEASE_CHANNEL}.yml) cp "${file}" release-publish/ ;; + esac + done + for file in release-assets/cafe-code-release-linux-x64/*; do + case "${file}" in + *.AppImage|*.blockmap|*/${RELEASE_CHANNEL}-linux.yml) cp "${file}" release-publish/ ;; + esac + done + for arch in arm64 x64; do + for file in release-assets/cafe-code-release-macos-${arch}/*; do + case "${file}" in + *.dmg|*.zip|*.blockmap) cp "${file}" release-publish/ ;; + esac + done + done + + node scripts/merge-update-manifests.ts \ + --platform mac \ + "release-assets/cafe-code-release-macos-arm64/${RELEASE_CHANNEL}-mac.yml" \ + "release-assets/cafe-code-release-macos-x64/${RELEASE_CHANNEL}-mac.yml" \ + "release-publish/${RELEASE_CHANNEL}-mac.yml" + + - name: Validate release manifests and checksums + run: >- + corepack yarn validate:desktop-release + --release-dir release-publish + --version "${RELEASE_VERSION}" + --channel "${RELEASE_CHANNEL}" + + - name: Create GitHub release + shell: bash + run: | + set -euo pipefail + args=( + "${GITHUB_REF_NAME}" + release-publish/* + --verify-tag + --title "Cafe Code ${RELEASE_VERSION}" + --notes "Unsigned desktop release. Windows uses NSIS, macOS provides arm64/x64 DMGs, and Linux support is x64 AppImage only. Verify manual downloads with SHA256SUMS.txt." + ) + if [[ "${{ needs.prepare.outputs.prerelease }}" == "true" ]]; then + args+=(--prerelease) + fi + gh release create "${args[@]}" + + detect-update: + name: Detect published GitHub update + needs: [prepare, publish] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Download lower-version packaged probe + uses: actions/download-artifact@v7 + with: + name: cafe-code-update-probe-linux-x64 + path: update-probe + + - name: Run detection-only AppImage probe + shell: bash + env: + CAFE_CODE_UPDATE_DETECTION_CHANNEL: ${{ needs.prepare.outputs.channel }} + CAFE_CODE_UPDATE_DETECTION_EXPECT_VERSION: ${{ needs.prepare.outputs.version }} + CAFE_CODE_UPDATE_DETECTION_RESULT: ${{ runner.temp }}/cafe-update-detection.json + run: | + set -euo pipefail + probe=(update-probe/*.AppImage) + if [[ ! -f "${probe[0]}" ]]; then + echo "Packaged update probe AppImage is missing." >&2 + exit 1 + fi + chmod 0755 "${probe[0]}" + probe_path="$(realpath "${probe[0]}")" + probe_log="${RUNNER_TEMP}/cafe-update-detection.log" + if ! env APPIMAGE="${probe_path}" APPIMAGE_EXTRACT_AND_RUN=1 \ + "${probe_path}" \ + --no-sandbox \ + --headless \ + --disable-gpu \ + --cafe-update-detection-probe \ + > "${probe_log}" 2>&1; then + tail -100 "${probe_log}" >&2 + exit 1 + fi + tail -20 "${probe_log}" + node -e ' + const fs = require("node:fs"); + const result = JSON.parse(fs.readFileSync(process.env.CAFE_CODE_UPDATE_DETECTION_RESULT, "utf8")); + if (!result.ok || !result.updateAvailable || result.availableVersion !== process.env.CAFE_CODE_UPDATE_DETECTION_EXPECT_VERSION) { + console.error(result); + process.exit(1); + } + console.log(JSON.stringify(result)); + ' diff --git a/AGENTS.md b/AGENTS.md index 286929fa0..118674cc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ - Keep Windows-only fixes documented in this section before implementation. Scope them so they do not change macOS or Linux behavior unless the task explicitly asks for a cross-platform change. Document how macOS/Linux behavior is preserved, and verify at least `yarn build:desktop` plus the relevant Windows command before considering a Windows fix complete. - Windows NSIS packaging uses `electron-builder`, which rejects a staged app if `electron` appears in `dependencies`; Electron must be present only in `devDependencies` for the staged builder package. Because `apps/server/package.json` currently lists `electron` among runtime dependencies, `scripts/build-desktop-artifact.ts` must omit `electron` from staged runtime dependencies while still adding the desktop Electron version to staged `devDependencies`. This is a staged package shape fix, not a runtime dependency or platform-target change. It preserves macOS/Linux behavior by leaving shared build config, platform target selection, icons, signing/publish configuration, and non-Electron staged runtime dependencies unchanged. - Unsigned Windows NSIS artifacts should set `win.signExecutable=false`, not `win.signAndEditExecutable=false`. This skips code signing while preserving executable resource editing and prevents NSIS helper/uninstaller signing paths from running during local unsigned builds. Keep signed Azure Trusted Signing builds on the existing `azureSignOptions` path. +- Unsigned Windows GitHub releases use the NSIS updater path with manifest SHA-512 validation but no Authenticode publisher validation. The tagged release workflow must keep `CAFE_CODE_DESKTOP_SIGNED=false`, publish the x64 installer plus its complete update manifest, and run the installer's existing `--updated` path without rerunning fresh-install managed-provider bootstrap. This Windows policy does not change unsigned macOS behavior (manual DMG replacement because Squirrel.Mac requires signing) or Linux behavior (x64 AppImage only). - Windows Codex shadow homes must not require Developer Mode or administrator symlink privileges. Keep macOS/Linux on the existing symlink-based layout, but on Windows use Windows-safe links for shared entries: directory junctions for shared directories, hard links or copies for shared files, and real copied private files for `auth.json`. Verify the Windows path without changing POSIX shadow-home behavior. - Windows Store/MSIX Codex installs expose private app resources under `C:\Program Files\WindowsApps` that can appear on `PATH` but fail with Access Denied outside the package identity. Windows-only Codex CLI autodetection should prefer user-owned Codex cache bins under `%LOCALAPPDATA%\OpenAI\Codex\bin\` ahead of WindowsApps resources. Do not apply this path hydration on macOS or Linux, and keep explicit user-configured `binaryPath` values authoritative. - Windows Codex and Claude provider instances can choose `runtimeSource: "system"` or `runtimeSource: "bundled"`. System mode is the default and preserves existing `binaryPath`/PATH behavior on every platform. Bundled mode is Windows-only: it must resolve provider shims and npm from Cafe-managed, user-writable paths under `%LOCALAPPDATA%\CafeCode\managed` or explicit Cafe managed-runtime environment overrides, never from global PATH, and macOS/Linux must report the bundled runtime as unsupported rather than silently falling back to system binaries. @@ -136,6 +137,7 @@ Cafe Code has three important runtime layers: - Main backend/server: owns app-level orchestration, event sourcing, projections, settings, WebSocket pushes, HTTP routes, and durable persistence. - Provider runtime process: the provider daemon owns long-running provider adapters and live sessions by default so provider work can survive UI/backend restarts when possible. A persistent provider supervisor still exists as an explicit runtime role, but automatic provider-daemon handoff to that extra supervisor hop is disabled until supervisor restart/fallback preserves the provider registry and active-session truth under dead IPC sockets. - Renderer environment runtime: the renderer always has one primary backend connection and may hydrate additional saved Cafe server connections. Every connection owns its authenticated RPC client and projection subscriptions; environment routing must remain explicit and must not change the primary backend's identity. +- Packaged desktop updates use stable `latest` or prerelease `nightly` GitHub Release feeds. Eligible builds check after startup and on a bounded poll without automatic download. Unsigned Windows x64 NSIS and Linux x64 AppImage builds expose explicit user-driven download/install, while unsigned macOS arm64/x64 builds expose detection plus an exact GitHub release link for manual DMG replacement because Squirrel.Mac requires code signing. Tagged releases validate manifest asset sizes and SHA-512 hashes before publication, then run a lower-version packaged AppImage detection-only probe against the public feed. Source-checkout Git branch diagnostics remain separate from packaged releases. - The desktop backend manager treats both sustained repeated backend HTTP health failures and bootstrap readiness timeouts as terminal backend run conditions even if the child process remains alive after termination is requested. This prevents a split-brain state where the backend PID is still present but the HTTP listener is gone, leaving the renderer unable to reconnect and blocking the manager forever on process `exitCode`. The post-readiness watchdog is intentionally more patient than bootstrap readiness because large long-running workspaces can briefly push SQLite/projection work over a one-second health probe. Do not lower the watchdog to short one-second failure windows; brief missed probes should log and recover, while a sustained outage should still restart the backend. On replacement startup, stale desktop backend children are reaped before binding the new backend; provider daemon/supervisor processes are intentionally excluded so provider sessions can survive the recovery. - The desktop checks the detached provider daemon every five seconds through the capability-authenticated `/api/provider-daemon/liveness` route. That route is a database-free process-identity boundary and must never read provider adapters, SQLite, journals, command ledgers, projections, or supervisor state. The richer `/api/provider-daemon/health` route remains available for cached debug/settings diagnostics, but it must never drive destructive watchdog recovery: on very large long-running databases, its diagnostic reads can legitimately exceed the request timeout while the daemon and provider sessions remain alive. A dead daemon PID triggers recovery after the first failed liveness probe; otherwise Cafe requires three consecutive liveness failures so transient event-loop pressure does not destroy a live long-running provider session. Recovery stops the backend first, replaces the authenticated daemon, and then starts a backend with the replacement daemon's newly issued lease. The daemon lease is inherited bootstrap capability data and must never be mutated in a running backend or copied through process argv/environment. Preserve and test the stop-backend -> recover-daemon -> start-backend order. - A detached provider daemon must not keep stdout/stderr pipes owned by Electron. Those pipes close when the desktop process restarts and can kill the surviving daemon with `EPIPE` on a later log write. Provider daemon and supervisor stdio therefore stays ignored, while each child owns a role-specific durable trace (`provider-daemon.trace.ndjson` or `provider-supervisor.trace.ndjson`) plus the existing bounded provider event logs. Do not redirect both detached runtimes into the backend's rotating `server.trace.ndjson`, because independent processes cannot safely coordinate that file's rotation. diff --git a/apps/desktop/src/app/DesktopUpdateDetectionProbe.test.ts b/apps/desktop/src/app/DesktopUpdateDetectionProbe.test.ts new file mode 100644 index 000000000..5c915f75c --- /dev/null +++ b/apps/desktop/src/app/DesktopUpdateDetectionProbe.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { + collectDesktopUpdateDetectionProbeResult, + isDesktopUpdateDetectionProbeEnabled, + type DesktopUpdateDetectionProbeDependencies, +} from "./DesktopUpdateDetectionProbe.ts"; + +function makeDependencies( + overrides: Partial = {}, +): DesktopUpdateDetectionProbeDependencies { + return { + platform: "linux", + arch: "x64", + isPackaged: true, + currentVersion: "1.0.0-nightly.20260721.0", + expectedVersion: "1.0.0-nightly.20260721.1", + channel: "nightly", + checkForUpdates: async () => ({ + updateAvailable: true, + availableVersion: "1.0.0-nightly.20260721.1", + }), + ...overrides, + }; +} + +describe("DesktopUpdateDetectionProbe", () => { + it("enables only for the explicit detection switch", () => { + expect(isDesktopUpdateDetectionProbeEnabled(["Cafe Code"])).toBe(false); + expect( + isDesktopUpdateDetectionProbeEnabled(["Cafe Code", "--cafe-update-detection-probe"]), + ).toBe(true); + }); + + it("accepts the exact newer release reported by the updater", async () => { + await expect( + collectDesktopUpdateDetectionProbeResult(makeDependencies()), + ).resolves.toMatchObject({ + ok: true, + updateAvailable: true, + availableVersion: "1.0.0-nightly.20260721.1", + failure: null, + }); + }); + + it("fails closed for source runs, no update, wrong versions, and updater errors", async () => { + await expect( + collectDesktopUpdateDetectionProbeResult(makeDependencies({ isPackaged: false })), + ).resolves.toMatchObject({ ok: false, failure: "not-packaged" }); + await expect( + collectDesktopUpdateDetectionProbeResult( + makeDependencies({ + checkForUpdates: async () => ({ updateAvailable: false, availableVersion: "1.0.0" }), + }), + ), + ).resolves.toMatchObject({ ok: false, failure: "no-update" }); + await expect( + collectDesktopUpdateDetectionProbeResult( + makeDependencies({ expectedVersion: "1.0.0-nightly.20260721.2" }), + ), + ).resolves.toMatchObject({ ok: false, failure: "unexpected-version" }); + await expect( + collectDesktopUpdateDetectionProbeResult( + makeDependencies({ + checkForUpdates: async () => { + throw new Error("secret feed URL must not escape"); + }, + }), + ), + ).resolves.toEqual( + expect.not.objectContaining({ message: expect.stringContaining("secret feed URL") }), + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopUpdateDetectionProbe.ts b/apps/desktop/src/app/DesktopUpdateDetectionProbe.ts new file mode 100644 index 000000000..12e548032 --- /dev/null +++ b/apps/desktop/src/app/DesktopUpdateDetectionProbe.ts @@ -0,0 +1,227 @@ +import { writeFile } from "node:fs/promises"; + +import { autoUpdater } from "electron-updater"; +import * as Electron from "electron"; + +export const DESKTOP_UPDATE_DETECTION_PROBE_SWITCH = "--cafe-update-detection-probe"; +export const DESKTOP_UPDATE_DETECTION_RESULT_ENV = "CAFE_CODE_UPDATE_DETECTION_RESULT"; +export const DESKTOP_UPDATE_DETECTION_EXPECT_VERSION_ENV = + "CAFE_CODE_UPDATE_DETECTION_EXPECT_VERSION"; +export const DESKTOP_UPDATE_DETECTION_CHANNEL_ENV = "CAFE_CODE_UPDATE_DETECTION_CHANNEL"; +export const DESKTOP_UPDATE_DETECTION_FEED_URL_ENV = "CAFE_CODE_UPDATE_DETECTION_FEED_URL"; +export const DESKTOP_UPDATE_DETECTION_OUTPUT_PREFIX = "CAFE_CODE_UPDATE_DETECTION="; + +const UPDATE_CHECK_TIMEOUT_MS = 60_000; + +export type DesktopUpdateDetectionFailure = + | "check-failed" + | "invalid-channel" + | "not-packaged" + | "no-update" + | "result-file" + | "unexpected-version"; + +export interface DesktopUpdateDetectionProbeDependencies { + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly isPackaged: boolean; + readonly currentVersion: string; + readonly expectedVersion: string | null; + readonly channel: "latest" | "nightly" | null; + readonly checkForUpdates: () => Promise<{ + readonly updateAvailable: boolean; + readonly availableVersion: string | null; + }>; +} + +export interface DesktopUpdateDetectionProbeResult { + readonly ok: boolean; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly isPackaged: boolean; + readonly channel: "latest" | "nightly" | null; + readonly currentVersion: string; + readonly expectedVersion: string | null; + readonly updateAvailable: boolean; + readonly availableVersion: string | null; + readonly failure: DesktopUpdateDetectionFailure | null; +} + +export function isDesktopUpdateDetectionProbeEnabled( + argv: readonly string[] = process.argv, +): boolean { + return argv.includes(DESKTOP_UPDATE_DETECTION_PROBE_SWITCH); +} + +function resolveUpdateChannel(rawChannel: string | undefined): "latest" | "nightly" | null { + const channel = rawChannel?.trim() || "latest"; + return channel === "latest" || channel === "nightly" ? channel : null; +} + +function baseProbeResult( + dependencies: DesktopUpdateDetectionProbeDependencies, +): Omit< + DesktopUpdateDetectionProbeResult, + "ok" | "updateAvailable" | "availableVersion" | "failure" +> { + return { + platform: dependencies.platform, + arch: dependencies.arch, + isPackaged: dependencies.isPackaged, + channel: dependencies.channel, + currentVersion: dependencies.currentVersion, + expectedVersion: dependencies.expectedVersion, + }; +} + +export async function collectDesktopUpdateDetectionProbeResult( + dependencies: DesktopUpdateDetectionProbeDependencies, +): Promise { + const base = baseProbeResult(dependencies); + if (!dependencies.isPackaged) { + return { + ...base, + ok: false, + updateAvailable: false, + availableVersion: null, + failure: "not-packaged", + }; + } + if (!dependencies.channel) { + return { + ...base, + ok: false, + updateAvailable: false, + availableVersion: null, + failure: "invalid-channel", + }; + } + + let updateAvailable = false; + let availableVersion: string | null = null; + try { + const result = await dependencies.checkForUpdates(); + updateAvailable = result.updateAvailable; + availableVersion = result.availableVersion; + } catch { + return { + ...base, + ok: false, + updateAvailable: false, + availableVersion: null, + failure: "check-failed", + }; + } + + if (!updateAvailable || !availableVersion) { + return { + ...base, + ok: false, + updateAvailable, + availableVersion, + failure: "no-update", + }; + } + if (dependencies.expectedVersion && availableVersion !== dependencies.expectedVersion) { + return { + ...base, + ok: false, + updateAvailable, + availableVersion, + failure: "unexpected-version", + }; + } + + return { + ...base, + ok: true, + updateAvailable, + availableVersion, + failure: null, + }; +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("update check timed out")), timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timeout); + reject(error instanceof Error ? error : new Error("update check failed")); + }, + ); + }); +} + +async function makeRealDependencies(): Promise { + const channel = resolveUpdateChannel(process.env[DESKTOP_UPDATE_DETECTION_CHANNEL_ENV]); + const expectedVersion = process.env[DESKTOP_UPDATE_DETECTION_EXPECT_VERSION_ENV]?.trim() || null; + + return { + platform: process.platform, + arch: process.arch, + isPackaged: Electron.app.isPackaged, + currentVersion: Electron.app.getVersion(), + expectedVersion, + channel, + checkForUpdates: async () => { + await Electron.app.whenReady(); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + if (channel) { + autoUpdater.channel = channel; + autoUpdater.allowPrerelease = channel === "nightly"; + autoUpdater.allowDowngrade = false; + } + const feedUrl = process.env[DESKTOP_UPDATE_DETECTION_FEED_URL_ENV]?.trim(); + if (feedUrl) { + autoUpdater.setFeedURL({ provider: "generic", url: feedUrl }); + } + const result = await withTimeout(autoUpdater.checkForUpdates(), UPDATE_CHECK_TIMEOUT_MS); + return { + updateAvailable: result?.isUpdateAvailable === true, + availableVersion: result?.updateInfo.version ?? null, + }; + }, + }; +} + +export async function runDesktopUpdateDetectionProbeAndExit(): Promise { + let result: DesktopUpdateDetectionProbeResult; + try { + result = await collectDesktopUpdateDetectionProbeResult(await makeRealDependencies()); + } catch { + result = { + ok: false, + platform: process.platform, + arch: process.arch, + isPackaged: Electron.app.isPackaged, + channel: resolveUpdateChannel(process.env[DESKTOP_UPDATE_DETECTION_CHANNEL_ENV]), + currentVersion: Electron.app.getVersion(), + expectedVersion: process.env[DESKTOP_UPDATE_DETECTION_EXPECT_VERSION_ENV]?.trim() || null, + updateAvailable: false, + availableVersion: null, + failure: "check-failed", + }; + } + + const resultPath = process.env[DESKTOP_UPDATE_DETECTION_RESULT_ENV]?.trim(); + if (resultPath) { + try { + await writeFile(resultPath, `${JSON.stringify(result)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + } catch { + result = { ...result, ok: false, failure: "result-file" }; + } + } + + console.info(`${DESKTOP_UPDATE_DETECTION_OUTPUT_PREFIX}${JSON.stringify(result)}`); + Electron.app.exit(result.ok ? 0 : 1); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index beecd6925..bf7959a12 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -46,6 +46,10 @@ import { isDesktopRuntimeSelfTestEnabled, runDesktopRuntimeSelfTestAndExit, } from "./app/DesktopRuntimeSelfTest.ts"; +import { + isDesktopUpdateDetectionProbeEnabled, + runDesktopUpdateDetectionProbeAndExit, +} from "./app/DesktopUpdateDetectionProbe.ts"; startStartupCpuProfiler({ role: "desktop-main" }); @@ -142,7 +146,9 @@ const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( ), ); -if (isDesktopRuntimeSelfTestEnabled()) { +if (isDesktopUpdateDetectionProbeEnabled()) { + void runDesktopUpdateDetectionProbeAndExit(); +} else if (isDesktopRuntimeSelfTestEnabled()) { void runDesktopRuntimeSelfTestAndExit(); } else { DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 5ab5198b3..0dd1449ef 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -28,6 +28,10 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { + getAutoUpdateDisabledReason, + resolveUnsignedDesktopUpdateInstallMode, +} from "./updateEligibility.ts"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -43,11 +47,6 @@ import { const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; -const DESKTOP_UPDATES_DISABLED_REASON = "Update checks are disabled in this Cafe Code build."; - -function areDesktopUpdatesDisabledInThisBuild(): boolean { - return true; -} const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; @@ -135,7 +134,12 @@ function createBaseUpdateState( environment: DesktopEnvironment.DesktopEnvironmentShape, ): DesktopUpdateState { return { - ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), + ...createInitialDesktopUpdateState( + environment.appVersion, + environment.runtimeInfo, + channel, + resolveUnsignedDesktopUpdateInstallMode(environment.platform), + ), enabled, status: enabled ? "idle" : "disabled", }; @@ -163,33 +167,6 @@ function shouldBroadcastDownloadProgress( return nextStep !== previousStep || nextPercent === 100; } -function getAutoUpdateDisabledReason(args: { - isDevelopment: boolean; - isPackaged: boolean; - platform: NodeJS.Platform; - appImage?: string | undefined; - disabledByEnv: boolean; - hasUpdateFeedConfig: boolean; -}): string | null { - if (areDesktopUpdatesDisabledInThisBuild()) { - return DESKTOP_UPDATES_DISABLED_REASON; - } - - if (!args.hasUpdateFeedConfig) { - return "Automatic updates are not available because no update feed is configured."; - } - if (args.isDevelopment || !args.isPackaged) { - return "Automatic updates are only available in packaged production builds."; - } - if (args.disabledByEnv) { - return "Automatic updates are disabled by the CAFE_CODE_DISABLE_AUTO_UPDATE setting."; - } - if (args.platform === "linux" && !args.appImage) { - return "Automatic updates on Linux require running the AppImage build."; - } - return null; -} - function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; } @@ -215,6 +192,7 @@ const make = Effect.gen(function* () { environment.appVersion, environment.runtimeInfo, environment.defaultDesktopSettings.updateChannel, + resolveUnsignedDesktopUpdateInstallMode(environment.platform), ), ); @@ -335,6 +313,7 @@ const make = Effect.gen(function* () { if ( !(yield* Ref.get(updaterConfiguredRef)) || (yield* Ref.get(updateDownloadInFlightRef)) || + state.installMode !== "in-app" || state.status !== "available" ) { return { accepted: false, completed: false }; @@ -368,6 +347,7 @@ const make = Effect.gen(function* () { if ( (yield* Ref.get(desktopState.quitting)) || !(yield* Ref.get(updaterConfiguredRef)) || + state.installMode !== "in-app" || state.status !== "downloaded" ) { return { accepted: false, completed: false }; diff --git a/apps/desktop/src/updates/updateEligibility.test.ts b/apps/desktop/src/updates/updateEligibility.test.ts new file mode 100644 index 000000000..7e8bb1c40 --- /dev/null +++ b/apps/desktop/src/updates/updateEligibility.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { + getAutoUpdateDisabledReason, + resolveUnsignedDesktopUpdateInstallMode, + type DesktopUpdateEligibilityInput, +} from "./updateEligibility.ts"; + +const eligibleInput: DesktopUpdateEligibilityInput = { + isDevelopment: false, + isPackaged: true, + platform: "win32", + disabledByEnv: false, + hasUpdateFeedConfig: true, +}; + +describe("desktop update eligibility", () => { + it("enables packaged Windows, macOS, and AppImage builds with a feed", () => { + expect(getAutoUpdateDisabledReason(eligibleInput)).toBeNull(); + expect(getAutoUpdateDisabledReason({ ...eligibleInput, platform: "darwin" })).toBeNull(); + expect( + getAutoUpdateDisabledReason({ + ...eligibleInput, + platform: "linux", + appImage: "/opt/Cafe-Code.AppImage", + }), + ).toBeNull(); + }); + + it("keeps source builds, opt-outs, missing feeds, and non-AppImage Linux disabled", () => { + expect(getAutoUpdateDisabledReason({ ...eligibleInput, hasUpdateFeedConfig: false })).toContain( + "no update feed", + ); + expect(getAutoUpdateDisabledReason({ ...eligibleInput, isDevelopment: true })).toContain( + "packaged production builds", + ); + expect(getAutoUpdateDisabledReason({ ...eligibleInput, isPackaged: false })).toContain( + "packaged production builds", + ); + expect(getAutoUpdateDisabledReason({ ...eligibleInput, disabledByEnv: true })).toContain( + "CAFE_CODE_DISABLE_AUTO_UPDATE", + ); + expect(getAutoUpdateDisabledReason({ ...eligibleInput, platform: "linux" })).toContain( + "AppImage", + ); + }); + + it("uses manual installation only for unsigned macOS artifacts", () => { + expect(resolveUnsignedDesktopUpdateInstallMode("darwin")).toBe("manual"); + expect(resolveUnsignedDesktopUpdateInstallMode("win32")).toBe("in-app"); + expect(resolveUnsignedDesktopUpdateInstallMode("linux")).toBe("in-app"); + }); +}); diff --git a/apps/desktop/src/updates/updateEligibility.ts b/apps/desktop/src/updates/updateEligibility.ts new file mode 100644 index 000000000..0ef663e9b --- /dev/null +++ b/apps/desktop/src/updates/updateEligibility.ts @@ -0,0 +1,34 @@ +import type { DesktopUpdateInstallMode } from "@cafecode/contracts"; + +export interface DesktopUpdateEligibilityInput { + readonly isDevelopment: boolean; + readonly isPackaged: boolean; + readonly platform: NodeJS.Platform; + readonly appImage?: string | undefined; + readonly disabledByEnv: boolean; + readonly hasUpdateFeedConfig: boolean; +} + +export function getAutoUpdateDisabledReason(args: DesktopUpdateEligibilityInput): string | null { + if (!args.hasUpdateFeedConfig) { + return "Automatic updates are not available because no update feed is configured."; + } + if (args.isDevelopment || !args.isPackaged) { + return "Automatic updates are only available in packaged production builds."; + } + if (args.disabledByEnv) { + return "Automatic updates are disabled by the CAFE_CODE_DISABLE_AUTO_UPDATE setting."; + } + if (args.platform === "linux" && !args.appImage) { + return "Automatic updates on Linux require running the AppImage build."; + } + return null; +} + +export function resolveUnsignedDesktopUpdateInstallMode( + platform: NodeJS.Platform, +): DesktopUpdateInstallMode { + // Squirrel.Mac rejects unsigned in-place updates. Keep detection available, but direct + // users to the release DMG until Cafe has an Apple Developer ID signing identity. + return platform === "darwin" ? "manual" : "in-app"; +} diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index e2f0519d3..b918b250e 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -20,6 +20,15 @@ const runtimeInfo = { } as const; describe("updateMachine", () => { + it("records the platform install mode in initial state", () => { + expect(createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest").installMode).toBe( + "in-app", + ); + expect( + createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest", "manual").installMode, + ).toBe("manual"); + }); + it("clears transient errors when a check starts", () => { const state = reduceDesktopUpdateStateOnCheckStart( { diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index 098effe33..cc083eb79 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -1,6 +1,7 @@ import type { DesktopRuntimeInfo, DesktopUpdateChannel, + DesktopUpdateInstallMode, DesktopUpdateState, } from "@cafecode/contracts"; @@ -18,11 +19,13 @@ export function createInitialDesktopUpdateState( currentVersion: string, runtimeInfo: DesktopRuntimeInfo, channel: DesktopUpdateChannel, + installMode: DesktopUpdateInstallMode = "in-app", ): DesktopUpdateState { return { enabled: false, status: "disabled", channel, + installMode, currentVersion, hostArch: runtimeInfo.hostArch, appArch: runtimeInfo.appArch, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0e68baad2..a49ac6770 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -114,10 +114,12 @@ import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateInstallConfirmationMessage, + getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, + type DesktopUpdateButtonAction, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; @@ -3233,7 +3235,7 @@ interface SidebarProjectsContentProps { bootstrappedEnvironmentIds: ReadonlySet; showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; - desktopUpdateButtonAction: "download" | "install" | "none"; + desktopUpdateButtonAction: DesktopUpdateButtonAction; desktopUpdateButtonDisabled: boolean; handleDesktopUpdateButtonClick: () => void; desktopDebugEnabled: boolean; @@ -3395,7 +3397,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( > {desktopUpdateButtonAction === "download" ? "Download ARM build" - : "Install ARM build"} + : desktopUpdateButtonAction === "manual" + ? "View ARM build" + : "Install ARM build"} ) : null} @@ -4112,6 +4116,27 @@ export default function Sidebar() { if (!bridge || !desktopUpdateState) return; if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; + if (desktopUpdateButtonAction === "manual") { + void bridge + .openExternal(getDesktopUpdateReleaseUrl(desktopUpdateState.availableVersion)) + .then((opened) => { + if (opened) return; + toastManager.add({ + type: "error", + title: "Could not open release", + description: "Open the Cafe Code releases page in your browser to install the DMG.", + }); + }) + .catch(() => { + toastManager.add({ + type: "error", + title: "Could not open release", + description: "Open the Cafe Code releases page in your browser to install the DMG.", + }); + }); + return; + } + if (desktopUpdateButtonAction === "download") { void bridge .downloadUpdate() diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts new file mode 100644 index 000000000..6a8ec17c7 --- /dev/null +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import type { DesktopUpdateState } from "@cafecode/contracts"; + +import { + getDesktopUpdateButtonTooltip, + getDesktopUpdateReleaseUrl, + resolveDesktopUpdateButtonAction, +} from "./desktopUpdate.logic"; + +function makeState(overrides: Partial = {}): DesktopUpdateState { + return { + enabled: true, + status: "available", + channel: "latest", + installMode: "in-app", + currentVersion: "1.0.0", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: "1.1.0", + downloadedVersion: null, + downloadPercent: null, + checkedAt: "2026-07-21T00:00:00.000Z", + message: null, + errorContext: null, + canRetry: false, + ...overrides, + }; +} + +describe("desktop update presentation", () => { + it("keeps supported updates on the in-app download path", () => { + expect(resolveDesktopUpdateButtonAction(makeState())).toBe("download"); + }); + + it("routes unsigned macOS updates to the exact GitHub release", () => { + const state = makeState({ installMode: "manual" }); + + expect(resolveDesktopUpdateButtonAction(state)).toBe("manual"); + expect(getDesktopUpdateButtonTooltip(state)).toContain("GitHub release"); + expect(getDesktopUpdateReleaseUrl(state.availableVersion)).toBe( + "https://github.com/cafeai/cafe-code/releases/tag/v1.1.0", + ); + }); + + it("falls back to the releases index when no version is available", () => { + expect(getDesktopUpdateReleaseUrl(null)).toBe("https://github.com/cafeai/cafe-code/releases"); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 6e1fc42c7..64fd5f054 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -1,10 +1,15 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@cafecode/contracts"; -export type DesktopUpdateButtonAction = "download" | "install" | "none"; +export type DesktopUpdateButtonAction = "download" | "install" | "manual" | "none"; + +const DESKTOP_RELEASES_URL = "https://github.com/cafeai/cafe-code/releases"; export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { + if (state.installMode === "manual" && state.availableVersion) { + return "manual"; + } if (state.downloadedVersion) { return "install"; } @@ -19,6 +24,11 @@ export function resolveDesktopUpdateButtonAction( return "none"; } +export function getDesktopUpdateReleaseUrl(version: string | null): string { + if (!version) return DESKTOP_RELEASES_URL; + return `${DESKTOP_RELEASES_URL}/tag/v${encodeURIComponent(version)}`; +} + export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): boolean { if (!state || !state.enabled) { return false; @@ -54,6 +64,9 @@ export function getArm64IntelBuildWarningDescription(state: DesktopUpdateState): export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string { if (state.status === "available") { + if (state.installMode === "manual") { + return `Update ${state.availableVersion ?? "available"} can be installed from its GitHub release`; + } return `Update ${state.availableVersion ?? "available"} ready to download`; } if (state.status === "downloading") { diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 1478f5481..68b144de7 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -503,6 +503,7 @@ const createDesktopBridgeStub = (overrides?: { enabled: false, status: "idle", channel: "latest", + installMode: "in-app", currentVersion: "0.0.0-test", hostArch: "arm64", appArch: "arm64", diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index d7e5b74d4..3764dbb64 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -12,6 +12,7 @@ import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, + getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -40,6 +41,27 @@ export function SidebarUpdatePill() { if (!bridge || !state) return; if (disabled || action === "none") return; + if (action === "manual") { + void bridge + .openExternal(getDesktopUpdateReleaseUrl(state.availableVersion)) + .then((opened) => { + if (opened) return; + toastManager.add({ + type: "error", + title: "Could not open release", + description: "Open the Cafe Code releases page in your browser to install the DMG.", + }); + }) + .catch(() => { + toastManager.add({ + type: "error", + title: "Could not open release", + description: "Open the Cafe Code releases page in your browser to install the DMG.", + }); + }); + return; + } + if (action === "download") { void bridge .downloadUpdate() @@ -139,6 +161,11 @@ export function SidebarUpdatePill() { Restart to update + ) : action === "manual" ? ( + <> + + View macOS update + ) : state?.status === "downloading" ? ( <> @@ -160,7 +187,7 @@ export function SidebarUpdatePill() { /> {tooltip} - {action === "download" && ( + {(action === "download" || action === "manual") && ( = 1`. The release workflow builds +Windows x64 NSIS, macOS arm64/x64 DMG plus updater ZIP, and Linux x64 AppImage. Linux formats other +than x64 AppImage are not currently supported. + +Before publication, the workflow merges the two macOS manifests and verifies every updater asset's +size and SHA-512 digest. It also publishes `SHA256SUMS.txt` for manual downloads. After publication, +a lower-version packaged AppImage runs a detection-only probe against the public GitHub feed and +must report the exact released version. The probe never downloads or installs the update. + +## Runtime Behavior + +Packaged apps check 15 seconds after launch and every four minutes afterward. Users can also check +from the application menu. Cafe Code does not automatically download an update and does not install +one merely because the app exits. + +- Windows x64: the user downloads from Cafe Code and confirms restart. The unsigned NSIS installer + runs for the current user with Electron Updater's `--updated /S --force-run` arguments. The update + path does not rerun the fresh-install managed-provider bootstrap. Windows may show Unknown + Publisher or SmartScreen warnings because there is no Authenticode certificate. +- macOS arm64/x64: Cafe Code detects the release but opens its exact GitHub release page for manual + DMG installation. Replace Cafe Code in `/Applications` from the DMG. Squirrel.Mac requires a + signed app, so unsigned builds must never offer in-place restart-and-install. Gatekeeper may + require the user to approve the unsigned app in Privacy & Security. +- Linux x64 AppImage: update checks run only when Cafe Code was launched from an AppImage and the + `APPIMAGE` path is available. After an explicit download and restart confirmation, Electron + Updater replaces the AppImage and relaunches it. The containing directory and AppImage must be + writable. If replacement fails, download the newer AppImage from GitHub and run it manually. + +Source checkouts use the separate Git branch update diagnostic. They compare commits on `main` or +`dev`; they do not consume GitHub Release manifests and cannot install packaged updates. + +## Trust Model + +The feed and artifacts are delivered over GitHub HTTPS, and Electron Updater verifies the SHA-512 +digest recorded in the release manifest before using a downloaded Windows or Linux artifact. This +detects transport corruption but does not establish publisher identity. Anyone who can publish to +the GitHub repository can distribute an unsigned executable accepted by the updater. Adding Apple +Developer ID signing/notarization and Windows Authenticode signing is required before describing +these artifacts as publisher-authenticated. diff --git a/docs/release-readiness-worklist.md b/docs/release-readiness-worklist.md index 71cc85ef9..723a75426 100644 --- a/docs/release-readiness-worklist.md +++ b/docs/release-readiness-worklist.md @@ -146,7 +146,13 @@ Each item includes the concrete attack or data-exposure vector. Where no malicio These are current incorrect, unreachable, contradictory, crash-prone, or non-portable behaviors. -- [ ] **RB-02 — Packaged automatic updates are unreachable.** The updater is implemented but an unconditional function disables it in packaged builds. Define channels and Linux behavior, then enable and test signed update/recovery flows—or remove the updater surface entirely. +- [x] **RB-02 — Packaged automatic updates are unreachable.** Packaged update detection is enabled, + stable/nightly release feeds are automated, and Linux support is explicitly x64 AppImage-only. + Tagged releases validate all update manifests and run a lower-version packaged AppImage detection + probe against GitHub. Windows NSIS and Linux AppImage installs remain unsigned; macOS detects the + update but uses manual DMG replacement because Squirrel.Mac requires signing. Native install, + recovery, signing, and notarization certification remain release-hardening work. See + [`desktop-releases-and-updates.md`](desktop-releases-and-updates.md). - [ ] **RB-07 — Provider availability checks accept versions outside a tested contract.** Codex and Claude detect versions without enforcing a supported base range; incompatible binaries can fail mid-turn. Publish/enforce version ranges, produce actionable health failures, regenerate Codex schemas in CI, and add supported-version fixtures/live canaries. diff --git a/package.json b/package.json index 2ba550d48..39d3cf133 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "dist:aur:cafe-code": "makepkg --cleanbuild --force --dir packaging/aur/cafe-code", "dist:arch:local": "node scripts/build-arch-package.ts", "release:smoke": "node scripts/release-smoke.ts", + "validate:desktop-release": "node scripts/validate-update-release.ts", "audit:repository": "node scripts/repository-audit.ts", "db:compact-activity-payloads": "node scripts/compact-activity-payloads.ts", "clean": "node scripts/clean.ts", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ebdf7cf61..6d627ba81 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -126,6 +126,7 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; +export type DesktopUpdateInstallMode = "in-app" | "manual"; export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; export const DesktopUpdateStatusSchema = Schema.Literals([ @@ -141,6 +142,7 @@ export const DesktopUpdateStatusSchema = Schema.Literals([ export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]); export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); +export const DesktopUpdateInstallModeSchema = Schema.Literals(["in-app", "manual"]); export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); export interface DesktopAppBranding { @@ -171,6 +173,7 @@ export interface DesktopUpdateState { enabled: boolean; status: DesktopUpdateStatus; channel: DesktopUpdateChannel; + installMode: DesktopUpdateInstallMode; currentVersion: string; hostArch: DesktopRuntimeArch; appArch: DesktopRuntimeArch; @@ -188,6 +191,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ enabled: Schema.Boolean, status: DesktopUpdateStatusSchema, channel: DesktopUpdateChannelSchema, + installMode: DesktopUpdateInstallModeSchema, currentVersion: Schema.String, hostArch: DesktopRuntimeArchSchema, appArch: DesktopRuntimeArchSchema, diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 1db59e054..381754a12 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -923,6 +923,12 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( target: target === "dmg" ? [target, "zip"] : [target], icon: "icon.icns", category: "public.app-category.developer-tools", + ...(signed + ? {} + : { + identity: null, + hardenedRuntime: false, + }), }; } diff --git a/scripts/validate-update-release.test.ts b/scripts/validate-update-release.test.ts new file mode 100644 index 000000000..978438d2a --- /dev/null +++ b/scripts/validate-update-release.test.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { serializeUpdateManifest, type UpdateManifest } from "./lib/update-manifest.ts"; +import { validateDesktopUpdateRelease } from "./validate-update-release.ts"; + +const version = "1.2.3-nightly.20260721.1"; +const releaseDirs: string[] = []; + +function sha512(bytes: Uint8Array): string { + return createHash("sha512").update(bytes).digest("base64"); +} + +async function makeReleaseFile(releaseDir: string, fileName: string): Promise { + const bytes = new TextEncoder().encode(`fixture:${fileName}`); + await writeFile(join(releaseDir, fileName), bytes); + return bytes; +} + +async function writeManifest( + releaseDir: string, + fileName: string, + assetNames: readonly string[], +): Promise { + const files: Array = []; + for (const assetName of assetNames) { + const bytes = await readFile(join(releaseDir, assetName)); + files.push({ url: assetName, sha512: sha512(bytes), size: bytes.length }); + } + await writeFile( + join(releaseDir, fileName), + serializeUpdateManifest( + { + version, + releaseDate: "2026-07-21T00:00:00.000Z", + files, + extras: {}, + }, + { platformLabel: fileName }, + ), + ); +} + +async function makeValidRelease(): Promise { + const releaseDir = await mkdtemp(join(tmpdir(), "cafe-update-release-")); + releaseDirs.push(releaseDir); + const assets = [ + `Cafe-Code-${version}-x64.exe`, + `Cafe-Code-${version}-arm64.dmg`, + `Cafe-Code-${version}-x64.dmg`, + `Cafe-Code-${version}-arm64.zip`, + `Cafe-Code-${version}-x64.zip`, + `Cafe-Code-${version}-x86_64.AppImage`, + ] as const; + await Promise.all(assets.map((asset) => makeReleaseFile(releaseDir, asset))); + await writeManifest(releaseDir, "nightly.yml", [assets[0]]); + await writeManifest(releaseDir, "nightly-mac.yml", [assets[3], assets[4]]); + await writeManifest(releaseDir, "nightly-linux.yml", [assets[5]]); + return releaseDir; +} + +afterEach(async () => { + await Promise.all(releaseDirs.splice(0).map((releaseDir) => rm(releaseDir, { recursive: true }))); +}); + +describe("validateDesktopUpdateRelease", () => { + it("validates every platform feed and writes sorted manual-download checksums", async () => { + const releaseDir = await makeValidRelease(); + + await expect( + validateDesktopUpdateRelease({ releaseDir, version, channel: "nightly" }), + ).resolves.toMatchObject({ version, channel: "nightly", fileCount: 9 }); + + const checksums = await readFile(join(releaseDir, "SHA256SUMS.txt"), "utf8"); + expect(checksums).toContain(`Cafe-Code-${version}-x86_64.AppImage`); + expect(checksums).toContain("nightly-mac.yml"); + }); + + it("rejects a missing required platform artifact", async () => { + const releaseDir = await makeValidRelease(); + await rm(join(releaseDir, `Cafe-Code-${version}-arm64.dmg`)); + + await expect( + validateDesktopUpdateRelease({ releaseDir, version, channel: "nightly" }), + ).rejects.toThrow("missing required file"); + }); + + it("rejects manifest checksum tampering", async () => { + const releaseDir = await makeValidRelease(); + await writeFile(join(releaseDir, `Cafe-Code-${version}-x86_64.AppImage`), "tampered"); + + await expect( + validateDesktopUpdateRelease({ releaseDir, version, channel: "nightly" }), + ).rejects.toThrow(/size|SHA-512/); + }); +}); diff --git a/scripts/validate-update-release.ts b/scripts/validate-update-release.ts new file mode 100644 index 000000000..89b2d047b --- /dev/null +++ b/scripts/validate-update-release.ts @@ -0,0 +1,214 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; + +import { parseUpdateManifest, type UpdateManifest } from "./lib/update-manifest.ts"; + +export type DesktopReleaseChannel = "latest" | "nightly"; + +export interface ValidateDesktopUpdateReleaseInput { + readonly releaseDir: string; + readonly version: string; + readonly channel: DesktopReleaseChannel; + readonly writeChecksums?: boolean; +} + +export interface DesktopUpdateReleaseValidationResult { + readonly version: string; + readonly channel: DesktopReleaseChannel; + readonly fileCount: number; + readonly manifests: readonly string[]; +} + +function sha512Base64(bytes: Uint8Array): string { + return createHash("sha512").update(bytes).digest("base64"); +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function decodeManifestFileName(url: string, manifestName: string): string { + let fileName: string; + try { + fileName = decodeURIComponent(url); + } catch { + throw new Error(`${manifestName} contains an invalid encoded asset URL: ${url}`); + } + + if (!fileName || basename(fileName) !== fileName || fileName.includes("\\")) { + throw new Error(`${manifestName} contains a non-local asset URL: ${url}`); + } + return fileName; +} + +async function validateManifestFiles( + releaseDir: string, + manifestName: string, + manifest: UpdateManifest, + expectedVersion: string, +): Promise> { + if (manifest.version !== expectedVersion) { + throw new Error( + `${manifestName} has version ${manifest.version}; expected ${expectedVersion}.`, + ); + } + + const referencedFiles = new Set(); + for (const file of manifest.files) { + const fileName = decodeManifestFileName(file.url, manifestName); + if (referencedFiles.has(fileName)) { + throw new Error(`${manifestName} references ${fileName} more than once.`); + } + referencedFiles.add(fileName); + + const filePath = join(releaseDir, fileName); + const fileStat = await stat(filePath).catch(() => null); + if (!fileStat?.isFile()) { + throw new Error(`${manifestName} references missing asset ${fileName}.`); + } + if (fileStat.size !== file.size) { + throw new Error( + `${manifestName} records size ${file.size} for ${fileName}; actual size is ${fileStat.size}.`, + ); + } + const bytes = await readFile(filePath); + if (sha512Base64(bytes) !== file.sha512) { + throw new Error(`${manifestName} has an invalid SHA-512 checksum for ${fileName}.`); + } + } + return referencedFiles; +} + +function expectedReleaseFiles(version: string, channel: DesktopReleaseChannel): readonly string[] { + return [ + `Cafe-Code-${version}-x64.exe`, + `Cafe-Code-${version}-arm64.dmg`, + `Cafe-Code-${version}-x64.dmg`, + `Cafe-Code-${version}-arm64.zip`, + `Cafe-Code-${version}-x64.zip`, + `Cafe-Code-${version}-x86_64.AppImage`, + `${channel}.yml`, + `${channel}-mac.yml`, + `${channel}-linux.yml`, + ]; +} + +async function assertRequiredFiles( + releaseDir: string, + requiredFiles: readonly string[], +): Promise { + for (const fileName of requiredFiles) { + const fileStat = await stat(join(releaseDir, fileName)).catch(() => null); + if (!fileStat?.isFile()) { + throw new Error(`Desktop release is missing required file ${fileName}.`); + } + } +} + +function assertManifestPayloads( + version: string, + manifestFiles: Readonly>>, +): void { + const expectedByManifest: Readonly> = { + windows: [`Cafe-Code-${version}-x64.exe`], + mac: [`Cafe-Code-${version}-arm64.zip`, `Cafe-Code-${version}-x64.zip`], + linux: [`Cafe-Code-${version}-x86_64.AppImage`], + }; + + for (const [platform, expectedFiles] of Object.entries(expectedByManifest)) { + const actualFiles = manifestFiles[platform]; + if (!actualFiles) { + throw new Error(`Desktop release validation did not load the ${platform} manifest.`); + } + for (const fileName of expectedFiles) { + if (!actualFiles.has(fileName)) { + throw new Error(`The ${platform} update manifest does not reference ${fileName}.`); + } + } + } +} + +async function writeReleaseChecksums(releaseDir: string): Promise { + const entries = await readdir(releaseDir, { withFileTypes: true }); + const fileNames = entries + .filter((entry) => entry.isFile() && entry.name !== "SHA256SUMS.txt") + .map((entry) => entry.name) + .toSorted(); + const lines: string[] = []; + for (const fileName of fileNames) { + lines.push(`${sha256Hex(await readFile(join(releaseDir, fileName)))} ${fileName}`); + } + await writeFile(join(releaseDir, "SHA256SUMS.txt"), `${lines.join("\n")}\n`, { + encoding: "utf8", + mode: 0o644, + }); +} + +export async function validateDesktopUpdateRelease( + input: ValidateDesktopUpdateReleaseInput, +): Promise { + const releaseDir = resolve(input.releaseDir); + const entries = await readdir(releaseDir, { withFileTypes: true }); + const unsupportedEntry = entries.find((entry) => !entry.isFile()); + if (unsupportedEntry) { + throw new Error( + `Desktop release directory contains a non-file entry: ${unsupportedEntry.name}.`, + ); + } + + const manifestNames = [ + `${input.channel}.yml`, + `${input.channel}-mac.yml`, + `${input.channel}-linux.yml`, + ] as const; + await assertRequiredFiles(releaseDir, expectedReleaseFiles(input.version, input.channel)); + + const manifestFiles: Record> = {}; + for (const [index, manifestName] of manifestNames.entries()) { + const raw = await readFile(join(releaseDir, manifestName), "utf8"); + const platform = ["windows", "mac", "linux"][index]; + if (!platform) throw new Error(`Unknown manifest index ${index}.`); + const manifest = parseUpdateManifest(raw, manifestName, platform); + manifestFiles[platform] = await validateManifestFiles( + releaseDir, + manifestName, + manifest, + input.version, + ); + } + assertManifestPayloads(input.version, manifestFiles); + + if (input.writeChecksums !== false) { + await writeReleaseChecksums(releaseDir); + } + + return { + version: input.version, + channel: input.channel, + fileCount: entries.length, + manifests: manifestNames, + }; +} + +function readFlag(name: string): string { + const index = process.argv.indexOf(name); + const value = index >= 0 ? process.argv[index + 1] : undefined; + if (!value) throw new Error(`Missing required ${name} argument.`); + return value; +} + +if (import.meta.main) { + const channel = readFlag("--channel"); + if (channel !== "latest" && channel !== "nightly") { + throw new Error(`Invalid --channel value '${channel}'.`); + } + const result = await validateDesktopUpdateRelease({ + releaseDir: readFlag("--release-dir"), + version: readFlag("--version"), + channel, + }); + console.info(JSON.stringify(result)); +} From 092f75ea1542fba31e4d8f3d0958e0d07ff243aa Mon Sep 17 00:00:00 2001 From: alotofcatz Date: Tue, 21 Jul 2026 23:44:12 +0100 Subject: [PATCH 12/12] Support AppImage block map metadata --- scripts/lib/update-manifest.ts | 24 +++++++++++++++++++++++- scripts/merge-update-manifests.test.ts | 21 +++++++++++++++++++++ scripts/validate-update-release.test.ts | 7 ++++++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/scripts/lib/update-manifest.ts b/scripts/lib/update-manifest.ts index 191a3c0e5..3c059efde 100644 --- a/scripts/lib/update-manifest.ts +++ b/scripts/lib/update-manifest.ts @@ -2,6 +2,7 @@ export interface UpdateManifestFile { readonly url: string; readonly sha512: string; readonly size: number; + readonly blockMapSize?: number; } export type UpdateManifestScalar = string | number | boolean; @@ -17,6 +18,7 @@ interface MutableUpdateManifestFile { url?: string; sha512?: string; size?: number; + blockMapSize?: number; } function stripSingleQuotes(value: string): string { @@ -48,6 +50,7 @@ function parseFileRecord( url: currentFile.url, sha512: currentFile.sha512, size: currentFile.size, + ...(currentFile.blockMapSize === undefined ? {} : { blockMapSize: currentFile.blockMapSize }), }; } @@ -113,6 +116,17 @@ export function parseUpdateManifest( continue; } + const fileBlockMapSizeMatch = line.match(/^ blockMapSize:\s*(\d+)$/); + if (fileBlockMapSizeMatch?.[1]) { + if (currentFile === null) { + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: blockMapSize without a file entry.`, + ); + } + currentFile.blockMapSize = Number(fileBlockMapSizeMatch[1]); + continue; + } + if (line === "files:") { inFiles = true; continue; @@ -219,7 +233,12 @@ export function mergeUpdateManifests( const filesByUrl = new Map(); for (const file of [...primary.files, ...secondary.files]) { const existing = filesByUrl.get(file.url); - if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { + if ( + existing && + (existing.sha512 !== file.sha512 || + existing.size !== file.size || + existing.blockMapSize !== file.blockMapSize) + ) { throw new Error( `Cannot merge ${platformLabel} update manifests: conflicting file entry for ${file.url}.`, ); @@ -259,6 +278,9 @@ export function serializeUpdateManifest( lines.push(` - url: ${file.url}`); lines.push(` sha512: ${file.sha512}`); lines.push(` size: ${file.size}`); + if (file.blockMapSize !== undefined) { + lines.push(` blockMapSize: ${file.blockMapSize}`); + } } for (const key of Object.keys(manifest.extras).toSorted()) { diff --git a/scripts/merge-update-manifests.test.ts b/scripts/merge-update-manifests.test.ts index 5f5ad591e..37a0b5928 100644 --- a/scripts/merge-update-manifests.test.ts +++ b/scripts/merge-update-manifests.test.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { Command, CliError } from "effect/unstable/cli"; +import { parseUpdateManifest, serializeUpdateManifest } from "./lib/update-manifest.ts"; import { mergePlatformUpdateManifests, mergeUpdateManifestsCommand, @@ -185,6 +186,26 @@ releaseDate: '2026-03-07T10:36:07.540Z' const reparsed = parsePlatformUpdateManifest("win", serialized, "latest-win-x64.yml"); assert.equal(reparsed.version, "1.0"); }); + + it("round-trips embedded AppImage block map metadata", () => { + const original = parseUpdateManifest( + `version: '1.0.0' +files: + - url: Cafe-Code-1.0.0-x86_64.AppImage + sha512: appimagesha + size: 125621344 + blockMapSize: 263625 +releaseDate: '2026-07-21T10:36:07.540Z' +`, + "latest-linux.yml", + "Linux", + ); + + assert.equal(original.files[0]?.blockMapSize, 263625); + const serialized = serializeUpdateManifest(original, { platformLabel: "Linux" }); + const reparsed = parseUpdateManifest(serialized, "latest-linux.yml", "Linux"); + assert.deepStrictEqual(reparsed, original); + }); }); it.layer(NodeServices.layer)("merge-update-manifests cli", (it) => { diff --git a/scripts/validate-update-release.test.ts b/scripts/validate-update-release.test.ts index 978438d2a..6d689c7e9 100644 --- a/scripts/validate-update-release.test.ts +++ b/scripts/validate-update-release.test.ts @@ -29,7 +29,12 @@ async function writeManifest( const files: Array = []; for (const assetName of assetNames) { const bytes = await readFile(join(releaseDir, assetName)); - files.push({ url: assetName, sha512: sha512(bytes), size: bytes.length }); + files.push({ + url: assetName, + sha512: sha512(bytes), + size: bytes.length, + ...(assetName.endsWith(".AppImage") ? { blockMapSize: 263625 } : {}), + }); } await writeFile( join(releaseDir, fileName),