windows support

This commit is contained in:
Davit
2026-04-17 17:18:33 +04:00
parent 00ef27fb67
commit 2533678ad0
17 changed files with 308 additions and 111 deletions
+16 -2
View File
@@ -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.
+1 -1
View File
@@ -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"
},
+4 -9
View File
@@ -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();
+4 -6
View File
@@ -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<ChannelsResponse>(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<string, string>): 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) {
+4 -7
View File
@@ -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<GwAgentEventPayload> {
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();
});
+5 -7
View File
@@ -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<CronListResponse>(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<string, string>): 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<string, string>): 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) {
+3 -5
View File
@@ -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<PluginInfo[]>(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,
});
+2 -4
View File
@@ -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<SkillInfo[]>(SKILLS_CACHE_TTL, () => {
const raw = execFileSync(OPENCLAW_BIN, ['skills', 'list', '--json', '--verbose'], {
const raw = ocExec(['skills', 'list', '--json', '--verbose'], {
encoding: 'utf-8',
timeout: 30000,
});
+69 -16
View File
@@ -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<typeof execFileSync>[2]): Buffer;
export function ocExec(args: string[], options: Parameters<typeof execFileSync>[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<void> {
const creds = loadGatewayCredentials();
const scopes = creds?.auth?.tokens?.operator?.scopes || [];
@@ -313,17 +370,13 @@ export async function ensureDevicePaired(): Promise<void> {
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<void> {
}
try {
execFileSync(OPENCLAW_BIN, ['gateway', 'call', 'health', '--json'], opts);
ocExec(['gateway', 'call', 'health', '--json'], opts);
} catch {
/* non-critical */
}
+34 -11
View File
@@ -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();
+3 -1
View File
@@ -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();
+1 -1
View File
@@ -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",
+8 -6
View File
@@ -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);
}
+49 -31
View File
@@ -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();
+5 -4
View File
@@ -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');
+58
View File
@@ -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);
}
+42
View File
@@ -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());
}