From 2533678ad0cdfcc66f309efbdf601bef3f8cb275 Mon Sep 17 00:00:00 2001 From: Davit Date: Fri, 17 Apr 2026 17:18:33 +0400 Subject: [PATCH] windows support --- README.MD | 18 +++++- api/package.json | 2 +- api/src/services/openclaw/agents.ts | 13 ++-- api/src/services/openclaw/channels.ts | 10 ++-- api/src/services/openclaw/chat.ts | 11 ++-- api/src/services/openclaw/cron.ts | 12 ++-- api/src/services/openclaw/plugins.ts | 8 +-- api/src/services/openclaw/skills.ts | 6 +- api/src/services/openclawGateway.ts | 85 ++++++++++++++++++++++----- api/src/services/ptyService.ts | 45 ++++++++++---- api/src/services/updateService.ts | 4 +- package.json | 2 +- scripts/build-dist.mjs | 14 +++-- scripts/cli.mjs | 80 +++++++++++++++---------- scripts/dev.js | 9 +-- scripts/proc.mjs | 58 ++++++++++++++++++ scripts/windows-autostart.mjs | 42 +++++++++++++ 17 files changed, 308 insertions(+), 111 deletions(-) create mode 100644 scripts/proc.mjs create mode 100644 scripts/windows-autostart.mjs diff --git a/README.MD b/README.MD index 2cd40f4..15fa2ff 100644 --- a/README.MD +++ b/README.MD @@ -28,6 +28,20 @@ openclaw --version openclaw auth status ``` +### Platform notes + +- **macOS / Linux** — works out of the box. +- **Windows 10/11** — supported. Additionally requires: + - **Git for Windows** (the auto-update flow uses `git`) + - **Visual Studio Build Tools** (for native modules `better-sqlite3` and `node-pty`). + Install with `npm install --global --production windows-build-tools` _or_ install the + "Desktop development with C++" workload from the Visual Studio installer. + - The legacy Python PTY fallback (`pty-bridge.py`) is POSIX-only and is automatically + skipped on Windows — `node-pty` uses ConPTY there instead. + - Run **PowerShell as Administrator** the first time you execute `npm start` so that + `npm link` can create the global `openclaw_client` shim, and so that auto-start can + be installed. + ## Quick Start ```bash @@ -36,7 +50,7 @@ cd openclaw_client npm start ``` -`npm start` builds everything, deploys to `~/.openclaw_client`, registers a macOS **LaunchAgent** (starts on login), and installs the global **`openclaw_client`** command. +`npm start` builds everything, deploys to `~/.openclaw_client`, installs an OS-appropriate auto-start (macOS **LaunchAgent**, Windows **Startup folder** shortcut), and installs the global **`openclaw_client`** command. | Service | URL | | -------- | ------------------------------- | @@ -63,7 +77,7 @@ After `npm start`, the **`openclaw_client`** command works from any directory: | `openclaw_client stop` | Stop servers | | `openclaw_client restart` | Stop + start | | `openclaw_client status` | Show service status | -| `openclaw_client uninstall` | Remove LaunchAgent, global CLI, api & client artifacts (keeps database) | +| `openclaw_client uninstall` | Remove auto-start, global CLI, api & client artifacts (keeps database) | | `openclaw_client uninstall --purge` | Also delete database (asks for confirmation) | To rebuild after code changes, run **`npm start`** from the repo again. diff --git a/api/package.json b/api/package.json index 4ffd41b..e13bf30 100644 --- a/api/package.json +++ b/api/package.json @@ -5,7 +5,7 @@ "main": "app.ts", "scripts": { "lint": "eslint .", - "dev": "nodemon --ext yaml,js,json,ts,*.d.ts --exec 'ts-node ./src/app.ts'", + "dev": "nodemon --ext yaml,js,json,ts,*.d.ts --exec \"ts-node ./src/app.ts\"", "build": "tsc --build", "start": "node build/src/app.js" }, diff --git a/api/src/services/openclaw/agents.ts b/api/src/services/openclaw/agents.ts index 66304e2..7bb8d4d 100644 --- a/api/src/services/openclaw/agents.ts +++ b/api/src/services/openclaw/agents.ts @@ -2,13 +2,10 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { execFileSync } from 'child_process'; -import { getOpenclawBin } from '../openclawGateway'; +import { ocExec } from '../openclawGateway'; import { agentDir, agentsDir, agentWorkspacePath } from './paths'; import { execErrText } from '../../utils/errors'; -const OPENCLAW_BIN = getOpenclawBin(); - const CLI_OPTS = { cwd: os.homedir(), env: { ...process.env, NO_COLOR: '1' }, @@ -59,8 +56,7 @@ export function registerAgent(agentId: string): { const workspace = agentWorkspacePath(agentId); console.log(`[register] adding agent: ${agentId}, workspace: ${workspace}`); try { - const output = execFileSync( - OPENCLAW_BIN, + const output = ocExec( ['agents', 'add', agentId, '--non-interactive', '--workspace', workspace, '--json'], CLI_OPTS ).toString(); @@ -79,7 +75,7 @@ export function setAgentIdentity(agentId: string, name: string): { ok: boolean; try { const args = ['agents', 'set-identity', '--agent', agentId]; if (name) args.push('--name', name); - const output = execFileSync(OPENCLAW_BIN, args, CLI_OPTS).toString(); + const output = ocExec(args, CLI_OPTS).toString(); console.log(`[set-identity] success: ${output.trim()}`); return { ok: true }; } catch (err) { @@ -96,8 +92,7 @@ export function removeAgent(agentId: string): { } { console.log(`[remove] removing agent: ${agentId}`); try { - const output = execFileSync( - OPENCLAW_BIN, + const output = ocExec( ['agents', 'delete', agentId, '--force', '--json'], CLI_OPTS ).toString(); diff --git a/api/src/services/openclaw/channels.ts b/api/src/services/openclaw/channels.ts index 116a3e4..cd232aa 100644 --- a/api/src/services/openclaw/channels.ts +++ b/api/src/services/openclaw/channels.ts @@ -1,11 +1,9 @@ /* eslint-disable no-console */ -import { execFileSync } from 'child_process'; import { ChannelAuth, ChannelChat, ChannelOpResult, ChannelsResponse } from '../../@types/channel'; -import { getOpenclawBin } from '../openclawGateway'; +import { ocExec } from '../openclawGateway'; import { withCache } from '../../utils/cache'; import { errMsg } from '../../utils/errors'; -const OPENCLAW_BIN = getOpenclawBin(); const CHANNELS_CACHE_TTL = 10 * 60 * 1000; interface RawChannelAuth { @@ -21,7 +19,7 @@ interface RawChannelsResponse { } const channelsCache = withCache(CHANNELS_CACHE_TTL, () => { - const raw = execFileSync(OPENCLAW_BIN, ['channels', 'list', '--json', '--no-usage'], { + const raw = ocExec(['channels', 'list', '--json', '--no-usage'], { encoding: 'utf-8', timeout: 30000, }); @@ -56,7 +54,7 @@ export function addChannel(channel: string, opts: Record): Chann Object.entries(opts).forEach(([key, val]) => { if (val) args.push(`--${key}`, val); }); - execFileSync(OPENCLAW_BIN, args, { encoding: 'utf-8', timeout: 30000 }); + ocExec(args, { encoding: 'utf-8', timeout: 30000 }); channelsCache.invalidate(); return { ok: true }; } catch (err) { @@ -68,7 +66,7 @@ export function removeChannel(channel: string, account?: string): ChannelOpResul try { const args = ['channels', 'remove', '--channel', channel, '--delete']; if (account) args.push('--account', account); - execFileSync(OPENCLAW_BIN, args, { encoding: 'utf-8', timeout: 15000 }); + ocExec(args, { encoding: 'utf-8', timeout: 15000 }); channelsCache.invalidate(); return { ok: true }; } catch (err) { diff --git a/api/src/services/openclaw/chat.ts b/api/src/services/openclaw/chat.ts index 3f16993..0b5abaa 100644 --- a/api/src/services/openclaw/chat.ts +++ b/api/src/services/openclaw/chat.ts @@ -1,17 +1,14 @@ /* eslint-disable no-console */ -import { spawn } from 'child_process'; import crypto from 'crypto'; import os from 'os'; import { Response } from 'express'; import { ChatRunHandle, SseEmitter } from '../../@types/openclaw'; import { GwAgentEventPayload, GwEventMessage, GwInboundMessage } from '../../@types/gateway'; -import { gateway, getOpenclawBin, loadGatewayCredentials } from '../openclawGateway'; +import { gateway, loadGatewayCredentials, ocSpawn } from '../openclawGateway'; import { createSseEmitter, GW_RE_PARTIAL_TAG, stripGatewayTags } from './sseEmitter'; import { extractThinkingFromJsonl, getSessionSettingsInternal } from './sessions'; import { errMsg } from '../../utils/errors'; -const OPENCLAW_BIN = getOpenclawBin(); - function isAgentEvent(msg: GwInboundMessage): msg is GwEventMessage { return msg.type === 'event' && (msg.event === 'agent' || msg.event === 'chat'); } @@ -110,7 +107,7 @@ function runAgentWithEmitter( console.log(`[chat] CLI fallback: openclaw ${args.join(' ')}`); - const child = spawn(OPENCLAW_BIN, args, { + const child = ocSpawn(args, { cwd: os.homedir(), env: { ...process.env, NO_COLOR: '1' }, }); @@ -180,14 +177,14 @@ function runAgentWithEmitter( } } - child.stdout.on('data', (data: Buffer) => { + child.stdout?.on('data', (data: Buffer) => { const cleaned = data.toString(); if (!cleaned) return; buf += cleaned; processBuf(); }); - child.stderr.on('data', (data: Buffer) => { + child.stderr?.on('data', (data: Buffer) => { stderrBuf += data.toString(); }); diff --git a/api/src/services/openclaw/cron.ts b/api/src/services/openclaw/cron.ts index e2eecaa..6534bd2 100644 --- a/api/src/services/openclaw/cron.ts +++ b/api/src/services/openclaw/cron.ts @@ -1,15 +1,13 @@ /* eslint-disable no-console */ -import { execFileSync } from 'child_process'; import { CronJob, CronListResponse, CronOpResult } from '../../@types/cron'; -import { getOpenclawBin } from '../openclawGateway'; +import { ocExec } from '../openclawGateway'; import { withCache } from '../../utils/cache'; import { errMsg } from '../../utils/errors'; -const OPENCLAW_BIN = getOpenclawBin(); const CRON_CACHE_TTL = 60 * 1000; const cronCache = withCache(CRON_CACHE_TTL, () => { - const raw = execFileSync(OPENCLAW_BIN, ['cron', 'list', '--all', '--json'], { + const raw = ocExec(['cron', 'list', '--all', '--json'], { encoding: 'utf-8', timeout: 30000, }); @@ -32,7 +30,7 @@ export function addCronJob(opts: Record): CronOpResult { Object.entries(opts).forEach(([key, val]) => { if (val) args.push(`--${key}`, val); }); - execFileSync(OPENCLAW_BIN, args, { encoding: 'utf-8', timeout: 30000 }); + ocExec(args, { encoding: 'utf-8', timeout: 30000 }); cronCache.invalidate(); return { ok: true }; } catch (err) { @@ -42,7 +40,7 @@ export function addCronJob(opts: Record): CronOpResult { export function removeCronJob(id: string): CronOpResult { try { - execFileSync(OPENCLAW_BIN, ['cron', 'rm', id], { encoding: 'utf-8', timeout: 15000 }); + ocExec(['cron', 'rm', id], { encoding: 'utf-8', timeout: 15000 }); cronCache.invalidate(); return { ok: true }; } catch (err) { @@ -53,7 +51,7 @@ export function removeCronJob(id: string): CronOpResult { export function toggleCronJob(id: string, enable: boolean): CronOpResult { try { const cmd = enable ? 'enable' : 'disable'; - execFileSync(OPENCLAW_BIN, ['cron', cmd, id], { encoding: 'utf-8', timeout: 15000 }); + ocExec(['cron', cmd, id], { encoding: 'utf-8', timeout: 15000 }); cronCache.invalidate(); return { ok: true }; } catch (err) { diff --git a/api/src/services/openclaw/plugins.ts b/api/src/services/openclaw/plugins.ts index 157bbd4..ec711d1 100644 --- a/api/src/services/openclaw/plugins.ts +++ b/api/src/services/openclaw/plugins.ts @@ -1,11 +1,9 @@ /* eslint-disable no-console */ -import { execFileSync } from 'child_process'; import { PluginInfo } from '../../@types/plugin'; -import { getOpenclawBin } from '../openclawGateway'; +import { ocExec } from '../openclawGateway'; import { withCache } from '../../utils/cache'; import { errMsg } from '../../utils/errors'; -const OPENCLAW_BIN = getOpenclawBin(); const PLUGINS_CACHE_TTL = 5 * 60 * 1000; interface RawPlugin { @@ -36,7 +34,7 @@ function parsePluginList(raw: string): PluginInfo[] { } const pluginsCache = withCache(PLUGINS_CACHE_TTL, () => { - const raw = execFileSync(OPENCLAW_BIN, ['plugins', 'list', '--json'], { + const raw = ocExec(['plugins', 'list', '--json'], { encoding: 'utf-8', timeout: 30000, }); @@ -55,7 +53,7 @@ export function listPlugins(): PluginInfo[] { export function togglePlugin(pluginId: string, enable: boolean): { ok: boolean; error?: string } { try { const cmd = enable ? 'enable' : 'disable'; - execFileSync(OPENCLAW_BIN, ['plugins', cmd, pluginId], { + ocExec(['plugins', cmd, pluginId], { encoding: 'utf-8', timeout: 15000, }); diff --git a/api/src/services/openclaw/skills.ts b/api/src/services/openclaw/skills.ts index fb07dfb..720812a 100644 --- a/api/src/services/openclaw/skills.ts +++ b/api/src/services/openclaw/skills.ts @@ -1,11 +1,9 @@ /* eslint-disable no-console */ -import { execFileSync } from 'child_process'; import { SkillInfo } from '../../@types/skill'; -import { getOpenclawBin } from '../openclawGateway'; +import { ocExec } from '../openclawGateway'; import { withCache } from '../../utils/cache'; import { errMsg } from '../../utils/errors'; -const OPENCLAW_BIN = getOpenclawBin(); const SKILLS_CACHE_TTL = 5 * 60 * 1000; interface RawSkill { @@ -54,7 +52,7 @@ function parseSkillList(raw: string): SkillInfo[] { } const skillsCache = withCache(SKILLS_CACHE_TTL, () => { - const raw = execFileSync(OPENCLAW_BIN, ['skills', 'list', '--json', '--verbose'], { + const raw = ocExec(['skills', 'list', '--json', '--verbose'], { encoding: 'utf-8', timeout: 30000, }); diff --git a/api/src/services/openclawGateway.ts b/api/src/services/openclawGateway.ts index 9d1cb97..45f48b9 100644 --- a/api/src/services/openclawGateway.ts +++ b/api/src/services/openclawGateway.ts @@ -1,7 +1,13 @@ /* eslint-disable no-console */ import { WebSocket as WsWebSocket } from 'ws'; import crypto from 'crypto'; -import { execFileSync } from 'child_process'; +import { + execFileSync, + ExecFileSyncOptionsWithStringEncoding, + spawn, + SpawnOptions, + ChildProcess, +} from 'child_process'; import fs from 'fs'; import path from 'path'; import os from 'os'; @@ -280,18 +286,33 @@ export class GatewayClient { export const gateway = new GatewayClient(); const OPENCLAW_BIN = (() => { - const candidates = [ - process.env.OPENCLAW_BIN, - '/opt/homebrew/bin/openclaw', - '/usr/local/bin/openclaw', - path.join(os.homedir(), '.local', 'bin', 'openclaw'), - ]; + const isWin = process.platform === 'win32'; + const candidates = isWin + ? [ + process.env.OPENCLAW_BIN, + process.env.APPDATA ? path.join(process.env.APPDATA, 'npm', 'openclaw.cmd') : undefined, + process.env.APPDATA ? path.join(process.env.APPDATA, 'npm', 'openclaw.exe') : undefined, + process.env.LOCALAPPDATA + ? path.join(process.env.LOCALAPPDATA, 'Programs', 'openclaw', 'openclaw.exe') + : undefined, + path.join(os.homedir(), '.local', 'bin', 'openclaw.exe'), + ] + : [ + process.env.OPENCLAW_BIN, + '/opt/homebrew/bin/openclaw', + '/usr/local/bin/openclaw', + path.join(os.homedir(), '.local', 'bin', 'openclaw'), + ]; const found = candidates.find((c) => c && fs.existsSync(c)); if (found) return found; try { - return execFileSync(process.platform === 'win32' ? 'where' : 'which', ['openclaw'], { + const out = execFileSync(isWin ? 'where' : 'which', ['openclaw'], { encoding: 'utf-8', - }).trim(); + }) + .toString() + .trim(); + // `where` can return multiple matches separated by newlines — take the first. + return out.split(/\r?\n/)[0].trim() || 'openclaw'; } catch { return 'openclaw'; } @@ -301,6 +322,42 @@ export function getOpenclawBin(): string { return OPENCLAW_BIN; } +const IS_WINDOWS = process.platform === 'win32'; +const NEEDS_SHELL_RE = /\.(cmd|bat)$/i; + +/** + * Cross-platform invocation of the OpenClaw CLI. + * + * On Windows, npm-installed CLIs are typically `.cmd` shims, which Node's + * `execFileSync` cannot run directly without a shell. This helper transparently + * routes those calls through `cmd.exe /d /s /c` so the rest of the codebase + * doesn't need to know about that detail. + * + * NOTE: arguments are passed as separate argv entries (not concatenated into a + * shell string), so spaces in args are handled by Node — but cmd.exe still + * interprets `&`, `|`, `^` if they appear unquoted in user-supplied args. + * All current call sites pass admin-controlled identifiers, not raw user input. + */ +export function ocExec( + args: string[], + options: ExecFileSyncOptionsWithStringEncoding +): string; +export function ocExec(args: string[], options?: Parameters[2]): Buffer; +export function ocExec(args: string[], options: Parameters[2] = {}): unknown { + if (IS_WINDOWS && NEEDS_SHELL_RE.test(OPENCLAW_BIN)) { + return execFileSync('cmd.exe', ['/d', '/s', '/c', OPENCLAW_BIN, ...args], options); + } + return execFileSync(OPENCLAW_BIN, args, options); +} + +/** Cross-platform `spawn` of the OpenClaw CLI (see {@link ocExec}). */ +export function ocSpawn(args: string[], options: SpawnOptions = {}): ChildProcess { + if (IS_WINDOWS && NEEDS_SHELL_RE.test(OPENCLAW_BIN)) { + return spawn('cmd.exe', ['/d', '/s', '/c', OPENCLAW_BIN, ...args], options); + } + return spawn(OPENCLAW_BIN, args, options); +} + export async function ensureDevicePaired(): Promise { const creds = loadGatewayCredentials(); const scopes = creds?.auth?.tokens?.operator?.scopes || []; @@ -313,17 +370,13 @@ export async function ensureDevicePaired(): Promise { const opts = { cwd: os.homedir(), env: { ...process.env, NO_COLOR: '1' }, timeout: 15000 }; try { - execFileSync(OPENCLAW_BIN, ['gateway', 'call', 'health', '--json'], opts); + ocExec(['gateway', 'call', 'health', '--json'], opts); } catch { /* may fail with pairing required */ } try { - const out = execFileSync( - OPENCLAW_BIN, - ['devices', 'approve', '--latest', '--json'], - opts - ).toString(); + const out = ocExec(['devices', 'approve', '--latest', '--json'], opts).toString(); console.log( '[setup] approved pending device:', out.includes('"requestId"') ? 'ok' : out.trim().slice(0, 200) @@ -338,7 +391,7 @@ export async function ensureDevicePaired(): Promise { } try { - execFileSync(OPENCLAW_BIN, ['gateway', 'call', 'health', '--json'], opts); + ocExec(['gateway', 'call', 'health', '--json'], opts); } catch { /* non-critical */ } diff --git a/api/src/services/ptyService.ts b/api/src/services/ptyService.ts index f1f776d..4908d1c 100644 --- a/api/src/services/ptyService.ts +++ b/api/src/services/ptyService.ts @@ -1,6 +1,7 @@ import { Server as HttpServer } from 'http'; -import { spawn, execSync, ChildProcess } from 'child_process'; +import { spawn, execFileSync, ChildProcess } from 'child_process'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import * as pty from 'node-pty'; import { WebSocketServer, WebSocket } from 'ws'; @@ -8,6 +9,8 @@ import { URL } from 'url'; import jwt from 'jsonwebtoken'; import { getOpenclawBin } from './openclawGateway'; +const IS_WINDOWS = process.platform === 'win32'; + function resolveBridge(): string { const candidates = [ path.join(__dirname, '..', '..', 'pty-bridge.py'), @@ -38,13 +41,19 @@ function verifyToken(token: string): boolean { } function findBinary(name: string): string { + const lookup = IS_WINDOWS ? 'where' : 'which'; try { - return execSync(`which ${name}`, { encoding: 'utf8' }).trim(); + const out = execFileSync(lookup, [name], { encoding: 'utf8' }).toString().trim(); + return out.split(/\r?\n/)[0].trim() || name; } catch { return name; } } +function defaultCwd(): string { + return process.env.HOME || process.env.USERPROFILE || os.homedir() || os.tmpdir(); +} + /** Strip undefined so node-pty / spawn do not get invalid env values. */ function cleanEnv(env: NodeJS.ProcessEnv): { [key: string]: string } { const out: { [key: string]: string } = {}; @@ -55,9 +64,10 @@ function cleanEnv(env: NodeJS.ProcessEnv): { [key: string]: string } { return out; } -function isPosixSpawnFailure(err: unknown): boolean { +function isRecoverableSpawnFailure(err: unknown): boolean { const msg = err instanceof Error ? err.message : String(err); - return msg.includes('posix_spawn'); + // posix_spawn is the macOS/Linux case; CreateProcess is node-pty on Windows (ConPTY). + return msg.includes('posix_spawn') || /CreateProcess/i.test(msg) || /EACCES|ENOENT/.test(msg); } function trySpawnNodePty(openclawBin: string, agentName: string): pty.IPty { @@ -65,16 +75,27 @@ function trySpawnNodePty(openclawBin: string, agentName: string): pty.IPty { name: 'xterm-256color' as const, cols: 80, rows: 24, - cwd: process.env.HOME || '/tmp', + cwd: defaultCwd(), env: cleanEnv({ ...process.env, TERM: 'xterm-256color' }), }; - const attempts: [string, string[]][] = [[openclawBin, ['agents', 'add', agentName]]]; + // On Windows, .cmd/.bat shims must go through cmd.exe; node-pty can't spawn them directly. + const baseArgs = ['agents', 'add', agentName]; + const attempts: [string, string[]][] = []; + if (IS_WINDOWS && /\.(cmd|bat)$/i.test(openclawBin)) { + attempts.push(['cmd.exe', ['/d', '/s', '/c', openclawBin, ...baseArgs]]); + } else { + attempts.push([openclawBin, baseArgs]); + } try { const resolved = fs.realpathSync(openclawBin); if (resolved !== openclawBin) { - attempts.push([resolved, ['agents', 'add', agentName]]); + if (IS_WINDOWS && /\.(cmd|bat)$/i.test(resolved)) { + attempts.push(['cmd.exe', ['/d', '/s', '/c', resolved, ...baseArgs]]); + } else { + attempts.push([resolved, baseArgs]); + } } } catch { /* keep single attempt */ @@ -87,7 +108,7 @@ function trySpawnNodePty(openclawBin: string, agentName: string): pty.IPty { return pty.spawn(file, args, opts); } catch (e) { lastErr = e; - if (!isPosixSpawnFailure(e)) { + if (!isRecoverableSpawnFailure(e)) { if (e instanceof Error) throw e; throw new Error(String(e)); } @@ -107,7 +128,7 @@ function spawnPythonBridge( python3Bin, ['-u', BRIDGE_SCRIPT, '80', '24', openclawBin, 'agents', 'add', agentName], { - cwd: process.env.HOME || '/tmp', + cwd: defaultCwd(), env: process.env as NodeJS.ProcessEnv, stdio: ['pipe', 'pipe', 'pipe'], } @@ -253,7 +274,8 @@ export default function attachPtyWebSocket(server: HttpServer): void { return; } - const forcePython = process.env.PTY_BACKEND === 'python'; + // Python bridge is POSIX-only (fcntl/termios/forkpty); Windows uses node-pty + ConPTY. + const forcePython = !IS_WINDOWS && process.env.PTY_BACKEND === 'python'; if (!forcePython) { try { @@ -265,7 +287,8 @@ export default function attachPtyWebSocket(server: HttpServer): void { return; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - if (!isPosixSpawnFailure(err)) { + const canFallback = !IS_WINDOWS && isRecoverableSpawnFailure(err); + if (!canFallback) { console.error(`[pty] spawn failed: ${msg}`); /* eslint-disable-line */ ws.send(JSON.stringify({ type: 'error', message: `Failed to start PTY: ${msg}` })); ws.close(); diff --git a/api/src/services/updateService.ts b/api/src/services/updateService.ts index b4e20f2..43a5fe9 100644 --- a/api/src/services/updateService.ts +++ b/api/src/services/updateService.ts @@ -134,7 +134,9 @@ export async function applyUpdate(): Promise<{ ok: boolean; error?: string }> { stdio: ['ignore', fd, fd], env: { ...process.env, - PATH: [path.dirname(process.execPath), process.env.PATH || ''].filter(Boolean).join(':'), + PATH: [path.dirname(process.execPath), process.env.PATH || ''] + .filter(Boolean) + .join(path.delimiter), }, }); child.unref(); diff --git a/package.json b/package.json index 5a64231..e0cd83b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-client", - "version": "2.3.8", + "version": "2.3.9", "description": "Web-based chat interface for OpenClaw AI agents", "private": true, "type": "module", diff --git a/scripts/build-dist.mjs b/scripts/build-dist.mjs index 84d0dd3..023177d 100644 --- a/scripts/build-dist.mjs +++ b/scripts/build-dist.mjs @@ -5,13 +5,15 @@ import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { portEnv, readPorts } from './ports.mjs'; +import { NPM_BIN } from './proc.mjs'; const SERVE_MJS = ` import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const DIST = path.join(path.dirname(new URL(import.meta.url).pathname), 'dist'); +const DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dist'); const PORT = Number(process.env.CLIENT_PORT) || Number(process.env.PORT) || 18800; const API_PORT = Number(process.env.API_PORT) || 18802; @@ -88,14 +90,14 @@ export function deploy() { const buildEnv = { ...process.env, ...portEnv() }; process.stdout.write('📦 Installing dependencies...\n'); - run('npm', ['ci', '--include=dev'], API_SRC); - run('npm', ['ci', '--include=dev'], CLIENT_SRC); + run(NPM_BIN, ['ci', '--include=dev'], API_SRC); + run(NPM_BIN, ['ci', '--include=dev'], CLIENT_SRC); process.stdout.write('🔨 Building...\n'); - run('npm', ['run', 'build'], API_SRC); + run(NPM_BIN, ['run', 'build'], API_SRC); // VITE_API_BASE_URL is embedded into the bundle at build time try { - execFileSync('npm', ['run', 'build'], { cwd: CLIENT_SRC, stdio: 'pipe', env: buildEnv }); + execFileSync(NPM_BIN, ['run', 'build'], { cwd: CLIENT_SRC, stdio: 'pipe', env: buildEnv }); } catch (err) { const output = err.stdout?.toString() || ''; const stderr = err.stderr?.toString() || ''; @@ -208,5 +210,5 @@ export function deploy() { ); process.stdout.write('📦 Installing production dependencies...\n'); - run('npm', ['ci', '--omit=dev'], apiDist); + run(NPM_BIN, ['ci', '--omit=dev'], apiDist); } diff --git a/scripts/cli.mjs b/scripts/cli.mjs index c12b104..c4a3533 100755 --- a/scripts/cli.mjs +++ b/scripts/cli.mjs @@ -18,6 +18,13 @@ import { writeLaunchAgentPlist, } from './launchd.mjs'; import { portEnv, readPorts } from './ports.mjs'; +import { IS_DARWIN, IS_WINDOWS, NPM_BIN, killPort, portListening } from './proc.mjs'; +import { + getStartupCmdPath, + removeWindowsAutostart, + windowsAutostartInstalled, + writeWindowsAutostart, +} from './windows-autostart.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DIST = path.join(os.homedir(), '.openclaw_client'); @@ -34,35 +41,17 @@ function currentPorts() { // ── helpers ────────────────────────────────────────────────────────────────── -function isDarwin() { - return process.platform === 'darwin'; -} - function killPorts() { const { all } = currentPorts(); - for (const port of all) { - try { - const pids = execFileSync('lsof', ['-ti', `:${port}`], { encoding: 'utf-8' }).trim(); - for (const p of pids.split('\n').filter(Boolean)) { - try { process.kill(+p); } catch { /* gone */ } - } - } catch { /* free */ } - } -} - -function portListening(port) { - try { - const out = execFileSync('lsof', ['-ti', `:${port}`], { encoding: 'utf-8' }).trim(); - return out.length > 0; - } catch { return false; } + for (const port of all) killPort(port); } function linkGlobal() { - execFileSync('npm', ['link'], { cwd: ROOT, stdio: 'pipe' }); + execFileSync(NPM_BIN, ['link'], { cwd: ROOT, stdio: 'pipe' }); } function unlinkGlobal() { - try { execFileSync('npm', ['unlink', '-g', 'openclaw-client'], { stdio: 'pipe' }); } catch { /* ok */ } + try { execFileSync(NPM_BIN, ['unlink', '-g', 'openclaw-client'], { stdio: 'pipe' }); } catch { /* ok */ } } function assertBuilt() { @@ -92,11 +81,25 @@ function installLaunchd() { bootstrapLaunchAgent(); } +function installWindowsAutostart() { + const runner = path.join(DIST, 'service-runner.mjs'); + writeWindowsAutostart({ + nodePath: process.execPath, + runnerPath: runner, + workDir: DIST, + logPath: LOG_FILE, + }); +} + function detachStart() { const fd = openSync(LOG_FILE, 'w'); const env = { ...process.env, NODE_ENV: 'production', ...portEnv() }; - const api = spawn('node', ['build/src/app.js'], { cwd: API_DIST, stdio: ['ignore', fd, fd], env, detached: true }); - const client = spawn('node', ['serve.mjs'], { cwd: CLIENT_DIST, stdio: ['ignore', fd, fd], env, detached: true }); + const common = { stdio: ['ignore', fd, fd], env, detached: true }; + // On Windows, `detached: true` with `windowsHide: true` keeps the servers + // alive after the parent exits without flashing a console window. + if (IS_WINDOWS) common.windowsHide = true; + const api = spawn(process.execPath, ['build/src/app.js'], { cwd: API_DIST, ...common }); + const client = spawn(process.execPath, ['serve.mjs'], { cwd: CLIENT_DIST, ...common }); api.unref(); client.unref(); closeSync(fd); @@ -114,13 +117,16 @@ function confirm(question) { // ── commands ───────────────────────────────────────────────────────────────── -/** npm start only — full build + deploy + launchd + global link */ +/** npm start only — full build + deploy + autostart (os-specific) + global link */ export function fullStart() { deploy(); killPorts(); - if (isDarwin()) { + if (IS_DARWIN) { installLaunchd(); + } else if (IS_WINDOWS) { + installWindowsAutostart(); + detachStart(); } else { detachStart(); } @@ -131,7 +137,8 @@ export function fullStart() { console.log(''); console.log(' 🚀 OpenClaw Client is running'); console.log(` 🌐 http://localhost:${clientPort}`); - if (isDarwin()) console.log(' 🔄 Starts automatically on login (LaunchAgent)'); + if (IS_DARWIN) console.log(' 🔄 Starts automatically on login (LaunchAgent)'); + else if (IS_WINDOWS) console.log(' 🔄 Starts automatically on login (Startup folder)'); console.log(' 📁 ~/.openclaw_client'); console.log(' ⚙️ Ports: ~/.openclaw_client/.env'); console.log(' 🛠️ openclaw_client status | stop | restart | uninstall'); @@ -143,8 +150,11 @@ function cmdStart() { assertBuilt(); killPorts(); - if (isDarwin()) { + if (IS_DARWIN) { installLaunchd(); + } else if (IS_WINDOWS) { + installWindowsAutostart(); + detachStart(); } else { detachStart(); } @@ -157,13 +167,13 @@ function cmdStart() { } function cmdStop() { - if (isDarwin()) bootoutLaunchAgent(); + if (IS_DARWIN) bootoutLaunchAgent(); killPorts(); console.log(' 🛑 OpenClaw Client stopped'); } function cmdRestart() { - if (isDarwin()) { + if (IS_DARWIN) { if (!existsSync(getPlistPath())) { console.log('❌ No LaunchAgent installed. Run `npm start` first.'); return; @@ -187,7 +197,7 @@ function cmdStatus() { console.log(' 📦 OpenClaw Client'); console.log(` 📁 ${DIST}`); - if (isDarwin()) { + if (IS_DARWIN) { try { const out = execFileSync('launchctl', ['print', `${getLaunchdDomain()}/${LAUNCH_AGENT_LABEL}`], { encoding: 'utf-8' }); const state = out.match(/^\s*state = (\S+)/m)?.[1] ?? 'unknown'; @@ -200,6 +210,12 @@ function cmdStatus() { } catch { console.log(' ❌ LaunchAgent: not loaded'); } + } else if (IS_WINDOWS) { + if (windowsAutostartInstalled()) { + console.log(` ✅ Autostart: installed (${getStartupCmdPath()})`); + } else { + console.log(' ❌ Autostart: not installed'); + } } const { apiPort, clientPort } = currentPorts(); @@ -230,9 +246,11 @@ async function cmdUninstall(args) { } } - if (isDarwin()) { + if (IS_DARWIN) { bootoutLaunchAgent(); removePlistFile(); + } else if (IS_WINDOWS) { + removeWindowsAutostart(); } killPorts(); unlinkGlobal(); diff --git a/scripts/dev.js b/scripts/dev.js index 1c3a5ee..8835616 100644 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { portEnv, readPorts } from './ports.mjs'; +import { NPM_BIN } from './proc.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const API_DIR = path.join(ROOT, 'api'); @@ -26,11 +27,11 @@ run('node', [path.join(ROOT, 'scripts', 'setup.js')]); // 2. Install dependencies if needed if (!fs.existsSync(path.join(API_DIR, 'node_modules'))) { process.stdout.write('📦 Installing API dependencies...\n'); - run('npm', ['install'], API_DIR); + run(NPM_BIN, ['install'], API_DIR); } if (!fs.existsSync(path.join(CLIENT_DIR, 'node_modules'))) { process.stdout.write('📦 Installing Client dependencies...\n'); - run('npm', ['install'], CLIENT_DIR); + run(NPM_BIN, ['install'], CLIENT_DIR); } // 3. Start both services in dev mode @@ -44,8 +45,8 @@ console.log(` 🧩 API: http://localhost:${apiPort}`); console.log(' ⚙️ Ports: ~/.openclaw_client/.env'); console.log(); -const api = spawn('npm', ['run', 'dev'], { cwd: API_DIR, stdio: 'pipe', env: childEnv }); -const client = spawn('npm', ['run', 'dev'], { cwd: CLIENT_DIR, stdio: 'pipe', env: childEnv }); +const api = spawn(NPM_BIN, ['run', 'dev'], { cwd: API_DIR, stdio: 'pipe', env: childEnv }); +const client = spawn(NPM_BIN, ['run', 'dev'], { cwd: CLIENT_DIR, stdio: 'pipe', env: childEnv }); prefix(api.stdout, 'API'); prefix(api.stderr, 'API'); diff --git a/scripts/proc.mjs b/scripts/proc.mjs new file mode 100644 index 0000000..4527c6e --- /dev/null +++ b/scripts/proc.mjs @@ -0,0 +1,58 @@ +import { execFileSync } from 'node:child_process'; + +export const IS_WINDOWS = process.platform === 'win32'; +export const IS_DARWIN = process.platform === 'darwin'; + +/** `npm` is `npm.cmd` on Windows — direct `spawn('npm',...)` can fail on some setups. */ +export const NPM_BIN = IS_WINDOWS ? 'npm.cmd' : 'npm'; + +/** List PIDs currently listening on `port` (cross-platform). */ +export function pidsOnPort(port) { + if (IS_WINDOWS) { + try { + const out = execFileSync('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf-8' }); + const pids = new Set(); + for (const line of out.split(/\r?\n/)) { + const parts = line.trim().split(/\s+/); + if (parts.length < 5) continue; + const [proto, local, , state, pid] = parts; + if (proto !== 'TCP' || state !== 'LISTENING') continue; + // Match both IPv4 (0.0.0.0:PORT) and IPv6 ([::]:PORT) + if (local.endsWith(`:${port}`)) pids.add(pid); + } + return [...pids]; + } catch { + return []; + } + } + try { + const out = execFileSync('lsof', ['-ti', `:${port}`], { encoding: 'utf-8' }).trim(); + return out.split('\n').filter(Boolean); + } catch { + return []; + } +} + +export function portListening(port) { + return pidsOnPort(port).length > 0; +} + +export function killPid(pid) { + if (IS_WINDOWS) { + try { + execFileSync('taskkill', ['/F', '/PID', String(pid)], { stdio: 'ignore' }); + } catch { + /* already gone */ + } + return; + } + try { + process.kill(+pid); + } catch { + /* already gone */ + } +} + +export function killPort(port) { + for (const pid of pidsOnPort(port)) killPid(pid); +} diff --git a/scripts/windows-autostart.mjs b/scripts/windows-autostart.mjs new file mode 100644 index 0000000..9aa92cb --- /dev/null +++ b/scripts/windows-autostart.mjs @@ -0,0 +1,42 @@ +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +export const STARTUP_CMD_NAME = 'OpenClawClient.cmd'; + +export function getStartupFolder() { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + return path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'); +} + +export function getStartupCmdPath() { + return path.join(getStartupFolder(), STARTUP_CMD_NAME); +} + +/** + * Install a Startup-folder .cmd that launches the service-runner on login. + * Uses `start "" /B` to avoid showing a console window. + * @param {{ nodePath: string; runnerPath: string; workDir: string; logPath: string }} opts + */ +export function writeWindowsAutostart(opts) { + const { nodePath, runnerPath, workDir, logPath } = opts; + const folder = getStartupFolder(); + mkdirSync(folder, { recursive: true }); + const script = [ + '@echo off', + `cd /d "${workDir}"`, + `start "" /B "${nodePath}" "${runnerPath}" 1>> "${logPath}" 2>&1`, + '', + ].join('\r\n'); + writeFileSync(getStartupCmdPath(), script); + return getStartupCmdPath(); +} + +export function removeWindowsAutostart() { + const p = getStartupCmdPath(); + if (existsSync(p)) unlinkSync(p); +} + +export function windowsAutostartInstalled() { + return existsSync(getStartupCmdPath()); +}