Add on-demand desktop server bundle

This commit is contained in:
Simos Mikelatos
2026-06-18 21:08:29 +00:00
parent 7786763dd1
commit f75ae385dd
14 changed files with 752 additions and 46 deletions

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import { createReadStream, readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const packageJson = JSON.parse(
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
);
function getElectronVersion() {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
).version;
} catch {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
).packages['node_modules/electron'].version;
} catch {
throw new Error('Could not resolve an exact Electron version for server native rebuild.');
}
}
}
function mapArch(arch = process.arch) {
return arch === 'arm64' ? 'arm64' : 'x64';
}
function mapPlatform(platform = process.platform) {
if (platform === 'darwin') return 'mac';
if (platform === 'win32') return 'win';
return 'linux';
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
shell: process.platform === 'win32',
...options,
});
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
});
});
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function copyRequired(stageDir, relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) {
throw new Error(`Required server bundle input is missing: ${relativePath}`);
}
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
}
async function copyIfExists(stageDir, relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) return;
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
}
async function writeServerPackageJson(stageDir) {
const stagedPackageJson = {
...packageJson,
scripts: {
...(packageJson.scripts || {}),
},
};
// The bundle stage is not a git checkout with dev dependencies, so lifecycle
// scripts such as Husky prepare must not run there. Dependency install scripts
// still run; native modules need them before the Electron ABI rebuild below.
delete stagedPackageJson.scripts.prepare;
delete stagedPackageJson.scripts.prepublishOnly;
await fs.writeFile(
path.join(stageDir, 'package.json'),
`${JSON.stringify(stagedPackageJson, null, 2)}\n`,
'utf8',
);
}
function sha256(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
const platform = mapPlatform(process.env.CLOUDCLI_BUNDLE_PLATFORM || process.platform);
const arch = mapArch(process.env.CLOUDCLI_BUNDLE_ARCH || process.arch);
const version = packageJson.version;
const bundleName = `cloudcli-server-${version}-${platform}-${arch}.tar.gz`;
const bundleRoot = path.join(rootDir, 'release', 'server-bundles');
const stageDir = path.join(bundleRoot, `.stage-${version}-${platform}-${arch}`);
const archivePath = path.join(bundleRoot, bundleName);
await fs.rm(stageDir, { recursive: true, force: true });
await fs.mkdir(stageDir, { recursive: true });
await fs.mkdir(bundleRoot, { recursive: true });
await copyRequired(stageDir, 'dist');
await copyRequired(stageDir, 'dist-server');
await copyRequired(stageDir, 'public');
await copyRequired(stageDir, 'shared');
await copyRequired(stageDir, 'package-lock.json');
await copyIfExists(stageDir, 'scripts/fix-node-pty.js');
await writeServerPackageJson(stageDir);
console.log('Installing production server dependencies into bundle stage...');
await run('npm', ['ci', '--omit=dev'], {
cwd: stageDir,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
});
const electronVersion = getElectronVersion();
const electronRebuild = process.platform === 'win32'
? path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild.cmd')
: path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild');
console.log(`Rebuilding native server dependencies for Electron ${electronVersion} (${arch})...`);
await run(electronRebuild, ['--version', electronVersion, '--module-dir', stageDir, '--arch', arch, '--force'], {
cwd: rootDir,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
});
if (await pathExists(path.join(stageDir, 'scripts', 'fix-node-pty.js'))) {
await run(process.execPath, ['scripts/fix-node-pty.js'], { cwd: stageDir });
}
await fs.writeFile(
path.join(stageDir, '.installed.json'),
JSON.stringify({ version, platform, arch, builtAt: new Date().toISOString() }, null, 2),
'utf8',
);
await fs.rm(archivePath, { force: true });
const tarArgs = process.platform === 'win32'
? ['-czf', archivePath, '-C', stageDir, '.']
: ['-czf', archivePath, '-C', stageDir, '.'];
await run('tar', tarArgs);
const digest = await sha256(archivePath);
const checksumPath = `${archivePath}.sha256`;
await fs.writeFile(checksumPath, `${digest} ${bundleName}\n`, 'utf8');
await fs.rm(stageDir, { recursive: true, force: true });
const size = (await fs.stat(archivePath)).size / 1024 / 1024;
console.log(`Wrote ${path.relative(rootDir, archivePath)} (${size.toFixed(1)} MB)`);
console.log(`Wrote ${path.relative(rootDir, checksumPath)}`);

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const stageDir = path.join(rootDir, '.desktop-build', 'desktop-app');
const packageJson = JSON.parse(
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
);
function getElectronVersion() {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
).version;
} catch {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
).packages['node_modules/electron'].version;
} catch {
throw new Error('Could not resolve an exact Electron version for desktop packaging.');
}
}
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function copyRequired(relativePath) {
const from = path.join(rootDir, relativePath);
const to = path.join(stageDir, relativePath);
if (!(await pathExists(from))) {
throw new Error(`Required desktop build input is missing: ${relativePath}`);
}
await fs.cp(from, to, { recursive: true });
}
async function copyIfExists(relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) return false;
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
return true;
}
async function copyNodeModule(packageName) {
const parts = packageName.split('/');
const source = path.join(rootDir, 'node_modules', ...parts);
if (!(await pathExists(source))) return false;
const target = path.join(stageDir, 'node_modules', ...parts);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.cp(source, target, { recursive: true });
return true;
}
function buildDesktopPackageJson(copiedOptionalDependencies) {
return {
name: `${packageJson.name}-desktop`,
version: packageJson.version,
productName: packageJson.productName,
description: `${packageJson.productName} desktop shell`,
author: packageJson.author,
license: packageJson.license,
type: 'module',
main: 'electron/main.js',
dependencies: {
ws: packageJson.dependencies.ws,
},
optionalDependencies: copiedOptionalDependencies,
build: {
appId: packageJson.build.appId,
productName: packageJson.build.productName,
asar: packageJson.build.asar,
artifactName: packageJson.build.artifactName,
electronVersion: getElectronVersion(),
directories: {
output: '../../release',
},
extraMetadata: {
main: 'electron/main.js',
},
files: [
'electron/**',
'public/**',
'dist/**',
'dist-server/**',
'node_modules/**',
'package.json',
],
protocols: packageJson.build.protocols,
mac: packageJson.build.mac,
win: packageJson.build.win,
},
};
}
await fs.rm(stageDir, { recursive: true, force: true });
await fs.mkdir(stageDir, { recursive: true });
await copyRequired('electron');
await copyRequired('dist');
await copyRequired('public');
// The desktop app still ships the standalone Computer Use desktop agent, but
// not the full local server. Local CloudCLI is downloaded on demand.
await copyRequired('dist-server/server/computer-use-agent.js');
await copyIfExists('dist-server/server/computer-use-agent.js.map');
await copyRequired('dist-server/server/modules/computer-use/computer-executor.js');
await copyIfExists('dist-server/server/modules/computer-use/computer-executor.js.map');
const copiedRuntimeDependencies = [];
if (await copyNodeModule('ws')) {
copiedRuntimeDependencies.push('ws');
} else {
throw new Error('Required desktop dependency is missing from node_modules: ws');
}
const copiedOptionalDependencies = {};
for (const [name, version] of Object.entries(packageJson.optionalDependencies || {})) {
if (await copyNodeModule(name)) {
copiedOptionalDependencies[name] = version;
}
}
await fs.writeFile(
path.join(stageDir, 'package.json'),
`${JSON.stringify(buildDesktopPackageJson(copiedOptionalDependencies), null, 2)}\n`,
'utf8',
);
console.log(`Prepared thin desktop app at ${path.relative(rootDir, stageDir)}`);
console.log(`Runtime dependencies: ${copiedRuntimeDependencies.join(', ')}`);
if (Object.keys(copiedOptionalDependencies).length) {
console.log(`Optional dependencies: ${Object.keys(copiedOptionalDependencies).join(', ')}`);
}