@brierb/brier-cli 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ import { createDaemonManager } from '../daemon/DaemonManager.js';
2
+ import { loadConfig } from '../config.js';
3
+ export const restartCommand = async (options) => {
4
+ let serverUrl = options.serverUrl;
5
+ let token = options.token ?? process.env.BRIER_TOKEN;
6
+ if (!serverUrl || !token) {
7
+ try {
8
+ const config = loadConfig({});
9
+ if (!serverUrl)
10
+ serverUrl = config.serverUrl;
11
+ if (!token)
12
+ token = config.token;
13
+ }
14
+ catch {
15
+ // Will be caught below
16
+ }
17
+ }
18
+ if (!serverUrl) {
19
+ throw new Error('Server URL is required. Pass --server-url or set BRIER_SERVER_URL.');
20
+ }
21
+ if (!token) {
22
+ throw new Error('BRIER_TOKEN is required. Pass --token or set BRIER_TOKEN env var.');
23
+ }
24
+ const manager = createDaemonManager();
25
+ await manager.restart({ serverUrl, token });
26
+ console.log('✓ 后台服务已重启');
27
+ console.log(` Server: ${serverUrl}`);
28
+ };
@@ -0,0 +1,13 @@
1
+ import { createDaemonManager } from '../daemon/DaemonManager.js';
2
+ import { LOG_FILE } from '../config.js';
3
+ export const startCommand = async (options) => {
4
+ const token = options.token ?? process.env.BRIER_TOKEN;
5
+ if (!token) {
6
+ throw new Error('BRIER_TOKEN is required. Pass --token or set BRIER_TOKEN env var.');
7
+ }
8
+ const manager = createDaemonManager();
9
+ await manager.start({ serverUrl: options.serverUrl, token });
10
+ console.log('✓ 后台服务已启动');
11
+ console.log(` Server: ${options.serverUrl}`);
12
+ console.log(` Log: ${LOG_FILE}`);
13
+ };
@@ -0,0 +1,27 @@
1
+ import { createDaemonManager } from '../daemon/DaemonManager.js';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { PID_FILE } from '../config.js';
4
+ export const statusCommand = () => {
5
+ const manager = createDaemonManager();
6
+ const status = manager.status();
7
+ if (status === 'running') {
8
+ let pidInfo = null;
9
+ if (existsSync(PID_FILE)) {
10
+ try {
11
+ pidInfo = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
12
+ }
13
+ catch {
14
+ // ignore
15
+ }
16
+ }
17
+ console.log('● 后台服务运行中');
18
+ if (pidInfo) {
19
+ console.log(` PID: ${pidInfo.pid}`);
20
+ console.log(` Server: ${pidInfo.serverUrl}`);
21
+ console.log(` Started: ${new Date(pidInfo.startTime).toLocaleString()}`);
22
+ }
23
+ }
24
+ else {
25
+ console.log('○ 后台服务未运行');
26
+ }
27
+ };
@@ -0,0 +1,6 @@
1
+ import { createDaemonManager } from '../daemon/DaemonManager.js';
2
+ export const stopCommand = async () => {
3
+ const manager = createDaemonManager();
4
+ await manager.stop();
5
+ console.log('✓ 后台服务已停止');
6
+ };
package/dist/config.js ADDED
@@ -0,0 +1,44 @@
1
+ import { hostname as getHostname, type as osType, arch, platform } from 'node:os';
2
+ import { execSync } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { RUNTIME_REGISTRY } from './runtimes.js';
6
+ export const BRIER_DIR = join(homedir(), '.brier');
7
+ export const PID_FILE = join(BRIER_DIR, 'daemon.pid');
8
+ export const LOG_FILE = join(BRIER_DIR, 'daemon.log');
9
+ export const loadConfig = (options) => {
10
+ const serverUrl = options.serverUrl ?? process.env.BRIER_SERVER_URL;
11
+ const token = options.token ?? process.env.BRIER_TOKEN;
12
+ if (!serverUrl) {
13
+ throw new Error('Server URL is required. Use --server-url or set BRIER_SERVER_URL');
14
+ }
15
+ if (!token) {
16
+ throw new Error('BRIER_TOKEN is required. Pass --token or set BRIER_TOKEN env var');
17
+ }
18
+ return {
19
+ serverUrl,
20
+ token,
21
+ hostname: getHostname(),
22
+ os: `${osType()} ${platform()} ${arch()}`,
23
+ runtimes: detectRuntimes(),
24
+ };
25
+ };
26
+ export const toWsUrl = (serverUrl) => {
27
+ return (serverUrl
28
+ .replace(/^https:\/\//, 'wss://')
29
+ .replace(/^http:\/\//, 'ws://')
30
+ .replace(/\/$/, '') + '/tunnel');
31
+ };
32
+ const detectRuntimes = () => {
33
+ const runtimes = [];
34
+ for (const { name, command } of RUNTIME_REGISTRY) {
35
+ try {
36
+ execSync(`command -v ${command}`, { stdio: 'pipe' });
37
+ runtimes.push(name);
38
+ }
39
+ catch {
40
+ // not installed
41
+ }
42
+ }
43
+ return runtimes;
44
+ };
@@ -0,0 +1,127 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { BRIER_DIR, PID_FILE } from '../config.js';
6
+ import { logger } from '../logger.js';
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+ const RUNNER_SCRIPT = join(__dirname, 'DaemonRunner.js');
10
+ const isProcessRunning = (pid) => {
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ }
15
+ catch (err) {
16
+ return err instanceof Error && 'code' in err && err.code === 'EPERM';
17
+ }
18
+ };
19
+ const readPidFile = () => {
20
+ if (!existsSync(PID_FILE))
21
+ return null;
22
+ try {
23
+ const data = readFileSync(PID_FILE, 'utf-8');
24
+ return JSON.parse(data);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ };
30
+ const writePidFile = (data) => {
31
+ mkdirSync(BRIER_DIR, { recursive: true });
32
+ writeFileSync(PID_FILE, JSON.stringify(data, null, 2));
33
+ };
34
+ const removePidFile = () => {
35
+ if (existsSync(PID_FILE)) {
36
+ unlinkSync(PID_FILE);
37
+ }
38
+ };
39
+ const waitForExit = (pid, timeoutMs = 5000) => {
40
+ return new Promise((resolve) => {
41
+ const startTime = Date.now();
42
+ const check = () => {
43
+ if (!isProcessRunning(pid)) {
44
+ resolve(true);
45
+ return;
46
+ }
47
+ if (Date.now() - startTime >= timeoutMs) {
48
+ resolve(false);
49
+ return;
50
+ }
51
+ setTimeout(check, 200);
52
+ };
53
+ check();
54
+ });
55
+ };
56
+ export const createDaemonManager = () => {
57
+ const start = async (options) => {
58
+ const existing = readPidFile();
59
+ if (existing && isProcessRunning(existing.pid)) {
60
+ throw new Error(`Daemon is already running (PID: ${existing.pid})`);
61
+ }
62
+ if (existing) {
63
+ removePidFile();
64
+ }
65
+ const childEnv = {
66
+ ...process.env,
67
+ BRIER_TOKEN: options.token,
68
+ BRIER_SERVER_URL: options.serverUrl,
69
+ BRIER_DAEMON_MODE: '1',
70
+ };
71
+ const child = spawn(process.execPath, [RUNNER_SCRIPT], {
72
+ detached: true,
73
+ stdio: 'ignore',
74
+ env: childEnv,
75
+ });
76
+ child.unref();
77
+ if (typeof child.pid !== 'number') {
78
+ throw new Error('Failed to spawn daemon process');
79
+ }
80
+ writePidFile({
81
+ pid: child.pid,
82
+ startTime: Date.now(),
83
+ serverUrl: options.serverUrl,
84
+ });
85
+ logger.info('Daemon started, PID:', child.pid);
86
+ };
87
+ const stop = async () => {
88
+ const data = readPidFile();
89
+ if (!data) {
90
+ logger.warn('No PID file found, daemon may not be running');
91
+ return;
92
+ }
93
+ if (!isProcessRunning(data.pid)) {
94
+ logger.info('Process not running, cleaning up PID file');
95
+ removePidFile();
96
+ return;
97
+ }
98
+ logger.info('Sending SIGTERM to PID:', data.pid);
99
+ process.kill(data.pid, 'SIGTERM');
100
+ const exited = await waitForExit(data.pid, 5000);
101
+ if (!exited) {
102
+ logger.warn('Process did not exit, sending SIGKILL');
103
+ try {
104
+ process.kill(data.pid, 'SIGKILL');
105
+ }
106
+ catch (err) {
107
+ logger.error('Failed to kill process:', err);
108
+ }
109
+ }
110
+ removePidFile();
111
+ logger.info('Daemon stopped');
112
+ };
113
+ const restart = async (options) => {
114
+ await stop();
115
+ await start(options);
116
+ };
117
+ const status = () => {
118
+ const data = readPidFile();
119
+ if (!data)
120
+ return 'stopped';
121
+ if (isProcessRunning(data.pid))
122
+ return 'running';
123
+ removePidFile();
124
+ return 'stopped';
125
+ };
126
+ return { start, stop, restart, status };
127
+ };
@@ -0,0 +1,70 @@
1
+ import { loadConfig, LOG_FILE } from '../config.js';
2
+ import { configureLogger, logger } from '../logger.js';
3
+ import { createTunnelClient } from '../tunnel/TunnelClient.js';
4
+ import { createTaskExecutor } from './TaskExecutor.js';
5
+ let tunnel = null;
6
+ const shutdown = async (signal) => {
7
+ logger.info(`Received ${signal}, shutting down...`);
8
+ if (tunnel) {
9
+ await tunnel.stop();
10
+ tunnel = null;
11
+ }
12
+ process.exit(0);
13
+ };
14
+ const handleUncaughtError = (err) => {
15
+ logger.error('Uncaught error:', err.message);
16
+ if (tunnel) {
17
+ tunnel.stop().finally(() => process.exit(1));
18
+ }
19
+ else {
20
+ process.exit(1);
21
+ }
22
+ };
23
+ const run = (config) => {
24
+ const safeSend = (message) => {
25
+ try {
26
+ tunnel?.send(message);
27
+ }
28
+ catch {
29
+ // Connection not ready, output is dropped
30
+ }
31
+ };
32
+ const taskExecutor = createTaskExecutor({
33
+ onOutput: (taskId, stream, data) => safeSend({ type: 'task-output', taskId, stream, data }),
34
+ onComplete: (taskId, exitCode) => safeSend({ type: 'task-complete', taskId, exitCode }),
35
+ onError: (taskId, error) => safeSend({ type: 'task-error', taskId, error }),
36
+ });
37
+ tunnel = createTunnelClient(config, taskExecutor);
38
+ tunnel.onStateChange((state) => {
39
+ logger.info('Tunnel state:', state);
40
+ });
41
+ tunnel.start();
42
+ logger.info('Daemon runner started');
43
+ logger.info('Server:', config.serverUrl);
44
+ logger.info('Hostname:', config.hostname);
45
+ logger.info('OS:', config.os);
46
+ logger.info('Runtimes:', config.runtimes.join(', ') || 'none detected');
47
+ };
48
+ const main = () => {
49
+ if (process.env.BRIER_DAEMON_MODE !== '1') {
50
+ console.error('This script is intended to be run as a daemon. Use "brier daemon start" instead.');
51
+ process.exit(1);
52
+ }
53
+ configureLogger(LOG_FILE);
54
+ let config;
55
+ try {
56
+ config = loadConfig({});
57
+ }
58
+ catch (err) {
59
+ logger.error('Config error:', err instanceof Error ? err.message : String(err));
60
+ process.exit(1);
61
+ }
62
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
63
+ process.on('SIGINT', () => void shutdown('SIGINT'));
64
+ process.on('uncaughtException', handleUncaughtError);
65
+ process.on('unhandledRejection', (reason) => {
66
+ logger.error('Unhandled rejection:', reason);
67
+ });
68
+ run(config);
69
+ };
70
+ main();
@@ -0,0 +1,76 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { logger } from '../logger.js';
3
+ import { RUNTIME_COMMANDS } from '../runtimes.js';
4
+ const MAX_CONCURRENT = 3;
5
+ export const createTaskExecutor = (callbacks) => {
6
+ const processes = new Map();
7
+ const resolveCommand = (task) => {
8
+ if (task.command)
9
+ return task.command;
10
+ const mapped = RUNTIME_COMMANDS[task.runtime];
11
+ if (mapped)
12
+ return mapped;
13
+ throw new Error(`Cannot resolve command for runtime: ${task.runtime}`);
14
+ };
15
+ const execute = (task) => {
16
+ if (processes.has(task.taskId)) {
17
+ logger.warn(`Task ${task.taskId} is already running, cancelling previous instance`);
18
+ cancel(task.taskId);
19
+ }
20
+ if (processes.size >= MAX_CONCURRENT) {
21
+ callbacks.onError(task.taskId, `Max concurrent tasks (${MAX_CONCURRENT}) reached`);
22
+ return;
23
+ }
24
+ const cmd = resolveCommand(task);
25
+ const childEnv = task.env ? { ...process.env, ...task.env } : process.env;
26
+ logger.info(`Task ${task.taskId} starting: ${cmd} ${task.args.join(' ')}`, task.runtime);
27
+ let child;
28
+ try {
29
+ child = spawn(cmd, task.args, {
30
+ cwd: task.cwd,
31
+ env: childEnv,
32
+ stdio: ['pipe', 'pipe', 'pipe'],
33
+ });
34
+ }
35
+ catch (err) {
36
+ callbacks.onError(task.taskId, err instanceof Error ? err.message : String(err));
37
+ return;
38
+ }
39
+ processes.set(task.taskId, child);
40
+ child.stdout?.on('data', (data) => {
41
+ callbacks.onOutput(task.taskId, 'stdout', data.toString());
42
+ });
43
+ child.stderr?.on('data', (data) => {
44
+ callbacks.onOutput(task.taskId, 'stderr', data.toString());
45
+ });
46
+ child.on('error', (err) => {
47
+ processes.delete(task.taskId);
48
+ logger.error(`Task ${task.taskId} process error:`, err.message);
49
+ callbacks.onError(task.taskId, err.message);
50
+ });
51
+ child.on('close', (code) => {
52
+ processes.delete(task.taskId);
53
+ const exitCode = code ?? 0;
54
+ logger.info(`Task ${task.taskId} completed with exit code ${exitCode}`);
55
+ callbacks.onComplete(task.taskId, exitCode);
56
+ });
57
+ };
58
+ const cancel = (taskId) => {
59
+ const child = processes.get(taskId);
60
+ if (!child) {
61
+ logger.warn(`Task ${taskId} not found, cannot cancel`);
62
+ return;
63
+ }
64
+ child.kill('SIGTERM');
65
+ processes.delete(taskId);
66
+ logger.info(`Task ${taskId} cancelled`);
67
+ };
68
+ const cancelAll = () => {
69
+ for (const [taskId, child] of processes) {
70
+ child.kill('SIGTERM');
71
+ logger.info(`Task ${taskId} cancelled (shutdown)`);
72
+ }
73
+ processes.clear();
74
+ };
75
+ return { execute, cancel, cancelAll };
76
+ };
package/dist/index.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { startCommand } from './commands/start.js';
4
+ import { stopCommand } from './commands/stop.js';
5
+ import { restartCommand } from './commands/restart.js';
6
+ import { statusCommand } from './commands/status.js';
7
+ const program = new Command();
8
+ program
9
+ .name('brier')
10
+ .description('Brier CLI - Connect to Brier platform via encrypted tunnel')
11
+ .version('0.0.1');
12
+ const daemon = program.command('daemon').description('Manage the brier background service');
13
+ daemon
14
+ .command('start')
15
+ .description('Start the background service')
16
+ .requiredOption('--server-url <url>', 'Brier server URL')
17
+ .option('--token <token>', 'BRIER_TOKEN (or set BRIER_TOKEN env var)')
18
+ .action(async (opts) => {
19
+ try {
20
+ await startCommand(opts);
21
+ }
22
+ catch (err) {
23
+ console.error('Error:', err instanceof Error ? err.message : String(err));
24
+ process.exit(1);
25
+ }
26
+ });
27
+ daemon
28
+ .command('stop')
29
+ .description('Stop the background service')
30
+ .action(async () => {
31
+ try {
32
+ await stopCommand();
33
+ }
34
+ catch (err) {
35
+ console.error('Error:', err instanceof Error ? err.message : String(err));
36
+ process.exit(1);
37
+ }
38
+ });
39
+ daemon
40
+ .command('restart')
41
+ .description('Restart the background service')
42
+ .option('--server-url <url>', 'Brier server URL (defaults to previous config)')
43
+ .option('--token <token>', 'BRIER_TOKEN (or set BRIER_TOKEN env var)')
44
+ .action(async (opts) => {
45
+ try {
46
+ await restartCommand(opts);
47
+ }
48
+ catch (err) {
49
+ console.error('Error:', err instanceof Error ? err.message : String(err));
50
+ process.exit(1);
51
+ }
52
+ });
53
+ daemon
54
+ .command('status')
55
+ .description('Check the background service status')
56
+ .action(() => {
57
+ statusCommand();
58
+ });
59
+ program.parseAsync(process.argv).catch((err) => {
60
+ console.error('Fatal:', err instanceof Error ? err.message : String(err));
61
+ process.exit(1);
62
+ });
package/dist/logger.js ADDED
@@ -0,0 +1,60 @@
1
+ import { mkdirSync, appendFileSync, statSync, renameSync, existsSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ const MAX_LOG_SIZE = 5 * 1024 * 1024;
4
+ let logFile;
5
+ export const configureLogger = (filePath) => {
6
+ logFile = filePath;
7
+ };
8
+ const formatMessage = (level, message, ...args) => {
9
+ const timestamp = new Date().toISOString();
10
+ const levelTag = level.toUpperCase().padEnd(5);
11
+ const rest = args.length > 0
12
+ ? ' ' + args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')
13
+ : '';
14
+ return `${timestamp} [${levelTag}] ${message}${rest}`;
15
+ };
16
+ const rotateIfNeeded = () => {
17
+ if (!logFile)
18
+ return;
19
+ try {
20
+ if (!existsSync(logFile))
21
+ return;
22
+ if (statSync(logFile).size < MAX_LOG_SIZE)
23
+ return;
24
+ const backup = logFile + '.1';
25
+ if (existsSync(backup)) {
26
+ try {
27
+ renameSync(backup, backup + '.old');
28
+ }
29
+ catch {
30
+ /* ignore */
31
+ }
32
+ }
33
+ renameSync(logFile, backup);
34
+ }
35
+ catch {
36
+ // rotation failed, continue writing to current file
37
+ }
38
+ };
39
+ const write = (level, message, ...args) => {
40
+ const formatted = formatMessage(level, message, ...args);
41
+ if (logFile) {
42
+ try {
43
+ mkdirSync(dirname(logFile), { recursive: true });
44
+ rotateIfNeeded();
45
+ appendFileSync(logFile, formatted + '\n');
46
+ }
47
+ catch {
48
+ console.error(formatted);
49
+ }
50
+ }
51
+ else {
52
+ const method = level === 'info' ? 'log' : level;
53
+ console[method](formatted);
54
+ }
55
+ };
56
+ export const logger = {
57
+ info: (message, ...args) => write('info', message, ...args),
58
+ warn: (message, ...args) => write('warn', message, ...args),
59
+ error: (message, ...args) => write('error', message, ...args),
60
+ };
@@ -0,0 +1,10 @@
1
+ export const RUNTIME_REGISTRY = [
2
+ { name: 'Claude Code', command: 'claude' },
3
+ { name: 'Codex CLI', command: 'codex' },
4
+ { name: 'GPT-4o CLI', command: 'gpt' },
5
+ { name: 'Gemini CLI', command: 'gemini' },
6
+ { name: 'Cursor CLI', command: 'cursor' },
7
+ { name: 'Node.js', command: 'node' },
8
+ { name: 'Python', command: 'python3' },
9
+ ];
10
+ export const RUNTIME_COMMANDS = Object.fromEntries(RUNTIME_REGISTRY.map((r) => [r.name, r.command]));
@@ -0,0 +1,216 @@
1
+ import { WebSocket } from 'ws';
2
+ import { toWsUrl } from '../config.js';
3
+ import { logger } from '../logger.js';
4
+ const HEARTBEAT_INTERVAL_MS = 30_000;
5
+ const HEARTBEAT_TIMEOUT_MS = 10_000;
6
+ const BASE_RECONNECT_DELAY_MS = 1_000;
7
+ const MAX_RECONNECT_DELAY_MS = 30_000;
8
+ const MAX_RECONNECT_ATTEMPTS = 50;
9
+ export const createTunnelClient = (config, taskExecutor) => {
10
+ let ws = null;
11
+ let state = 'disconnected';
12
+ let running = false;
13
+ let reconnectAttempts = 0;
14
+ let heartbeatTimer = null;
15
+ let heartbeatTimeoutTimer = null;
16
+ let reconnectTimer = null;
17
+ const listeners = new Set();
18
+ const notifyStateChange = (newState) => {
19
+ state = newState;
20
+ for (const callback of listeners) {
21
+ callback(newState);
22
+ }
23
+ };
24
+ const onStateChange = (callback) => {
25
+ listeners.add(callback);
26
+ return () => {
27
+ listeners.delete(callback);
28
+ };
29
+ };
30
+ const getState = () => state;
31
+ const send = (message) => {
32
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
33
+ throw new Error('Tunnel is not connected');
34
+ }
35
+ ws.send(JSON.stringify(message));
36
+ };
37
+ const startHeartbeat = () => {
38
+ stopHeartbeat();
39
+ heartbeatTimer = setInterval(() => {
40
+ if (ws && ws.readyState === WebSocket.OPEN) {
41
+ send({ type: 'heartbeat', timestamp: Date.now() });
42
+ heartbeatTimeoutTimer = setTimeout(() => {
43
+ logger.warn('Heartbeat timeout, forcing reconnect');
44
+ ws?.close(4000, 'heartbeat timeout');
45
+ }, HEARTBEAT_TIMEOUT_MS);
46
+ }
47
+ }, HEARTBEAT_INTERVAL_MS);
48
+ };
49
+ const stopHeartbeat = () => {
50
+ if (heartbeatTimer) {
51
+ clearInterval(heartbeatTimer);
52
+ heartbeatTimer = null;
53
+ }
54
+ if (heartbeatTimeoutTimer) {
55
+ clearTimeout(heartbeatTimeoutTimer);
56
+ heartbeatTimeoutTimer = null;
57
+ }
58
+ };
59
+ const handleServerMessage = (message) => {
60
+ switch (message.type) {
61
+ case 'auth-ok':
62
+ logger.info('Tunnel authenticated, computerId:', message.computerId);
63
+ reconnectAttempts = 0;
64
+ notifyStateChange('connected');
65
+ startHeartbeat();
66
+ break;
67
+ case 'auth-failed':
68
+ logger.error('Authentication failed:', message.reason);
69
+ notifyStateChange('error');
70
+ running = false;
71
+ break;
72
+ case 'heartbeat-ack':
73
+ if (heartbeatTimeoutTimer) {
74
+ clearTimeout(heartbeatTimeoutTimer);
75
+ heartbeatTimeoutTimer = null;
76
+ }
77
+ break;
78
+ case 'task-start':
79
+ logger.info('Task start:', message.taskId, message.command);
80
+ taskExecutor.execute({
81
+ taskId: message.taskId,
82
+ runtime: message.runtime,
83
+ command: message.command,
84
+ args: message.args,
85
+ cwd: message.cwd,
86
+ env: message.env,
87
+ });
88
+ break;
89
+ case 'task-cancel':
90
+ logger.info('Task cancel:', message.taskId);
91
+ taskExecutor.cancel(message.taskId);
92
+ break;
93
+ case 'query-runtimes':
94
+ send({ type: 'runtime-info', runtimes: config.runtimes });
95
+ break;
96
+ }
97
+ };
98
+ const scheduleReconnect = () => {
99
+ if (!running)
100
+ return;
101
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
102
+ logger.error('Max reconnect attempts reached, stopping');
103
+ notifyStateChange('error');
104
+ running = false;
105
+ return;
106
+ }
107
+ reconnectAttempts++;
108
+ const delay = Math.min(BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY_MS);
109
+ logger.info(`Reconnecting in ${(delay / 1000).toFixed(0)}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
110
+ notifyStateChange('reconnecting');
111
+ reconnectTimer = setTimeout(() => {
112
+ if (running) {
113
+ connect();
114
+ }
115
+ }, delay);
116
+ };
117
+ const connect = () => {
118
+ if (ws) {
119
+ ws.removeAllListeners();
120
+ ws.terminate();
121
+ ws = null;
122
+ }
123
+ const wsUrl = toWsUrl(config.serverUrl);
124
+ logger.info('Connecting to', wsUrl);
125
+ notifyStateChange('connecting');
126
+ ws = new WebSocket(wsUrl, {
127
+ headers: {
128
+ Authorization: `Bearer ${config.token}`,
129
+ 'X-Brier-Hostname': config.hostname,
130
+ 'X-Brier-OS': config.os,
131
+ },
132
+ });
133
+ ws.on('open', () => {
134
+ logger.info('WebSocket connected, authenticating...');
135
+ send({
136
+ type: 'auth',
137
+ token: config.token,
138
+ hostname: config.hostname,
139
+ os: config.os,
140
+ runtimes: config.runtimes,
141
+ });
142
+ });
143
+ ws.on('message', (data) => {
144
+ try {
145
+ const message = JSON.parse(data.toString());
146
+ handleServerMessage(message);
147
+ }
148
+ catch (err) {
149
+ logger.error('Failed to parse server message:', err);
150
+ }
151
+ });
152
+ ws.on('close', (code, reason) => {
153
+ const reasonStr = reason.toString() || `code ${code}`;
154
+ logger.warn(`WebSocket closed: ${reasonStr}`);
155
+ stopHeartbeat();
156
+ if (running) {
157
+ scheduleReconnect();
158
+ }
159
+ else {
160
+ notifyStateChange('disconnected');
161
+ }
162
+ });
163
+ ws.on('error', (err) => {
164
+ logger.error('WebSocket error:', err.message);
165
+ });
166
+ ws.on('ping', () => {
167
+ ws?.pong();
168
+ });
169
+ };
170
+ const start = () => {
171
+ if (running) {
172
+ logger.warn('Tunnel is already running');
173
+ return;
174
+ }
175
+ running = true;
176
+ reconnectAttempts = 0;
177
+ connect();
178
+ };
179
+ const stop = async () => {
180
+ running = false;
181
+ stopHeartbeat();
182
+ taskExecutor.cancelAll();
183
+ if (reconnectTimer) {
184
+ clearTimeout(reconnectTimer);
185
+ reconnectTimer = null;
186
+ }
187
+ if (ws) {
188
+ const closePromise = new Promise((resolve) => {
189
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
190
+ ws.once('close', () => resolve());
191
+ ws.close(1000, 'client shutdown');
192
+ setTimeout(() => {
193
+ if (ws && ws.readyState !== WebSocket.CLOSED) {
194
+ ws.terminate();
195
+ }
196
+ resolve();
197
+ }, 3000);
198
+ }
199
+ else {
200
+ resolve();
201
+ }
202
+ });
203
+ await closePromise;
204
+ ws = null;
205
+ }
206
+ notifyStateChange('disconnected');
207
+ logger.info('Tunnel stopped');
208
+ };
209
+ return {
210
+ start,
211
+ stop,
212
+ send,
213
+ getState,
214
+ onStateChange,
215
+ };
216
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@brierb/brier-cli",
3
+ "version": "0.0.4",
4
+ "description": "Brier 平台接入命令行工具:通过加密隧道将本机接入 Brier 远程编码池,接收并执行远程任务",
5
+ "keywords": [
6
+ "brier",
7
+ "cli",
8
+ "tunnel",
9
+ "agent"
10
+ ],
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "bin": {
14
+ "brier": "./dist/index.js"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "import": "./dist/index.js",
19
+ "types": "./types/index.d.ts"
20
+ },
21
+ "./tunnel": {
22
+ "import": "./dist/tunnel/TunnelClient.js",
23
+ "types": "./types/tunnel/TunnelClient.d.ts"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "types"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "dev": "tsc --watch",
36
+ "prepublishOnly": "pnpm run build",
37
+ "release:patch": "npm version patch --no-git-tag-version && npm publish --access public",
38
+ "release:minor": "npm version minor --no-git-tag-version && npm publish --access public",
39
+ "release:major": "npm version major --no-git-tag-version && npm publish --access public"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "commander": "^12.1.0",
46
+ "ws": "^8.18.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^24.13.3",
50
+ "@types/ws": "^8.5.13",
51
+ "typescript": "^6.0.3"
52
+ }
53
+ }
@@ -0,0 +1,5 @@
1
+ export interface RestartOptions {
2
+ serverUrl?: string;
3
+ token?: string;
4
+ }
5
+ export declare const restartCommand: (options: RestartOptions) => Promise<void>;
@@ -0,0 +1,5 @@
1
+ export interface StartOptions {
2
+ serverUrl: string;
3
+ token?: string;
4
+ }
5
+ export declare const startCommand: (options: StartOptions) => Promise<void>;
@@ -0,0 +1 @@
1
+ export declare const statusCommand: () => void;
@@ -0,0 +1 @@
1
+ export declare const stopCommand: () => Promise<void>;
@@ -0,0 +1,9 @@
1
+ import type { DaemonConfig } from './types.js';
2
+ export declare const BRIER_DIR: string;
3
+ export declare const PID_FILE: string;
4
+ export declare const LOG_FILE: string;
5
+ export declare const loadConfig: (options: {
6
+ serverUrl?: string;
7
+ token?: string;
8
+ }) => DaemonConfig;
9
+ export declare const toWsUrl: (serverUrl: string) => string;
@@ -0,0 +1,14 @@
1
+ import type { DaemonStatus } from '../types.js';
2
+ export interface DaemonManager {
3
+ start: (options: {
4
+ serverUrl: string;
5
+ token: string;
6
+ }) => Promise<void>;
7
+ stop: () => Promise<void>;
8
+ restart: (options: {
9
+ serverUrl: string;
10
+ token: string;
11
+ }) => Promise<void>;
12
+ status: () => DaemonStatus;
13
+ }
14
+ export declare const createDaemonManager: () => DaemonManager;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import type { TaskInfo } from '../types.js';
2
+ export interface TaskExecutorCallbacks {
3
+ onOutput: (taskId: string, stream: 'stdout' | 'stderr', data: string) => void;
4
+ onComplete: (taskId: string, exitCode: number) => void;
5
+ onError: (taskId: string, error: string) => void;
6
+ }
7
+ export interface TaskExecutor {
8
+ execute: (task: TaskInfo) => void;
9
+ cancel: (taskId: string) => void;
10
+ cancelAll: () => void;
11
+ }
12
+ export declare const createTaskExecutor: (callbacks: TaskExecutorCallbacks) => TaskExecutor;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,6 @@
1
+ export declare const configureLogger: (filePath: string | undefined) => void;
2
+ export declare const logger: {
3
+ info: (message: string, ...args: unknown[]) => void;
4
+ warn: (message: string, ...args: unknown[]) => void;
5
+ error: (message: string, ...args: unknown[]) => void;
6
+ };
@@ -0,0 +1,6 @@
1
+ export interface RuntimeEntry {
2
+ name: string;
3
+ command: string;
4
+ }
5
+ export declare const RUNTIME_REGISTRY: readonly RuntimeEntry[];
6
+ export declare const RUNTIME_COMMANDS: Record<string, string>;
@@ -0,0 +1,10 @@
1
+ import type { ClientMessage, DaemonConfig, TunnelState } from '../types.js';
2
+ import type { TaskExecutor } from '../daemon/TaskExecutor.js';
3
+ export interface TunnelClient {
4
+ start: () => void;
5
+ stop: () => Promise<void>;
6
+ send: (message: ClientMessage) => void;
7
+ getState: () => TunnelState;
8
+ onStateChange: (callback: (state: TunnelState) => void) => () => void;
9
+ }
10
+ export declare const createTunnelClient: (config: DaemonConfig, taskExecutor: TaskExecutor) => TunnelClient;
@@ -0,0 +1,72 @@
1
+ export type DaemonStatus = 'running' | 'stopped' | 'error';
2
+ export type TunnelState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'error';
3
+ export type StreamType = 'stdout' | 'stderr';
4
+ export type ClientMessage = {
5
+ type: 'auth';
6
+ token: string;
7
+ hostname: string;
8
+ os: string;
9
+ runtimes: string[];
10
+ } | {
11
+ type: 'heartbeat';
12
+ timestamp: number;
13
+ } | {
14
+ type: 'task-output';
15
+ taskId: string;
16
+ stream: StreamType;
17
+ data: string;
18
+ } | {
19
+ type: 'task-complete';
20
+ taskId: string;
21
+ exitCode: number;
22
+ } | {
23
+ type: 'task-error';
24
+ taskId: string;
25
+ error: string;
26
+ } | {
27
+ type: 'runtime-info';
28
+ runtimes: string[];
29
+ };
30
+ export type ServerMessage = {
31
+ type: 'auth-ok';
32
+ computerId: string;
33
+ } | {
34
+ type: 'auth-failed';
35
+ reason: string;
36
+ } | {
37
+ type: 'heartbeat-ack';
38
+ timestamp: number;
39
+ } | {
40
+ type: 'task-start';
41
+ taskId: string;
42
+ runtime: string;
43
+ command: string;
44
+ args: string[];
45
+ cwd?: string;
46
+ env?: Record<string, string>;
47
+ } | {
48
+ type: 'task-cancel';
49
+ taskId: string;
50
+ } | {
51
+ type: 'query-runtimes';
52
+ };
53
+ export interface TaskInfo {
54
+ taskId: string;
55
+ runtime: string;
56
+ command: string;
57
+ args: string[];
58
+ cwd?: string;
59
+ env?: Record<string, string>;
60
+ }
61
+ export interface DaemonConfig {
62
+ serverUrl: string;
63
+ token: string;
64
+ hostname: string;
65
+ os: string;
66
+ runtimes: string[];
67
+ }
68
+ export interface PidFileData {
69
+ pid: number;
70
+ startTime: number;
71
+ serverUrl: string;
72
+ }