diff --git a/.github/workflows/desktop-macos-branch-build.yml b/.github/workflows/desktop-macos-branch-build.yml index 0038b1d1..5222fcfd 100644 --- a/.github/workflows/desktop-macos-branch-build.yml +++ b/.github/workflows/desktop-macos-branch-build.yml @@ -61,11 +61,15 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + - name: Build local server bundle + run: node scripts/release/build-server-bundle.js + - name: Verify macOS artifacts run: | test -n "$(find release -maxdepth 1 -name '*.dmg' -print -quit)" test -n "$(find release -maxdepth 1 -name '*.zip' -print -quit)" - shasum -a 256 release/*.{dmg,zip} > release/SHASUMS256.txt + test -n "$(find release/server-bundles -maxdepth 1 -name 'cloudcli-server-*.tar.gz' -print -quit)" + shasum -a 256 release/*.{dmg,zip} release/server-bundles/* > release/SHASUMS256.txt cat release/SHASUMS256.txt - name: Upload branch build artifacts @@ -77,6 +81,7 @@ jobs: release/*.zip release/*.yml release/*.blockmap + release/server-bundles/* release/SHASUMS256.txt if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/desktop-macos-release.yml b/.github/workflows/desktop-macos-release.yml index 307b4a79..033b3212 100644 --- a/.github/workflows/desktop-macos-release.yml +++ b/.github/workflows/desktop-macos-release.yml @@ -81,11 +81,15 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + - name: Build local server bundle + run: node scripts/release/build-server-bundle.js + - name: Verify macOS artifacts run: | test -n "$(find release -maxdepth 1 -name '*.dmg' -print -quit)" test -n "$(find release -maxdepth 1 -name '*.zip' -print -quit)" - shasum -a 256 release/*.{dmg,zip} > release/SHASUMS256.txt + test -n "$(find release/server-bundles -maxdepth 1 -name 'cloudcli-server-*.tar.gz' -print -quit)" + shasum -a 256 release/*.{dmg,zip} release/server-bundles/* > release/SHASUMS256.txt cat release/SHASUMS256.txt - name: Publish GitHub release assets @@ -101,4 +105,5 @@ jobs: release/*.zip release/*.yml release/*.blockmap + release/server-bundles/* release/SHASUMS256.txt diff --git a/.github/workflows/desktop-windows-branch-build.yml b/.github/workflows/desktop-windows-branch-build.yml index 362c1025..e30b060d 100644 --- a/.github/workflows/desktop-windows-branch-build.yml +++ b/.github/workflows/desktop-windows-branch-build.yml @@ -44,12 +44,16 @@ jobs: env: CSC_IDENTITY_AUTO_DISCOVERY: "false" + - name: Build local server bundle + run: node scripts/release/build-server-bundle.js + - name: Verify Windows artifacts shell: bash run: | test -n "$(find release -maxdepth 1 -name '*.exe' -print -quit)" test -n "$(find release -maxdepth 1 -name '*.zip' -print -quit)" - sha256sum release/*.{exe,zip} > release/SHASUMS256.txt + test -n "$(find release/server-bundles -maxdepth 1 -name 'cloudcli-server-*.tar.gz' -print -quit)" + sha256sum release/*.{exe,zip} release/server-bundles/* > release/SHASUMS256.txt cat release/SHASUMS256.txt - name: Upload branch build artifacts @@ -61,6 +65,7 @@ jobs: release/*.zip release/*.yml release/*.blockmap + release/server-bundles/* release/SHASUMS256.txt if-no-files-found: error retention-days: 14 diff --git a/.gitignore b/.gitignore index 04e311bc..270d2eb4 100755 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,8 @@ tasks/ .worktrees/ # Local desktop packaging artifacts +/.desktop-build/ +/release/ cloudcli-sidebar-app-source.tar.gz cloudcli-sidebar.html electron/*.tar.gz diff --git a/README.md b/README.md index ab821b7f..8b4725a8 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ - **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality - **File Explorer** - Interactive file tree with syntax highlighting and live editing - **Git Explorer** - View, stage and commit your changes. You can also switch branches +- **Browser Use** - Open browser sessions for web research, testing, and agent-driven browser tasks - **Session Management** - Resume conversations, manage multiple sessions, and track history - **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation @@ -73,6 +74,11 @@ The fastest way to get started — no local setup required. Get a fully managed, **[Get started with CloudCLI Cloud](https://cloudcli.ai)** +### Desktop App + +Download the latest macOS or Windows desktop app from the **[GitHub Releases](https://github.com/siteboon/claudecodeui/releases)** page. + +Use the desktop app to open CloudCLI Cloud environments, switch between local and remote workspaces, copy mobile/browser URLs, and keep Local CloudCLI available from your menu bar or tray. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you. ### Self-Hosted (Open source) diff --git a/electron/computerAgent.js b/electron/computerAgent.js index fab9cb49..d832ca84 100644 --- a/electron/computerAgent.js +++ b/electron/computerAgent.js @@ -22,7 +22,6 @@ function getNodeRuntime(isPackaged) { return { command: 'node', env: {} }; } -/** Converts an environment access URL (https://x) to its desktop-agent ws URL. */ function toAgentWsUrl(httpUrl) { try { const parsed = new URL(httpUrl); @@ -37,10 +36,8 @@ function toAgentWsUrl(httpUrl) { } /** - * Manages the standalone Computer Use desktop agent process. While the user has - * Computer Use enabled, this keeps an agent connected to every running cloud - * environment so hosted sessions can drive this machine. The local CloudCLI - * server is not involved. + * Keeps a Computer Use desktop agent connected to running cloud environments + * while desktop access is enabled. */ export class ComputerAgentController { constructor({ appRoot, settingsPath, isPackaged = false, getRunningEnvironmentUrls, promptConsent, onChange }) { @@ -97,7 +94,6 @@ export class ComputerAgentController { return this.settings; } - /** Reconciles the agent process with the current settings + environments. */ async sync() { const targets = this.settings.enabled ? (this.getRunningEnvironmentUrls?.() || []) : []; const wsTargets = targets.map(toAgentWsUrl).filter(Boolean); @@ -113,7 +109,7 @@ export class ComputerAgentController { } if (this.child && sameTargets) { - return; // already running with the right targets + return; } this.currentTargets = wsTargets; diff --git a/electron/desktopWindow.js b/electron/desktopWindow.js index 1f1143fa..cfc96de7 100644 --- a/electron/desktopWindow.js +++ b/electron/desktopWindow.js @@ -31,6 +31,31 @@ function buildPlaceholderHtml(title, message, logs = []) { ].join(''); } +function isHttpUrl(url) { + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) { + try { + const source = new URL(sourceUrl); + if ((source.hostname === '127.0.0.1' || source.hostname === 'localhost') && source.protocol === 'http:') { + return true; + } + if (source.protocol !== 'https:') { + return false; + } + const controlPlane = new URL(controlPlaneUrl); + return source.origin === controlPlane.origin || source.hostname.endsWith('.cloudcli.ai'); + } catch { + return false; + } +} + export class DesktopWindowManager { constructor({ appName, @@ -163,6 +188,9 @@ export class DesktopWindowManager { } async showContentTarget(target) { + if (!isHttpUrl(target.url)) { + throw new Error(`Refusing to load unsupported app URL: ${target.url}`); + } const tabId = this.tabs.getTabIdForTarget(target); const view = this.getOrCreateTabView(tabId); this.attachContentView(view); @@ -672,11 +700,8 @@ export class DesktopWindowManager { configurePermissions() { session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => { const sourceUrl = webContents.getURL(); - const isCloudCliOrigin = sourceUrl.startsWith('http://127.0.0.1:') - || sourceUrl.startsWith(this.getCloudState().controlPlaneUrl) - || /^https:\/\/[a-z0-9-]+\.cloudcli\.ai/i.test(sourceUrl); const allowedPermissions = new Set(['clipboard-read', 'media']); - callback(isCloudCliOrigin && allowedPermissions.has(permission)); + callback(isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission)); }); } diff --git a/electron/localServer.js b/electron/localServer.js index 0e0fa8a3..4eca2dbd 100644 --- a/electron/localServer.js +++ b/electron/localServer.js @@ -5,6 +5,8 @@ import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; +import { ServerInstaller } from './serverInstaller.js'; + const DEFAULT_PORT = 3001; const HOST = '127.0.0.1'; const DISPLAY_HOST = 'localhost'; @@ -169,6 +171,26 @@ function getDisplayUrl(baseUrl) { } } +async function pathExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function getServerCwd(appRoot, serverEntry) { + const normalizedEntry = path.resolve(serverEntry); + const bundledEntry = path.resolve(appRoot, 'dist-server', 'server', 'index.js'); + if (normalizedEntry === bundledEntry) { + return appRoot; + } + + // Installed server entries are laid out as /dist-server/server/index.js. + return path.resolve(path.dirname(normalizedEntry), '..', '..'); +} + async function readServerMarkerUrl() { try { const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8'); @@ -210,10 +232,11 @@ async function waitForCloudCliServer(baseUrl, timeoutMs) { } export class LocalServerController { - constructor({ appRoot, settingsPath, isPackaged = false, onChange }) { + constructor({ appRoot, settingsPath, isPackaged = false, appVersion, onChange }) { this.appRoot = appRoot; this.settingsPath = settingsPath; this.isPackaged = isPackaged; + this.appVersion = appVersion; this.onChange = onChange; this.localServerUrl = null; this.localServerPort = null; @@ -334,20 +357,40 @@ export class LocalServerController { }; } - startBundledServer(port) { - const serverEntry = process.env.ELECTRON_SERVER_ENTRY - || path.join(this.appRoot, 'dist-server', 'server', 'index.js'); + /** Resolves the local server entry, installing the matching runtime if needed. */ + async resolveServerEntry() { + if (process.env.ELECTRON_SERVER_ENTRY) { + return process.env.ELECTRON_SERVER_ENTRY; + } + + const bundledEntry = path.join(this.appRoot, 'dist-server', 'server', 'index.js'); + if (process.env.CLOUDCLI_USE_INSTALLED_SERVER !== '1' && await pathExists(bundledEntry)) { + return bundledEntry; + } + + if (!this.appVersion) { + throw new Error('Cannot install local server: app version is unknown.'); + } + const installer = new ServerInstaller({ + version: this.appVersion, + onLog: (line) => this.appendStartupLog(line), + }); + return installer.ensureInstalled(); + } + + startBundledServer(port, serverEntry) { const bindHost = this.getServerBindHost(); const runtime = getNodeRuntime(this.isPackaged); + const serverCwd = getServerCwd(this.appRoot, serverEntry); const command = `${runtime.command} ${serverEntry}`; this.appendStartupLog(`$ ${command}`); this.appendStartupLog(`runtime: ${runtime.label}`); - this.appendStartupLog(`cwd: ${this.appRoot}`); + this.appendStartupLog(`cwd: ${serverCwd}`); this.appendStartupLog(`HOST=${bindHost} SERVER_PORT=${port} NODE_ENV=production`); this.ownedServerProcess = spawn(runtime.command, [serverEntry], { - cwd: this.appRoot, + cwd: serverCwd, detached: true, env: { ...process.env, @@ -414,11 +457,13 @@ export class LocalServerController { } } + const serverEntry = await this.resolveServerEntry(); + const port = await chooseServerPort(this.getServerBindHost()); const serverUrl = `http://${HOST}:${port}`; const displayUrl = `http://${DISPLAY_HOST}:${port}`; this.localServerPort = port; - this.startBundledServer(port); + this.startBundledServer(port, serverEntry); const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS); if (!ready) { diff --git a/electron/main.js b/electron/main.js index d6f493ed..6c2f3dec 100644 --- a/electron/main.js +++ b/electron/main.js @@ -16,6 +16,7 @@ const CALLBACK_PROTOCOL = 'cloudcli'; const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`; const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai'; const REMOTE_START_TIMEOUT_MS = 30000; +const AUTH_CALLBACK_TTL_MS = 10 * 60 * 1000; const tabs = new TabsController(); @@ -26,6 +27,7 @@ let cloud = null; let computerAgent = null; let isQuitting = false; let isRefreshingCloud = false; +let pendingCloudConnectStartedAt = 0; function getAppRoot() { return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..'); @@ -142,22 +144,8 @@ function getDesktopState() { }; } -function isSafeExternalUrl(url) { - try { - const parsed = new URL(url); - return ['https:', 'http:', 'mailto:'].includes(parsed.protocol) - || (parsed.protocol === `${CALLBACK_PROTOCOL}:` && parsed.hostname === 'auth'); - } catch { - return false; - } -} - async function openExternalUrl(url) { - if (!isSafeExternalUrl(url)) { - throw new Error(`Refusing to open unsupported external URL: ${url}`); - } - - if (url.startsWith(`${CALLBACK_PROTOCOL}://`)) { + if (String(url).startsWith(`${CALLBACK_PROTOCOL}://`)) { await handleDeepLink(url); return; } @@ -286,7 +274,6 @@ async function refreshCloudEnvironments({ showErrors = false } = {}) { throw error; } finally { isRefreshingCloud = false; - // Reconcile the Computer Use desktop agent with the latest running environments. void computerAgent?.sync().catch((error) => console.error('[ComputerAgent] sync failed:', error?.message || error)); syncDesktopState(); } @@ -294,6 +281,7 @@ async function refreshCloudEnvironments({ showErrors = false } = {}) { async function connectCloudAccount() { const connectUrl = cloud.buildConnectUrl(); + pendingCloudConnectStartedAt = Date.now(); clipboard.writeText(connectUrl); await openExternalUrl(connectUrl); return connectUrl; @@ -311,6 +299,11 @@ async function handleDeepLink(url) { return; } + if (!pendingCloudConnectStartedAt || Date.now() - pendingCloudConnectStartedAt > AUTH_CALLBACK_TTL_MS) { + await showError('CloudCLI account connection failed', new Error('No recent CloudCLI account connection was started from this app.')); + return; + } + const apiKey = parsed.searchParams.get('api_key'); if (!apiKey) { await showError('CloudCLI account connection failed', new Error('The callback did not include an API key.')); @@ -321,6 +314,7 @@ async function handleDeepLink(url) { apiKey, email: parsed.searchParams.get('email'), }); + pendingCloudConnectStartedAt = 0; await refreshCloudEnvironments({ showErrors: true }); dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, { @@ -360,7 +354,7 @@ async function openLocalWebUi() { throw new Error('Local CloudCLI URL is not available yet.'); } - await shell.openExternal(url); + await openExternalUrl(url); return getDesktopState(); } @@ -414,7 +408,7 @@ async function stopEnvironment(environment) { } async function openEnvironmentInBrowser(environment) { - await shell.openExternal(await cloud.getEnvironmentLaunchUrl(environment)); + await openExternalUrl(await cloud.getEnvironmentLaunchUrl(environment)); return getDesktopState(); } @@ -436,6 +430,26 @@ function getSshHost(credentials) { return atIndex >= 0 ? target.slice(atIndex + 1) : 'ssh.cloudcli.ai'; } +function getSafeSshUsername(credentials) { + const username = String(credentials.username || ''); + if (!/^[a-zA-Z0-9._-]+$/.test(username)) { + throw new Error('Cloud environment returned an invalid SSH username.'); + } + return username; +} + +function getSafeSshHost(credentials) { + const host = getSshHost(credentials); + if (!/^[a-zA-Z0-9.-]+$/.test(host)) { + throw new Error('Cloud environment returned an invalid SSH host.'); + } + return host; +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + async function getEnvironmentCredentials(environment) { const credentials = await cloud.getEnvironmentCredentials(environment); if (credentials.password) { @@ -447,14 +461,15 @@ async function getEnvironmentCredentials(environment) { async function openEnvironmentInIde(environment, ide) { const credentials = await getEnvironmentCredentials(environment); const scheme = ide === 'cursor' ? 'cursor' : 'vscode'; - const remoteUri = `${scheme}://vscode-remote/ssh-remote+${credentials.username}@${getSshHost(credentials)}/workspace/${getProjectFolder(environment)}?windowId=_blank`; + const remoteUri = `${scheme}://vscode-remote/ssh-remote+${getSafeSshUsername(credentials)}@${getSafeSshHost(credentials)}/workspace/${getProjectFolder(environment)}?windowId=_blank`; await shell.openExternal(remoteUri); return getDesktopState(); } async function openEnvironmentInSsh(environment) { const credentials = await getEnvironmentCredentials(environment); - const sshCommand = `ssh -t ${getSshTarget(credentials)} "cd /workspace/${getProjectFolder(environment)} && exec $SHELL -l"`; + const remoteCommand = `cd /workspace/${getProjectFolder(environment)} && exec $SHELL -l`; + const sshCommand = `ssh -t ${shellQuote(getSshTarget(credentials))} ${shellQuote(remoteCommand)}`; if (process.platform === 'darwin') { const escaped = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); @@ -488,7 +503,7 @@ async function copyEnvironmentMobileUrl(environment) { } async function openCloudDashboard() { - await shell.openExternal(CLOUDCLI_CONTROL_PLANE_URL); + await openExternalUrl(CLOUDCLI_CONTROL_PLANE_URL); return getDesktopState(); } @@ -807,6 +822,7 @@ async function bootstrap() { appRoot: getAppRoot(), settingsPath: getSettingsPath(), isPackaged: app.isPackaged, + appVersion: app.getVersion(), onChange: syncDesktopState, }); cloud = new CloudController({ diff --git a/electron/serverInstaller.js b/electron/serverInstaller.js new file mode 100644 index 00000000..5aab396e --- /dev/null +++ b/electron/serverInstaller.js @@ -0,0 +1,275 @@ +import { spawn } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import { createReadStream, createWriteStream } from 'node:fs'; +import https from 'node:https'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * Installs the versioned local server runtime used by CloudCLI Desktop. + * + * Server bundles are cached under: + * ~/.cloudcli/server//dist-server/server/index.js + */ + +const DEFAULT_INSTALL_ROOT = path.join(os.homedir(), '.cloudcli', 'server'); +const DEFAULT_BUNDLE_BASE_URL = 'https://github.com/siteboon/claudecodeui/releases/download'; +const MAX_REDIRECTS = 5; +const LOCAL_DOWNLOAD_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); + +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'; +} + +export class ServerInstaller { + constructor({ + version, + platform = process.platform, + arch = process.arch, + installRoot = process.env.CLOUDCLI_SERVER_DIR || DEFAULT_INSTALL_ROOT, + bundleBaseUrl = process.env.CLOUDCLI_SERVER_BUNDLE_URL || DEFAULT_BUNDLE_BASE_URL, + onLog, + } = {}) { + if (!version) throw new Error('ServerInstaller requires the app version'); + this.version = version; + this.platform = mapPlatform(platform); + this.arch = mapArch(arch); + this.installRoot = installRoot; + this.bundleBaseUrl = bundleBaseUrl.replace(/\/+$/, ''); + this.onLog = typeof onLog === 'function' ? onLog : () => {}; + } + + /** Directory the current version's server is (or will be) installed in. */ + getVersionDir() { + return path.join(this.installRoot, this.version); + } + + /** Absolute path to the server entry once installed. */ + getServerEntry() { + return path.join(this.getVersionDir(), 'dist-server', 'server', 'index.js'); + } + + getBundleName() { + return `cloudcli-server-${this.version}-${this.platform}-${this.arch}.tar.gz`; + } + + getBundleUrl() { + const url = new URL(`${this.bundleBaseUrl}/v${this.version}/${this.getBundleName()}`); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_DOWNLOAD_HOSTS.has(url.hostname))) { + throw new Error(`Refusing unsupported server bundle URL: ${url.toString()}`); + } + return url.toString(); + } + + log(line) { + this.onLog(String(line)); + } + + async isInstalled() { + try { + const marker = JSON.parse( + await fs.readFile(path.join(this.getVersionDir(), '.installed.json'), 'utf8'), + ); + if (marker.version !== this.version) return false; + await fs.access(this.getServerEntry()); + return true; + } catch { + return false; + } + } + + /** + * Ensures the server for this version is installed, downloading + extracting + * it if needed. Returns the resolved server entry path. + */ + async ensureInstalled() { + if (await this.isInstalled()) { + this.log(`Local server ${this.version} already installed.`); + return this.getServerEntry(); + } + + const versionDir = this.getVersionDir(); + const tmpDir = path.join(this.installRoot, `.tmp-${this.version}-${process.pid}`); + const archivePath = path.join(tmpDir, this.getBundleName()); + + await fs.mkdir(tmpDir, { recursive: true }); + try { + const url = this.getBundleUrl(); + this.log(`Downloading local server bundle…`); + this.log(url); + await this.#download(url, archivePath); + await this.#verifyChecksum(url, archivePath); + + this.log('Extracting local server…'); + await fs.rm(versionDir, { recursive: true, force: true }); + await fs.mkdir(versionDir, { recursive: true }); + await this.#validateArchive(archivePath); + await this.#extract(archivePath, versionDir); + + const entry = this.getServerEntry(); + await fs.access(entry); + + await fs.writeFile( + path.join(versionDir, '.installed.json'), + JSON.stringify({ version: this.version, installedAt: new Date().toISOString() }, null, 2), + 'utf8', + ); + this.log(`Local server ${this.version} installed.`); + return entry; + } catch (error) { + await fs.rm(versionDir, { recursive: true, force: true }).catch(() => {}); + throw new Error(`Failed to install local server: ${error.message}`); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + } + + #download(url, destPath, redirectsLeft = MAX_REDIRECTS) { + return new Promise((resolve, reject) => { + const req = https.get(url, (res) => { + const { statusCode, headers } = res; + + if (statusCode >= 300 && statusCode < 400 && headers.location) { + res.resume(); + if (redirectsLeft <= 0) { + reject(new Error('Too many redirects')); + return; + } + const next = new URL(headers.location, url).toString(); + resolve(this.#download(next, destPath, redirectsLeft - 1)); + return; + } + + if (statusCode !== 200) { + res.resume(); + reject(new Error(`Download failed with HTTP ${statusCode}`)); + return; + } + + const total = Number(headers['content-length']) || 0; + let received = 0; + let lastPct = -1; + const out = createWriteStream(destPath); + + res.on('data', (chunk) => { + received += chunk.length; + if (total) { + const pct = Math.floor((received / total) * 100); + if (pct !== lastPct && pct % 10 === 0) { + lastPct = pct; + this.log(`Downloading… ${pct}%`); + } + } + }); + res.pipe(out); + out.on('finish', () => out.close(resolve)); + out.on('error', reject); + res.on('error', reject); + }); + req.on('error', reject); + }); + } + + async #verifyChecksum(url, archivePath) { + let expected; + try { + expected = (await this.#fetchText(`${url}.sha256`)).trim().split(/\s+/)[0]; + } catch (error) { + throw new Error(`Could not verify server bundle checksum: ${error.message}`); + } + const actual = await this.#sha256(archivePath); + if (expected.toLowerCase() !== actual.toLowerCase()) { + throw new Error('Checksum mismatch — refusing to install'); + } + this.log('Checksum verified.'); + } + + #fetchText(url, redirectsLeft = MAX_REDIRECTS) { + return new Promise((resolve, reject) => { + https + .get(url, (res) => { + const { statusCode, headers } = res; + if (statusCode >= 300 && statusCode < 400 && headers.location) { + res.resume(); + if (redirectsLeft <= 0) return reject(new Error('Too many redirects')); + return resolve(this.#fetchText(new URL(headers.location, url).toString(), redirectsLeft - 1)); + } + if (statusCode !== 200) { + res.resume(); + return reject(new Error(`HTTP ${statusCode}`)); + } + let body = ''; + res.setEncoding('utf8'); + res.on('data', (c) => (body += c)); + res.on('end', () => resolve(body)); + res.on('error', reject); + }) + .on('error', reject); + }); + } + + #sha256(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = createReadStream(filePath); + stream.on('data', (c) => hash.update(c)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); + } + + #extract(archivePath, destDir) { + return new Promise((resolve, reject) => { + const child = spawn('tar', ['-xzf', archivePath, '-C', destDir], { + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }); + let stderr = ''; + child.stderr?.on('data', (c) => (stderr += c)); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`tar exited with code ${code}: ${stderr.trim()}`)); + }); + }); + } + + #validateArchive(archivePath) { + return new Promise((resolve, reject) => { + const child = spawn('tar', ['-tzf', archivePath], { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (c) => { stdout += c; }); + child.stderr?.on('data', (c) => { stderr += c; }); + child.once('error', reject); + child.once('exit', (code) => { + if (code !== 0) { + reject(new Error(`tar list exited with code ${code}: ${stderr.trim()}`)); + return; + } + for (const entry of stdout.split(/\r?\n/).filter(Boolean)) { + const normalized = entry.replace(/\\/g, '/'); + if ( + path.isAbsolute(normalized) + || /^[a-zA-Z]:\//.test(normalized) + || normalized.split('/').includes('..') + ) { + reject(new Error(`Refusing unsafe archive entry: ${entry}`)); + return; + } + } + resolve(); + }); + }); + } +} diff --git a/package.json b/package.json index 6cf4f199..cc00efbb 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,11 @@ "client": "vite", "desktop": "electron electron/main.js", "desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js", - "desktop:pack": "npm run build && electron-builder --dir", - "desktop:dist:mac": "npm run build && electron-builder --mac dmg zip", - "desktop:dist:win": "npm run build && electron-builder --win nsis zip", + "desktop:stage": "node scripts/release/prepare-desktop-app.js", + "desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir", + "desktop:dist:mac": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --mac dmg zip", + "desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis zip", + "server:bundle": "npm run build && node scripts/release/build-server-bundle.js", "desktop:icon:mac": "node electron/scripts/generate-macos-icon.js", "build": "npm run build:client && npm run build:server", "build:client": "vite build", @@ -71,7 +73,8 @@ "dist-server/", "shared/", "server/", - "package.json" + "package.json", + "!**/node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*/**" ], "protocols": [ { diff --git a/scripts/release/build-server-bundle.js b/scripts/release/build-server-bundle.js new file mode 100644 index 00000000..f0b0fc0d --- /dev/null +++ b/scripts/release/build-server-bundle.js @@ -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)}`); diff --git a/scripts/release/prepare-desktop-app.js b/scripts/release/prepare-desktop-app.js new file mode 100644 index 00000000..d458b1c7 --- /dev/null +++ b/scripts/release/prepare-desktop-app.js @@ -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(', ')}`); +} diff --git a/server/index.js b/server/index.js index 36b4d3d1..f87349e3 100755 --- a/server/index.js +++ b/server/index.js @@ -78,6 +78,7 @@ const __dirname = getModuleDir(import.meta.url); // The server source runs from /server, while the compiled output runs from /dist-server/server. // Resolving the app root once keeps every repo-level lookup below aligned across both layouts. const APP_ROOT = findAppRoot(__dirname); +const packageJson = JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')); const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; const MAX_FILE_UPLOAD_SIZE_MB = 200; const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024; @@ -158,6 +159,7 @@ app.use(express.urlencoded({ limit: '50mb', extended: true })); app.get('/health', (req, res) => { res.json({ status: 'ok', + version: packageJson.version, timestamp: new Date().toISOString(), installMode });