mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 00:48:07 +00:00
windows support
This commit is contained in:
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user