nanobot/desktop/scripts/prepare-engine.mjs
Xubin Ren ab9f49970d
feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
2026-06-06 19:49:33 +08:00

266 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
readdir,
readFile,
readlink,
rm,
stat,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..");
const desktopRoot = path.resolve(__dirname, "..");
const engineDest = path.resolve(
process.env.NANOBOT_ENGINE_DEST ?? path.join(desktopRoot, "resources", "nanobot-engine"),
);
const pythonVersion = process.env.NANOBOT_DESKTOP_PYTHON_VERSION ?? "3.12";
const githubBase = "https://github.com/astral-sh/python-build-standalone";
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: "inherit",
...options,
});
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} failed`);
}
}
async function download(url, destination) {
const response = await fetch(url, {
headers: {
"User-Agent": "nanobot/desktop-build",
},
});
if (!response.ok) {
throw new Error(`failed to download ${url}: HTTP ${response.status}`);
}
await writeFile(destination, Buffer.from(await response.arrayBuffer()));
}
async function fetchText(url) {
const response = await fetch(url, {
headers: {
"User-Agent": "nanobot/desktop-build",
"Accept": "text/html",
},
});
if (!response.ok) {
throw new Error(`failed to fetch ${url}: HTTP ${response.status}`);
}
return await response.text();
}
function targetTriple() {
const requested = process.env.NANOBOT_DESKTOP_ARCH ?? process.arch;
if (requested === "arm64" || requested === "aarch64") return "aarch64-apple-darwin";
if (requested === "x64" || requested === "x86_64") return "x86_64-apple-darwin";
throw new Error(`unsupported desktop engine arch: ${requested}`);
}
function latestReleaseTag(html) {
const match = html.match(/\/astral-sh\/python-build-standalone\/releases\/tag\/(\d{8})/);
if (!match) {
throw new Error("could not find latest python-build-standalone release tag");
}
return match[1];
}
async function defaultStandaloneUrl() {
const release =
process.env.PYTHON_STANDALONE_RELEASE
?? latestReleaseTag(await fetchText(`${githubBase}/releases`));
const triple = targetTriple();
const assetHtml = await fetchText(`${githubBase}/releases/expanded_assets/${release}`);
const escapedVersion = pythonVersion.replace(".", "\\.");
const assetPattern = new RegExp(
`cpython-${escapedVersion}\\.\\d+\\+${release}-${triple}-install_only\\.tar\\.gz`,
);
const asset = assetHtml.match(assetPattern)?.[0];
if (!asset) {
throw new Error(
`could not find a CPython ${pythonVersion} install_only asset for ${triple} in ${release}`,
);
}
return `${githubBase}/releases/download/${release}/${asset}`;
}
async function walk(dir, matches = []) {
for (const entry of await readdir(dir)) {
const fullPath = path.join(dir, entry);
const info = await stat(fullPath);
if (info.isDirectory()) {
await walk(fullPath, matches);
} else if (entry === "python3" || entry === "python") {
matches.push(fullPath);
}
}
return matches;
}
async function findStandaloneRoot(extractDir) {
const candidates = await walk(extractDir);
for (const candidate of candidates) {
const normalized = candidate.split(path.sep).join("/");
if (normalized.endsWith("/install/bin/python3")) {
return path.dirname(path.dirname(candidate));
}
}
for (const candidate of candidates) {
const parent = path.dirname(candidate);
if (path.basename(parent) === "bin") {
return path.dirname(parent);
}
}
throw new Error("could not find python-build-standalone bin/python3 in extracted archive");
}
function isInside(parent, child) {
const relative = path.relative(parent, child);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function rewriteInternalSymlinks(root, sourceRoot) {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
const info = await lstat(fullPath);
if (info.isSymbolicLink()) {
const target = await readlink(fullPath);
if (!path.isAbsolute(target) || !isInside(sourceRoot, target)) {
continue;
}
const targetInBundle = path.join(engineDest, path.relative(sourceRoot, target));
const relativeTarget = path.relative(path.dirname(fullPath), targetInBundle);
await unlink(fullPath);
await symlink(relativeTarget, fullPath);
} else if (entry.isDirectory()) {
await rewriteInternalSymlinks(fullPath, sourceRoot);
}
}
}
async function assertNoExternalSymlinks(root) {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
const info = await lstat(fullPath);
if (info.isSymbolicLink()) {
const target = await readlink(fullPath);
if (path.isAbsolute(target) && !isInside(engineDest, target)) {
throw new Error(`external symlink left in engine bundle: ${fullPath} -> ${target}`);
}
} else if (entry.isDirectory()) {
await assertNoExternalSymlinks(fullPath);
}
}
}
async function tarSupportsZstd() {
const result = spawnSync("tar", ["--help"], { encoding: "utf8" });
return `${result.stdout}\n${result.stderr}`.includes("zstd");
}
async function extractArchive(archive, destination) {
await mkdir(destination, { recursive: true });
if (archive.endsWith(".tar.zst") && !(await tarSupportsZstd())) {
throw new Error("tar.zst archives require a tar build with zstd support");
}
run("tar", ["-xf", archive, "-C", destination]);
}
async function resolveArchive() {
const localArchive = process.env.PYTHON_STANDALONE_TARBALL;
if (localArchive) {
return { archive: path.resolve(localArchive), cleanupArchive: false };
}
const url = process.env.PYTHON_STANDALONE_URL ?? await defaultStandaloneUrl();
const suffix = url.endsWith(".tar.gz") ? ".tar.gz" : path.extname(url);
const downloadPath = path.join(tmpdir(), `nanobot-python-${Date.now()}${suffix}`);
console.log(`Downloading Python runtime from ${url}`);
await download(url, downloadPath);
return { archive: downloadPath, cleanupArchive: true };
}
async function installNanobot(pythonPath) {
run(pythonPath, ["-m", "ensurepip", "--upgrade"]);
run(pythonPath, ["-m", "pip", "install", "--upgrade", "pip"]);
const installArgs = ["-m", "pip", "install", "--upgrade"];
const wheelhouse = process.env.NANOBOT_WHEELHOUSE;
if (wheelhouse) {
installArgs.push("--no-index", "--find-links", path.resolve(wheelhouse));
}
installArgs.push(`${repoRoot}[api]`);
run(pythonPath, installArgs);
}
async function writeManifest(pythonPath) {
const version = spawnSync(pythonPath, ["--version"], { encoding: "utf8" });
const pyproject = await readFile(path.join(repoRoot, "pyproject.toml"), "utf8");
const match = pyproject.match(/^version\s*=\s*"([^"]+)"/m);
await writeFile(
path.join(engineDest, "nanobot-engine.json"),
JSON.stringify(
{
python: version.stdout.trim() || version.stderr.trim(),
nanobot_version: match?.[1] ?? "unknown",
prepared_at: new Date().toISOString(),
source: "python-build-standalone",
},
null,
2,
),
"utf8",
);
}
async function main() {
if (process.argv.includes("--print-runtime-url")) {
console.log(await defaultStandaloneUrl());
return;
}
const { archive, cleanupArchive } = await resolveArchive();
const extractDir = path.join(tmpdir(), `nanobot-engine-${Date.now()}`);
try {
await rm(extractDir, { recursive: true, force: true });
await extractArchive(archive, extractDir);
const standaloneRoot = await findStandaloneRoot(extractDir);
await rm(engineDest, { recursive: true, force: true });
await mkdir(path.dirname(engineDest), { recursive: true });
await cp(standaloneRoot, engineDest, { recursive: true });
await rewriteInternalSymlinks(engineDest, standaloneRoot);
await assertNoExternalSymlinks(engineDest);
const pythonPath = path.join(engineDest, "bin", "python3");
await installNanobot(pythonPath);
await writeManifest(pythonPath);
await writeFile(path.join(engineDest, ".gitkeep"), "", "utf8");
console.log(`Prepared nanobot desktop engine at ${engineDest}`);
} finally {
await rm(extractDir, { recursive: true, force: true });
if (cleanupArchive) {
await rm(archive, { force: true });
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});