mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-24 11:15:48 +08:00
Add on-demand desktop server bundle
This commit is contained in:
@@ -61,11 +61,15 @@ jobs:
|
|||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Build local server bundle
|
||||||
|
run: node scripts/release/build-server-bundle.js
|
||||||
|
|
||||||
- name: Verify macOS artifacts
|
- name: Verify macOS artifacts
|
||||||
run: |
|
run: |
|
||||||
test -n "$(find release -maxdepth 1 -name '*.dmg' -print -quit)"
|
test -n "$(find release -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||||
test -n "$(find release -maxdepth 1 -name '*.zip' -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
|
cat release/SHASUMS256.txt
|
||||||
|
|
||||||
- name: Upload branch build artifacts
|
- name: Upload branch build artifacts
|
||||||
@@ -77,6 +81,7 @@ jobs:
|
|||||||
release/*.zip
|
release/*.zip
|
||||||
release/*.yml
|
release/*.yml
|
||||||
release/*.blockmap
|
release/*.blockmap
|
||||||
|
release/server-bundles/*
|
||||||
release/SHASUMS256.txt
|
release/SHASUMS256.txt
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|||||||
7
.github/workflows/desktop-macos-release.yml
vendored
7
.github/workflows/desktop-macos-release.yml
vendored
@@ -81,11 +81,15 @@ jobs:
|
|||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Build local server bundle
|
||||||
|
run: node scripts/release/build-server-bundle.js
|
||||||
|
|
||||||
- name: Verify macOS artifacts
|
- name: Verify macOS artifacts
|
||||||
run: |
|
run: |
|
||||||
test -n "$(find release -maxdepth 1 -name '*.dmg' -print -quit)"
|
test -n "$(find release -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||||
test -n "$(find release -maxdepth 1 -name '*.zip' -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
|
cat release/SHASUMS256.txt
|
||||||
|
|
||||||
- name: Publish GitHub release assets
|
- name: Publish GitHub release assets
|
||||||
@@ -101,4 +105,5 @@ jobs:
|
|||||||
release/*.zip
|
release/*.zip
|
||||||
release/*.yml
|
release/*.yml
|
||||||
release/*.blockmap
|
release/*.blockmap
|
||||||
|
release/server-bundles/*
|
||||||
release/SHASUMS256.txt
|
release/SHASUMS256.txt
|
||||||
|
|||||||
@@ -44,12 +44,16 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
|
||||||
|
- name: Build local server bundle
|
||||||
|
run: node scripts/release/build-server-bundle.js
|
||||||
|
|
||||||
- name: Verify Windows artifacts
|
- name: Verify Windows artifacts
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
test -n "$(find release -maxdepth 1 -name '*.exe' -print -quit)"
|
test -n "$(find release -maxdepth 1 -name '*.exe' -print -quit)"
|
||||||
test -n "$(find release -maxdepth 1 -name '*.zip' -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
|
cat release/SHASUMS256.txt
|
||||||
|
|
||||||
- name: Upload branch build artifacts
|
- name: Upload branch build artifacts
|
||||||
@@ -61,6 +65,7 @@ jobs:
|
|||||||
release/*.zip
|
release/*.zip
|
||||||
release/*.yml
|
release/*.yml
|
||||||
release/*.blockmap
|
release/*.blockmap
|
||||||
|
release/server-bundles/*
|
||||||
release/SHASUMS256.txt
|
release/SHASUMS256.txt
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -145,6 +145,8 @@ tasks/
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
# Local desktop packaging artifacts
|
# Local desktop packaging artifacts
|
||||||
|
/.desktop-build/
|
||||||
|
/release/
|
||||||
cloudcli-sidebar-app-source.tar.gz
|
cloudcli-sidebar-app-source.tar.gz
|
||||||
cloudcli-sidebar.html
|
cloudcli-sidebar.html
|
||||||
electron/*.tar.gz
|
electron/*.tar.gz
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
|
- **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
|
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
- **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
|
- **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)
|
- **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
|
- **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)**
|
**[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)
|
### Self-Hosted (Open source)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ function getNodeRuntime(isPackaged) {
|
|||||||
return { command: 'node', env: {} };
|
return { command: 'node', env: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Converts an environment access URL (https://x) to its desktop-agent ws URL. */
|
|
||||||
function toAgentWsUrl(httpUrl) {
|
function toAgentWsUrl(httpUrl) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(httpUrl);
|
const parsed = new URL(httpUrl);
|
||||||
@@ -37,10 +36,8 @@ function toAgentWsUrl(httpUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the standalone Computer Use desktop agent process. While the user has
|
* Keeps a Computer Use desktop agent connected to running cloud environments
|
||||||
* Computer Use enabled, this keeps an agent connected to every running cloud
|
* while desktop access is enabled.
|
||||||
* environment so hosted sessions can drive this machine. The local CloudCLI
|
|
||||||
* server is not involved.
|
|
||||||
*/
|
*/
|
||||||
export class ComputerAgentController {
|
export class ComputerAgentController {
|
||||||
constructor({ appRoot, settingsPath, isPackaged = false, getRunningEnvironmentUrls, promptConsent, onChange }) {
|
constructor({ appRoot, settingsPath, isPackaged = false, getRunningEnvironmentUrls, promptConsent, onChange }) {
|
||||||
@@ -97,7 +94,6 @@ export class ComputerAgentController {
|
|||||||
return this.settings;
|
return this.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reconciles the agent process with the current settings + environments. */
|
|
||||||
async sync() {
|
async sync() {
|
||||||
const targets = this.settings.enabled ? (this.getRunningEnvironmentUrls?.() || []) : [];
|
const targets = this.settings.enabled ? (this.getRunningEnvironmentUrls?.() || []) : [];
|
||||||
const wsTargets = targets.map(toAgentWsUrl).filter(Boolean);
|
const wsTargets = targets.map(toAgentWsUrl).filter(Boolean);
|
||||||
@@ -113,7 +109,7 @@ export class ComputerAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.child && sameTargets) {
|
if (this.child && sameTargets) {
|
||||||
return; // already running with the right targets
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentTargets = wsTargets;
|
this.currentTargets = wsTargets;
|
||||||
|
|||||||
@@ -31,6 +31,31 @@ function buildPlaceholderHtml(title, message, logs = []) {
|
|||||||
].join('');
|
].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 {
|
export class DesktopWindowManager {
|
||||||
constructor({
|
constructor({
|
||||||
appName,
|
appName,
|
||||||
@@ -163,6 +188,9 @@ export class DesktopWindowManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async showContentTarget(target) {
|
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 tabId = this.tabs.getTabIdForTarget(target);
|
||||||
const view = this.getOrCreateTabView(tabId);
|
const view = this.getOrCreateTabView(tabId);
|
||||||
this.attachContentView(view);
|
this.attachContentView(view);
|
||||||
@@ -672,11 +700,8 @@ export class DesktopWindowManager {
|
|||||||
configurePermissions() {
|
configurePermissions() {
|
||||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||||
const sourceUrl = webContents.getURL();
|
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']);
|
const allowedPermissions = new Set(['clipboard-read', 'media']);
|
||||||
callback(isCloudCliOrigin && allowedPermissions.has(permission));
|
callback(isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import net from 'node:net';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { ServerInstaller } from './serverInstaller.js';
|
||||||
|
|
||||||
const DEFAULT_PORT = 3001;
|
const DEFAULT_PORT = 3001;
|
||||||
const HOST = '127.0.0.1';
|
const HOST = '127.0.0.1';
|
||||||
const DISPLAY_HOST = 'localhost';
|
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 <root>/dist-server/server/index.js.
|
||||||
|
return path.resolve(path.dirname(normalizedEntry), '..', '..');
|
||||||
|
}
|
||||||
|
|
||||||
async function readServerMarkerUrl() {
|
async function readServerMarkerUrl() {
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8');
|
const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8');
|
||||||
@@ -210,10 +232,11 @@ async function waitForCloudCliServer(baseUrl, timeoutMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LocalServerController {
|
export class LocalServerController {
|
||||||
constructor({ appRoot, settingsPath, isPackaged = false, onChange }) {
|
constructor({ appRoot, settingsPath, isPackaged = false, appVersion, onChange }) {
|
||||||
this.appRoot = appRoot;
|
this.appRoot = appRoot;
|
||||||
this.settingsPath = settingsPath;
|
this.settingsPath = settingsPath;
|
||||||
this.isPackaged = isPackaged;
|
this.isPackaged = isPackaged;
|
||||||
|
this.appVersion = appVersion;
|
||||||
this.onChange = onChange;
|
this.onChange = onChange;
|
||||||
this.localServerUrl = null;
|
this.localServerUrl = null;
|
||||||
this.localServerPort = null;
|
this.localServerPort = null;
|
||||||
@@ -334,20 +357,40 @@ export class LocalServerController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
startBundledServer(port) {
|
/** Resolves the local server entry, installing the matching runtime if needed. */
|
||||||
const serverEntry = process.env.ELECTRON_SERVER_ENTRY
|
async resolveServerEntry() {
|
||||||
|| path.join(this.appRoot, 'dist-server', 'server', 'index.js');
|
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 bindHost = this.getServerBindHost();
|
||||||
const runtime = getNodeRuntime(this.isPackaged);
|
const runtime = getNodeRuntime(this.isPackaged);
|
||||||
|
const serverCwd = getServerCwd(this.appRoot, serverEntry);
|
||||||
|
|
||||||
const command = `${runtime.command} ${serverEntry}`;
|
const command = `${runtime.command} ${serverEntry}`;
|
||||||
this.appendStartupLog(`$ ${command}`);
|
this.appendStartupLog(`$ ${command}`);
|
||||||
this.appendStartupLog(`runtime: ${runtime.label}`);
|
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.appendStartupLog(`HOST=${bindHost} SERVER_PORT=${port} NODE_ENV=production`);
|
||||||
|
|
||||||
this.ownedServerProcess = spawn(runtime.command, [serverEntry], {
|
this.ownedServerProcess = spawn(runtime.command, [serverEntry], {
|
||||||
cwd: this.appRoot,
|
cwd: serverCwd,
|
||||||
detached: true,
|
detached: true,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -414,11 +457,13 @@ export class LocalServerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serverEntry = await this.resolveServerEntry();
|
||||||
|
|
||||||
const port = await chooseServerPort(this.getServerBindHost());
|
const port = await chooseServerPort(this.getServerBindHost());
|
||||||
const serverUrl = `http://${HOST}:${port}`;
|
const serverUrl = `http://${HOST}:${port}`;
|
||||||
const displayUrl = `http://${DISPLAY_HOST}:${port}`;
|
const displayUrl = `http://${DISPLAY_HOST}:${port}`;
|
||||||
this.localServerPort = port;
|
this.localServerPort = port;
|
||||||
this.startBundledServer(port);
|
this.startBundledServer(port, serverEntry);
|
||||||
|
|
||||||
const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS);
|
const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS);
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const CALLBACK_PROTOCOL = 'cloudcli';
|
|||||||
const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`;
|
const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`;
|
||||||
const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai';
|
const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai';
|
||||||
const REMOTE_START_TIMEOUT_MS = 30000;
|
const REMOTE_START_TIMEOUT_MS = 30000;
|
||||||
|
const AUTH_CALLBACK_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
const tabs = new TabsController();
|
const tabs = new TabsController();
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ let cloud = null;
|
|||||||
let computerAgent = null;
|
let computerAgent = null;
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
let isRefreshingCloud = false;
|
let isRefreshingCloud = false;
|
||||||
|
let pendingCloudConnectStartedAt = 0;
|
||||||
|
|
||||||
function getAppRoot() {
|
function getAppRoot() {
|
||||||
return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..');
|
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) {
|
async function openExternalUrl(url) {
|
||||||
if (!isSafeExternalUrl(url)) {
|
if (String(url).startsWith(`${CALLBACK_PROTOCOL}://`)) {
|
||||||
throw new Error(`Refusing to open unsupported external URL: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.startsWith(`${CALLBACK_PROTOCOL}://`)) {
|
|
||||||
await handleDeepLink(url);
|
await handleDeepLink(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -286,7 +274,6 @@ async function refreshCloudEnvironments({ showErrors = false } = {}) {
|
|||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshingCloud = false;
|
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));
|
void computerAgent?.sync().catch((error) => console.error('[ComputerAgent] sync failed:', error?.message || error));
|
||||||
syncDesktopState();
|
syncDesktopState();
|
||||||
}
|
}
|
||||||
@@ -294,6 +281,7 @@ async function refreshCloudEnvironments({ showErrors = false } = {}) {
|
|||||||
|
|
||||||
async function connectCloudAccount() {
|
async function connectCloudAccount() {
|
||||||
const connectUrl = cloud.buildConnectUrl();
|
const connectUrl = cloud.buildConnectUrl();
|
||||||
|
pendingCloudConnectStartedAt = Date.now();
|
||||||
clipboard.writeText(connectUrl);
|
clipboard.writeText(connectUrl);
|
||||||
await openExternalUrl(connectUrl);
|
await openExternalUrl(connectUrl);
|
||||||
return connectUrl;
|
return connectUrl;
|
||||||
@@ -311,6 +299,11 @@ async function handleDeepLink(url) {
|
|||||||
return;
|
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');
|
const apiKey = parsed.searchParams.get('api_key');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
await showError('CloudCLI account connection failed', new Error('The callback did not include an API key.'));
|
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,
|
apiKey,
|
||||||
email: parsed.searchParams.get('email'),
|
email: parsed.searchParams.get('email'),
|
||||||
});
|
});
|
||||||
|
pendingCloudConnectStartedAt = 0;
|
||||||
await refreshCloudEnvironments({ showErrors: true });
|
await refreshCloudEnvironments({ showErrors: true });
|
||||||
|
|
||||||
dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||||
@@ -360,7 +354,7 @@ async function openLocalWebUi() {
|
|||||||
throw new Error('Local CloudCLI URL is not available yet.');
|
throw new Error('Local CloudCLI URL is not available yet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await shell.openExternal(url);
|
await openExternalUrl(url);
|
||||||
return getDesktopState();
|
return getDesktopState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +408,7 @@ async function stopEnvironment(environment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openEnvironmentInBrowser(environment) {
|
async function openEnvironmentInBrowser(environment) {
|
||||||
await shell.openExternal(await cloud.getEnvironmentLaunchUrl(environment));
|
await openExternalUrl(await cloud.getEnvironmentLaunchUrl(environment));
|
||||||
return getDesktopState();
|
return getDesktopState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +430,26 @@ function getSshHost(credentials) {
|
|||||||
return atIndex >= 0 ? target.slice(atIndex + 1) : 'ssh.cloudcli.ai';
|
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) {
|
async function getEnvironmentCredentials(environment) {
|
||||||
const credentials = await cloud.getEnvironmentCredentials(environment);
|
const credentials = await cloud.getEnvironmentCredentials(environment);
|
||||||
if (credentials.password) {
|
if (credentials.password) {
|
||||||
@@ -447,14 +461,15 @@ async function getEnvironmentCredentials(environment) {
|
|||||||
async function openEnvironmentInIde(environment, ide) {
|
async function openEnvironmentInIde(environment, ide) {
|
||||||
const credentials = await getEnvironmentCredentials(environment);
|
const credentials = await getEnvironmentCredentials(environment);
|
||||||
const scheme = ide === 'cursor' ? 'cursor' : 'vscode';
|
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);
|
await shell.openExternal(remoteUri);
|
||||||
return getDesktopState();
|
return getDesktopState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEnvironmentInSsh(environment) {
|
async function openEnvironmentInSsh(environment) {
|
||||||
const credentials = await getEnvironmentCredentials(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') {
|
if (process.platform === 'darwin') {
|
||||||
const escaped = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
const escaped = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
@@ -488,7 +503,7 @@ async function copyEnvironmentMobileUrl(environment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openCloudDashboard() {
|
async function openCloudDashboard() {
|
||||||
await shell.openExternal(CLOUDCLI_CONTROL_PLANE_URL);
|
await openExternalUrl(CLOUDCLI_CONTROL_PLANE_URL);
|
||||||
return getDesktopState();
|
return getDesktopState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,6 +822,7 @@ async function bootstrap() {
|
|||||||
appRoot: getAppRoot(),
|
appRoot: getAppRoot(),
|
||||||
settingsPath: getSettingsPath(),
|
settingsPath: getSettingsPath(),
|
||||||
isPackaged: app.isPackaged,
|
isPackaged: app.isPackaged,
|
||||||
|
appVersion: app.getVersion(),
|
||||||
onChange: syncDesktopState,
|
onChange: syncDesktopState,
|
||||||
});
|
});
|
||||||
cloud = new CloudController({
|
cloud = new CloudController({
|
||||||
|
|||||||
275
electron/serverInstaller.js
Normal file
275
electron/serverInstaller.js
Normal file
@@ -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/<version>/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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
11
package.json
11
package.json
@@ -34,9 +34,11 @@
|
|||||||
"client": "vite",
|
"client": "vite",
|
||||||
"desktop": "electron electron/main.js",
|
"desktop": "electron electron/main.js",
|
||||||
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 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:stage": "node scripts/release/prepare-desktop-app.js",
|
||||||
"desktop:dist:mac": "npm run build && electron-builder --mac dmg zip",
|
"desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir",
|
||||||
"desktop:dist:win": "npm run build && electron-builder --win nsis zip",
|
"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",
|
"desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
|
||||||
"build": "npm run build:client && npm run build:server",
|
"build": "npm run build:client && npm run build:server",
|
||||||
"build:client": "vite build",
|
"build:client": "vite build",
|
||||||
@@ -71,7 +73,8 @@
|
|||||||
"dist-server/",
|
"dist-server/",
|
||||||
"shared/",
|
"shared/",
|
||||||
"server/",
|
"server/",
|
||||||
"package.json"
|
"package.json",
|
||||||
|
"!**/node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*/**"
|
||||||
],
|
],
|
||||||
"protocols": [
|
"protocols": [
|
||||||
{
|
{
|
||||||
|
|||||||
175
scripts/release/build-server-bundle.js
Normal file
175
scripts/release/build-server-bundle.js
Normal 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)}`);
|
||||||
146
scripts/release/prepare-desktop-app.js
Normal file
146
scripts/release/prepare-desktop-app.js
Normal 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(', ')}`);
|
||||||
|
}
|
||||||
@@ -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.
|
// 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.
|
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||||
const APP_ROOT = findAppRoot(__dirname);
|
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 installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||||
const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||||
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
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) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
|
version: packageJson.version,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
installMode
|
installMode
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user