mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-11-02 20:10:31 +00:00
Desktop Tauri builds now update themselves in place on Windows (signed NSIS), macOS x64/arm64 (signed .app.tar.gz) and Linux x64 (signed AppImage), and Linux gains AppImage distribution. The application no longer shells out to `winget upgrade`; WinGet remains a distribution channel. Updates follow the same two feeds as the server notifications: `stable` offers GitHub's latest final release when it is newer; `preview` (dev + release) offers the newest publication of any kind, ordered by publication because dev labels reuse their base version. The saved `server.updateFeed` selects the feed (otherwise the build label). Only releases carrying their signed `latest.json` are installable, so releases still building are never offered. Native code owns URLs, keys and installers: renderers pass only the feed and, after an explicit "Install and restart", the accepted version. The manifest must announce exactly the selected version and `requireSignedVersion` binds it to the signature. Full verification precedes shutdown; installation and restart are admitted atomically with an all-window flush; timeouts, cancellation and failed cleanup discard the installer without replay. Failures belong to the installing window, survive reloads and are reported once; concurrent checks report "busy". Menu actions reach only the focused local application document. The vendored tauri-plugin-updater 2.12.0 runs its Windows exit callback only after a successful installer launch. Publication stages four verified payloads (signed version required) and publishes each release's `latest.json` only after validating every receipt and uploaded digest; rebuilt tags drop a stale manifest first. The signing private key reaches only `tauri build` steps. Unsigned builds and PR runs publish nothing. Debian/RPM and unsupported formats use the release-page fallback. The Tauri CLI minimum is 2.11.5. Validated by eight rounds of independent review (zero findings on both native/CI and renderer/docs), 268 native tests, script and browser regressions, real signed update trials on Windows and Linux (from loopback endpoints, before feed selection), and published-package trials on Windows, Linux and macOS x64. A production-signed dev prerelease, macOS signed updates and draft restoration across an update remain to be exercised.
336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
const fs = require("fs")
|
|
const os = require("os")
|
|
const path = require("path")
|
|
const { spawnSync } = require("child_process")
|
|
|
|
const excludedDistRoots = new Set(["codenomad-server", "opencode-config", "opencode-config-template", "opencode-config.js"])
|
|
const npmTargets = {
|
|
"darwin-x64": { os: "darwin", cpu: "x64" },
|
|
"darwin-arm64": { os: "darwin", cpu: "arm64" },
|
|
"linux-x64": { os: "linux", cpu: "x64" },
|
|
"linux-arm64": { os: "linux", cpu: "arm64" },
|
|
"win32-x64": { os: "win32", cpu: "x64" },
|
|
"win32-arm64": { os: "win32", cpu: "arm64" },
|
|
}
|
|
|
|
function resolveNpmTarget(target = process.env.CODENOMAD_NODE_TARGET || `${process.platform}-${process.arch}`) {
|
|
const npmTarget = npmTargets[target]
|
|
if (!npmTarget) throw new Error(`Unsupported desktop packaging target: ${target}`)
|
|
return { target, ...npmTarget }
|
|
}
|
|
|
|
function resolveLockDependency(packages, ownerPath, dependency) {
|
|
let current = ownerPath
|
|
while (true) {
|
|
const candidate = current ? `${current}/node_modules/${dependency}` : `node_modules/${dependency}`
|
|
if (packages[candidate]) return candidate
|
|
const nested = current.lastIndexOf("/node_modules/")
|
|
if (nested >= 0) current = current.slice(0, nested)
|
|
else if (current.startsWith("node_modules/")) current = ""
|
|
else {
|
|
const parent = current.lastIndexOf("/")
|
|
current = parent >= 0 ? current.slice(0, parent) : ""
|
|
}
|
|
if (!current) {
|
|
const rootCandidate = `node_modules/${dependency}`
|
|
return packages[rootCandidate] ? rootCandidate : null
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateServerProductionLock(lock) {
|
|
const packages = lock && lock.packages
|
|
if (!packages || !packages["packages/server"]) throw new Error("Root package-lock.json does not contain packages/server")
|
|
const pending = ["packages/server"]
|
|
const visited = new Set()
|
|
while (pending.length) {
|
|
const packagePath = pending.pop()
|
|
if (!packagePath || visited.has(packagePath)) continue
|
|
visited.add(packagePath)
|
|
const pkg = packages[packagePath]
|
|
if (!pkg) throw new Error(`Root package-lock.json is missing ${packagePath}`)
|
|
if (packagePath !== "packages/server" && (!pkg.version || !pkg.resolved || !pkg.integrity)) {
|
|
throw new Error(`Root package-lock.json does not integrity-pin ${packagePath}`)
|
|
}
|
|
const dependencies = { ...pkg.dependencies, ...pkg.optionalDependencies }
|
|
for (const dependency of Object.keys(dependencies)) {
|
|
const resolved = resolveLockDependency(packages, packagePath, dependency)
|
|
if (!resolved) throw new Error(`Root package-lock.json cannot resolve ${dependency} from ${packagePath}`)
|
|
pending.push(resolved)
|
|
}
|
|
}
|
|
return visited
|
|
}
|
|
|
|
function stagePackagedServer(options) {
|
|
const { workspaceRoot, serverRoot, log = () => {}, env = process.env } = options
|
|
const npmTarget = resolveNpmTarget(options.target || env.CODENOMAD_NODE_TARGET)
|
|
const lockPath = path.join(workspaceRoot, "package-lock.json")
|
|
validateServerProductionLock(JSON.parse(fs.readFileSync(lockPath, "utf8")))
|
|
|
|
const stagingRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-server-"))
|
|
const stagedServerRoot = path.join(stagingRoot, "packages", "server")
|
|
try {
|
|
fs.mkdirSync(stagedServerRoot, { recursive: true })
|
|
fs.copyFileSync(path.join(workspaceRoot, "package.json"), path.join(stagingRoot, "package.json"))
|
|
fs.copyFileSync(lockPath, path.join(stagingRoot, "package-lock.json"))
|
|
fs.copyFileSync(path.join(serverRoot, "package.json"), path.join(stagedServerRoot, "package.json"))
|
|
|
|
log(`installing production server dependencies from the workspace lock for ${npmTarget.target}`)
|
|
const npmArgs = [
|
|
"ci",
|
|
"--workspace",
|
|
"@neuralnomads/codenomad",
|
|
"--include-workspace-root=false",
|
|
"--omit=dev",
|
|
"--ignore-scripts",
|
|
`--os=${npmTarget.os}`,
|
|
`--cpu=${npmTarget.cpu}`,
|
|
"--fund=false",
|
|
"--audit=false",
|
|
]
|
|
const npmCli = env.npm_execpath && env.npm_node_execpath
|
|
? [env.npm_node_execpath, [env.npm_execpath, ...npmArgs]]
|
|
: process.platform === "win32"
|
|
? [env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm", ...npmArgs]]
|
|
: ["npm", npmArgs]
|
|
const result = spawnSync(npmCli[0], npmCli[1], { cwd: stagingRoot, stdio: "inherit", env })
|
|
if (result.error) throw result.error
|
|
if (result.status !== 0) throw new Error(`npm ci exited with code ${result.status ?? 1}`)
|
|
|
|
const rootModules = path.join(stagingRoot, "node_modules")
|
|
const serverModules = path.join(stagedServerRoot, "node_modules")
|
|
const serverOverrides = path.join(stagingRoot, "server-node-modules")
|
|
if (fs.existsSync(serverModules)) fs.renameSync(serverModules, serverOverrides)
|
|
fs.rmSync(path.join(rootModules, "@neuralnomads", "codenomad"), { recursive: true, force: true })
|
|
fs.cpSync(rootModules, serverModules, { recursive: true, dereference: true })
|
|
if (fs.existsSync(serverOverrides)) fs.cpSync(serverOverrides, serverModules, { recursive: true, dereference: true })
|
|
for (const artifact of ["public", "dist"]) {
|
|
fs.cpSync(path.join(serverRoot, artifact), path.join(stagedServerRoot, artifact), { recursive: true })
|
|
}
|
|
return { stagingRoot, stagedServerRoot, target: npmTarget.target }
|
|
} catch (error) {
|
|
fs.rmSync(stagingRoot, { recursive: true, force: true })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function copyPackagedServerResources(options) {
|
|
const { serverRoot, serverDest, log = () => {} } = options
|
|
|
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
|
fs.mkdirSync(serverDest, { recursive: true })
|
|
|
|
copyRequiredArtifact(serverRoot, serverDest, "package.json", log)
|
|
copyRequiredArtifact(serverRoot, serverDest, "public", log)
|
|
copyRequiredArtifact(serverRoot, serverDest, "node_modules", log)
|
|
copyServerDist(serverRoot, serverDest, log)
|
|
stripNodeModuleBins(path.join(serverDest, "node_modules"), log)
|
|
pruneKnownServerDependencies(path.join(serverDest, "node_modules"), log, resolveNpmTarget(options.target))
|
|
}
|
|
|
|
function copyRequiredArtifact(serverRoot, serverDest, name, log) {
|
|
const from = path.join(serverRoot, name)
|
|
const to = path.join(serverDest, name)
|
|
if (!fs.existsSync(from)) {
|
|
throw new Error(`Missing required server artifact: ${from}`)
|
|
}
|
|
fs.cpSync(from, to, { recursive: true, dereference: true })
|
|
log(`copied ${name}`)
|
|
}
|
|
|
|
function copyServerDist(serverRoot, serverDest, log) {
|
|
const from = path.join(serverRoot, "dist")
|
|
const to = path.join(serverDest, "dist")
|
|
|
|
if (!fs.existsSync(from)) {
|
|
throw new Error(`Missing required server artifact: ${from}`)
|
|
}
|
|
|
|
fs.cpSync(from, to, {
|
|
recursive: true,
|
|
dereference: true,
|
|
filter(source) {
|
|
const relative = path.relative(from, source)
|
|
if (!relative) return true
|
|
const [root] = relative.split(path.sep)
|
|
if (excludedDistRoots.has(root)) return false
|
|
return !/\.test\.js$/.test(path.basename(relative))
|
|
},
|
|
})
|
|
log("copied filtered dist")
|
|
}
|
|
|
|
function stripNodeModuleBins(root, log) {
|
|
if (!fs.existsSync(root)) return
|
|
|
|
const stack = [root]
|
|
let removed = 0
|
|
|
|
while (stack.length > 0) {
|
|
const current = stack.pop()
|
|
if (!current) break
|
|
|
|
let entries
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const full = path.join(current, entry.name)
|
|
if (entry.name === ".bin") {
|
|
fs.rmSync(full, { recursive: true, force: true })
|
|
removed += 1
|
|
continue
|
|
}
|
|
if (entry.isDirectory()) {
|
|
stack.push(full)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (removed > 0) {
|
|
log(`removed ${removed} node_modules/.bin directories`)
|
|
}
|
|
}
|
|
|
|
function removeIfExists(target) {
|
|
if (!fs.existsSync(target)) return 0
|
|
fs.rmSync(target, { recursive: true, force: true })
|
|
return 1
|
|
}
|
|
|
|
function removeFilesMatching(root, patterns) {
|
|
if (!fs.existsSync(root)) return 0
|
|
|
|
const stack = [root]
|
|
let removed = 0
|
|
|
|
while (stack.length > 0) {
|
|
const current = stack.pop()
|
|
if (!current) break
|
|
|
|
let entries
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const full = path.join(current, entry.name)
|
|
if (entry.isDirectory()) {
|
|
stack.push(full)
|
|
continue
|
|
}
|
|
|
|
if (entry.isFile() && patterns.some((pattern) => pattern.test(entry.name))) {
|
|
fs.rmSync(full, { force: true })
|
|
removed += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
return removed
|
|
}
|
|
|
|
function prunePackage(root, options) {
|
|
if (!fs.existsSync(root)) return 0
|
|
|
|
let removed = 0
|
|
for (const relativePath of options.remove ?? []) {
|
|
removed += removeIfExists(path.join(root, relativePath))
|
|
}
|
|
if (options.filePatterns?.length) {
|
|
removed += removeFilesMatching(root, options.filePatterns)
|
|
}
|
|
return removed
|
|
}
|
|
|
|
function pruneKnownServerDependencies(root, log, target) {
|
|
if (!fs.existsSync(root)) return
|
|
|
|
let removed = 0
|
|
const declarationAndMaps = [/\.d\.[cm]?ts$/, /\.map$/]
|
|
const packageDocs = [/\.md$/i, /\.markdown$/i]
|
|
|
|
// Desktop Linux bundles ship the official glibc Node runtime. msgpackr's
|
|
// platform package also includes musl ELFs which cannot load in this runtime
|
|
// and make linuxdeploy abort while resolving their incompatible dependencies.
|
|
if (target.os === "linux") {
|
|
removed += prunePackage(path.join(root, "@msgpackr-extract", `msgpackr-extract-linux-${target.cpu}`), {
|
|
filePatterns: [/\.musl\.node$/],
|
|
})
|
|
}
|
|
|
|
removed += prunePackage(path.join(root, "openai"), {
|
|
remove: ["CHANGELOG.md", "README.md", "bin", "src"],
|
|
filePatterns: [...declarationAndMaps],
|
|
})
|
|
removed += prunePackage(path.join(root, "fastify"), {
|
|
remove: ["docs", "examples", "integration", "test", "types", "build", "fastify.d.ts"],
|
|
filePatterns: [...packageDocs],
|
|
})
|
|
removed += prunePackage(path.join(root, "@fastify", "cors"), {
|
|
remove: ["bench.js", "benchmark", "test", "types"],
|
|
filePatterns: [...packageDocs],
|
|
})
|
|
removed += prunePackage(path.join(root, "@fastify", "reply-from"), {
|
|
remove: ["examples", "test", "types"],
|
|
filePatterns: [...packageDocs],
|
|
})
|
|
removed += prunePackage(path.join(root, "@fastify", "static"), {
|
|
remove: ["example", "test", "types", "tsconfig.eslint.json"],
|
|
filePatterns: [...packageDocs],
|
|
})
|
|
removed += prunePackage(path.join(root, "pino"), {
|
|
remove: [
|
|
"benchmarks",
|
|
"browser.js",
|
|
"build",
|
|
"docs",
|
|
"docsify",
|
|
"examples",
|
|
"favicon-16x16.png",
|
|
"favicon-32x32.png",
|
|
"favicon.ico",
|
|
"index.html",
|
|
"pino-banner.png",
|
|
"pino-logo-hire.png",
|
|
"pino-tree.png",
|
|
"pino.d.ts",
|
|
"pretty-demo.png",
|
|
"test",
|
|
"tsconfig.json",
|
|
],
|
|
filePatterns: [...packageDocs],
|
|
})
|
|
removed += prunePackage(path.join(root, "undici"), {
|
|
remove: ["docs", "index.d.ts", "scripts", "types"],
|
|
filePatterns: [...packageDocs],
|
|
})
|
|
removed += prunePackage(path.join(root, "zod"), {
|
|
remove: ["README.md", "src"],
|
|
filePatterns: [...declarationAndMaps],
|
|
})
|
|
removed += prunePackage(path.join(root, "yaml"), {
|
|
remove: ["README.md", "bin.mjs", "browser"],
|
|
filePatterns: [...declarationAndMaps],
|
|
})
|
|
removed += prunePackage(path.join(root, "node-forge"), {
|
|
remove: ["README.md", "flash"],
|
|
})
|
|
|
|
if (removed > 0) {
|
|
log(`removed ${removed} known non-runtime files/directories from server dependencies`)
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
copyPackagedServerResources,
|
|
resolveNpmTarget,
|
|
stagePackagedServer,
|
|
validateServerProductionLock,
|
|
}
|