CodeNomad/scripts/smoke-packaged-resources.cjs
Pascal André 61b9218412
chore(desktop): upgrade bundled Node to 24.20.0 LTS (#679)
## Summary

- Upgrade the standalone Node executable bundled with **both Tauri and
Electron** from **22.22.2 to 24.20.0 LTS (Krypton)**.
- Add a single exact `.node-version` pin consumed by the runtime
preparation script and all Node CI setup steps, replacing the previous
Node 20/22 build/test split. Official archive SHA-256 verification and
executable-only packaging are preserved.
- Set **macOS 13.5 minimum** explicitly in both desktop bundle manifests
and update the build documentation. This compatibility change was
explicitly accepted by the requester.
- Strengthen packaged-resource smoke checks: verify the bundled Node
matches the pin, reject stale runtimes, and exercise ESM imports
including the native OpenCode V2 client from the packaged server
directory.
- Add regression coverage for the shared runtime/CI pin and macOS
minimum-version parity.

## Scope and compatibility

This updates the Node process running the CodeNomad backend. It does
**not** upgrade Electron 39 or its internal Node/Chromium, WebView2, the
user's Node installation, or the independently managed OpenCode daemon.
No application dependencies or lockfile versions are changed.

Node 24's official macOS binaries require 13.5 rather than Node 22's
11.0. Windows x64/ARM64 and Linux glibc x64/ARM64 keep their existing
Node platform floors. No additional platform or musl support is
introduced.

The Windows x64 Node executable increases from approximately **83.04 MiB
to 89.06 MiB** uncompressed. Final installer/archive size changes have
not been measured. This is a runtime maintenance update, not the
deferred packaging-size optimization.

## Validation

Performed in an isolated worktree on Windows with the official,
checksum-verified **Node 24.20.0 / npm 11.19.0** distribution:

- [x] Clean `npm ci`; lockfile unchanged.
- [x] UI, Electron, and server TypeScript checks.
- [x] Server suite: **391 passed, 2 platform-skipped, 0 failed**.
- [x] Electron Node-level native-logic suite: **192 passed**.
- [x] Packaging/runtime regression tests: **6 passed**.
- [x] `npm run bundle:server --workspace @codenomad/tauri-app`: rebuild
UI/server resources, stage production dependencies,
download/check/extract Node 24, and run packaged
startup/import/loading-asset checks.
- [x] Negative smoke: the previous release's packaged Node 22.22.2 is
rejected with a version mismatch.
- [x] Desktop manifest schema checks (Tauri numeric-format annotations
ignored by the standalone JSON schema validator).
- [x] `git diff --check`.

The preliminary compatibility study also loaded all 14 direct server
dependencies and the packaged `msgpackr-extract` N-API binary under Node
24, with a successful serialization roundtrip.

## Remaining release gates / existing warnings

- [ ] Cross-platform CI builds and tests on the final head.
- [ ] Native GUI launch, restore, OpenCode streaming, shutdown, and
restart verification for the rebuilt desktop applications. Resource
smoke tests and Node-level Electron tests are **not** a complete
native-release approval.

`npm ci` still reports the existing 49 dependency advisories; this PR
does not run an unrelated dependency upgrade. npm 11.19 also warns about
existing install scripts awaiting `allowScripts` coverage. `actionlint`
reports only the pre-existing constant-false condition in
`build-and-upload.yml` (now line 816).

The touched `build-and-upload.yml` remains approximately 913 lines; no
unrelated workflow refactor is included.
2026-09-19 22:01:30 +02:00

132 lines
4.5 KiB
JavaScript

#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { spawnSync } = require("child_process")
const { MANAGED_NODE_VERSION } = require("./prepare-node-runtime.cjs")
const requiredPackages = [
"yaml",
"fastify",
"@fastify/static",
"@fastify/cors",
"@fastify/reply-from",
"@opencode/client",
"openai",
"pino",
"undici",
"zod",
"node-forge",
]
function parseArgs(argv) {
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === "--resources") options.resources = argv[++index]
else if (arg === "--loading") options.loading = argv[++index]
else if (arg === "--target") options.target = argv[++index]
else throw new Error(`Unknown argument: ${arg}`)
}
if (!options.resources) throw new Error("Missing --resources <path>")
return options
}
function platformDirName() {
const platform = process.platform === "darwin" ? "darwin" : process.platform
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch
return `${platform}-${arch}`
}
function targetParts(target) {
const [platform, ...archParts] = target.split("-")
const arch = archParts.join("-")
if (!platform || !arch) throw new Error(`Invalid target: ${target}`)
return { platform, arch }
}
function nodeBinary(resourcesRoot, target) {
const { platform } = targetParts(target)
const executable = platform === "win32" ? "node.exe" : path.join("bin", "node")
return path.join(resourcesRoot, "node", target, executable)
}
function canExecuteTarget(target) {
return target === platformDirName()
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, { stdio: "inherit", ...options })
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`${command} exited with code ${result.status ?? 1}`)
}
return result
}
function smokeServer(resourcesRoot, target) {
const serverRoot = path.join(resourcesRoot, "server")
const entrypoint = path.join(serverRoot, "dist", "bin.js")
const node = nodeBinary(resourcesRoot, target)
for (const requiredPath of [node, entrypoint, path.join(serverRoot, "node_modules")]) {
if (!fs.existsSync(requiredPath)) throw new Error(`Missing packaged runtime path: ${requiredPath}`)
}
for (const packageName of requiredPackages) {
const packageRoot = path.join(serverRoot, "node_modules", ...packageName.split("/"))
if (!fs.existsSync(packageRoot)) throw new Error(`Missing packaged dependency: ${packageName}`)
}
console.log(`packaged server static checks ok for ${target}`)
if (!canExecuteTarget(target)) {
console.log(`skipping executable smoke for ${target} on host ${platformDirName()}`)
return
}
const version = run(node, ["--version"], { stdio: "pipe", encoding: "utf8" }).stdout.trim()
if (version !== MANAGED_NODE_VERSION) {
throw new Error(`Packaged Node version ${version} does not match ${MANAGED_NODE_VERSION}`)
}
console.log(`packaged Node version ok: ${version}`)
run(node, [entrypoint, "--version"])
const importScript = [
`for (const name of ${JSON.stringify(requiredPackages)}) await import(name);`,
"console.log('packaged dependency imports ok');",
].join(" ")
// Resolve from the packaged server, not the build checkout. The V2 client is ESM-only.
run(node, ["--input-type=module", "-e", importScript], { cwd: serverRoot })
}
function smokeLoadingAssets(loadingRoot) {
if (!loadingRoot) return
const htmlPath = path.join(loadingRoot, "loading.html")
if (!fs.existsSync(htmlPath)) throw new Error(`Missing loading HTML: ${htmlPath}`)
const html = fs.readFileSync(htmlPath, "utf8")
const refs = [...html.matchAll(/(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1])
for (const ref of refs) {
if (/^(?:https?:)?\/\//.test(ref)) continue
if (!ref.startsWith("/assets/") && !ref.startsWith("assets/")) continue
const relative = ref.replace(/^\//, "")
const target = path.join(loadingRoot, relative)
if (!fs.existsSync(target)) throw new Error(`Missing loading asset referenced by ${htmlPath}: ${ref}`)
}
console.log("loading asset references ok")
}
try {
const options = parseArgs(process.argv.slice(2))
const resourcesRoot = path.resolve(options.resources)
const target = options.target ?? platformDirName()
smokeServer(resourcesRoot, target)
smokeLoadingAssets(options.loading ? path.resolve(options.loading) : null)
} catch (error) {
console.error("[smoke-packaged-resources] failed:", error)
process.exit(1)
}