cli4ai 1.0.4 → 1.1.0
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.
- package/package.json +3 -2
- package/src/cli.ts +94 -1
- package/src/commands/remotes.ts +253 -0
- package/src/commands/routines.ts +27 -8
- package/src/commands/run.ts +64 -1
- package/src/commands/serve.ts +66 -0
- package/src/core/config.ts +9 -2
- package/src/core/link.ts +17 -1
- package/src/core/remote-client.ts +419 -0
- package/src/core/remotes.ts +268 -0
- package/src/core/routine-engine.ts +91 -23
- package/src/core/routines.ts +9 -6
- package/src/core/scheduler.ts +2 -2
- package/src/server/service.ts +434 -0
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from 'fs';
|
|
6
6
|
import { spawn } from 'child_process';
|
|
7
|
+
import { parse as parseYaml } from 'yaml';
|
|
7
8
|
import { executeTool, ExecuteToolError } from './execute.js';
|
|
9
|
+
import { remoteRunTool, RemoteConnectionError, RemoteApiError } from './remote-client.js';
|
|
10
|
+
import { getRemote } from './remotes.js';
|
|
8
11
|
|
|
9
12
|
export class RoutineParseError extends Error {
|
|
10
13
|
constructor(
|
|
@@ -78,6 +81,8 @@ export interface RoutineC4aiStep extends RoutineBaseStep {
|
|
|
78
81
|
env?: Record<string, string>;
|
|
79
82
|
stdin?: string;
|
|
80
83
|
capture?: StepCapture;
|
|
84
|
+
/** Name of a configured remote to execute on (optional) */
|
|
85
|
+
remote?: string;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
export interface RoutineSetStep extends RoutineBaseStep {
|
|
@@ -153,11 +158,13 @@ export function loadRoutineDefinition(path: string): RoutineDefinition {
|
|
|
153
158
|
throw new RoutineParseError(path, `Failed to read: ${err instanceof Error ? err.message : String(err)}`);
|
|
154
159
|
}
|
|
155
160
|
|
|
161
|
+
const isYaml = path.endsWith('.yaml') || path.endsWith('.yml');
|
|
156
162
|
let data: unknown;
|
|
157
163
|
try {
|
|
158
|
-
data = JSON.parse(content);
|
|
159
|
-
} catch {
|
|
160
|
-
|
|
164
|
+
data = isYaml ? parseYaml(content) : JSON.parse(content);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const format = isYaml ? 'YAML' : 'JSON';
|
|
167
|
+
throw new RoutineParseError(path, `Invalid ${format}: ${err instanceof Error ? err.message : String(err)}`);
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
return validateRoutineDefinition(data, path);
|
|
@@ -318,6 +325,9 @@ function validateRoutineDefinition(value: unknown, source?: string): RoutineDefi
|
|
|
318
325
|
if (s.capture !== undefined && !['inherit', 'text', 'json'].includes(String(s.capture))) {
|
|
319
326
|
throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
|
|
320
327
|
}
|
|
328
|
+
if (s.remote !== undefined && typeof s.remote !== 'string') {
|
|
329
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "remote" (must be string)`, { source, id: s.id, got: s.remote });
|
|
330
|
+
}
|
|
321
331
|
}
|
|
322
332
|
|
|
323
333
|
if (s.type === 'set') {
|
|
@@ -718,39 +728,91 @@ export async function runRoutine(def: RoutineDefinition, vars: Record<string, st
|
|
|
718
728
|
const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
|
|
719
729
|
const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
|
|
720
730
|
const stdin = step.stdin !== undefined ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin' }) : undefined;
|
|
731
|
+
const remoteName = step.remote !== undefined ? renderScalarString(step.remote, ctx, { stepId: step.id, field: 'remote' }) : undefined;
|
|
721
732
|
|
|
722
733
|
const capture = step.capture ?? 'text';
|
|
723
734
|
|
|
724
735
|
try {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
+
let execResult: { exitCode: number; durationMs: number; stdout?: string; stderr?: string };
|
|
737
|
+
|
|
738
|
+
if (remoteName) {
|
|
739
|
+
// Remote execution
|
|
740
|
+
const remote = getRemote(remoteName);
|
|
741
|
+
if (!remote) {
|
|
742
|
+
throw new ExecuteToolError('NOT_FOUND', `Remote "${remoteName}" not found`, {
|
|
743
|
+
step: step.id,
|
|
744
|
+
hint: 'Use "cli4ai remotes add <name> <url>" to configure a remote'
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const remoteRes = await remoteRunTool(remoteName, {
|
|
749
|
+
package: pkg,
|
|
750
|
+
command: cmd,
|
|
751
|
+
args,
|
|
752
|
+
env: Object.keys(env).length > 0 ? env : undefined,
|
|
753
|
+
stdin,
|
|
754
|
+
timeout: step.timeout
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
execResult = {
|
|
758
|
+
exitCode: remoteRes.exitCode,
|
|
759
|
+
durationMs: remoteRes.durationMs,
|
|
760
|
+
stdout: remoteRes.stdout,
|
|
761
|
+
stderr: remoteRes.stderr
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
// Log stderr from remote
|
|
765
|
+
if (remoteRes.stderr) {
|
|
766
|
+
process.stderr.write(remoteRes.stderr);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Handle remote-level errors
|
|
770
|
+
if (remoteRes.error && !remoteRes.success) {
|
|
771
|
+
throw new ExecuteToolError(
|
|
772
|
+
remoteRes.error.code,
|
|
773
|
+
remoteRes.error.message,
|
|
774
|
+
remoteRes.error.details
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
// Local execution
|
|
779
|
+
const localRes = await executeTool({
|
|
780
|
+
packageName: pkg,
|
|
781
|
+
command: cmd,
|
|
782
|
+
args,
|
|
783
|
+
cwd: invocationDir,
|
|
784
|
+
env,
|
|
785
|
+
stdin,
|
|
786
|
+
capture: 'pipe',
|
|
787
|
+
timeoutMs: step.timeout,
|
|
788
|
+
teeStderr: true
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
execResult = {
|
|
792
|
+
exitCode: localRes.exitCode,
|
|
793
|
+
durationMs: localRes.durationMs,
|
|
794
|
+
stdout: localRes.stdout,
|
|
795
|
+
stderr: localRes.stderr
|
|
796
|
+
};
|
|
797
|
+
}
|
|
736
798
|
|
|
737
799
|
const res: StepRunResult = {
|
|
738
800
|
id: step.id,
|
|
739
801
|
type: step.type,
|
|
740
|
-
status:
|
|
741
|
-
exitCode:
|
|
742
|
-
durationMs:
|
|
743
|
-
stdout:
|
|
744
|
-
stderr:
|
|
802
|
+
status: execResult.exitCode === 0 ? 'success' : 'failed',
|
|
803
|
+
exitCode: execResult.exitCode,
|
|
804
|
+
durationMs: execResult.durationMs,
|
|
805
|
+
stdout: execResult.stdout,
|
|
806
|
+
stderr: execResult.stderr
|
|
745
807
|
};
|
|
746
808
|
|
|
747
809
|
if (capture === 'json') {
|
|
748
810
|
try {
|
|
749
|
-
res.json =
|
|
811
|
+
res.json = execResult.stdout ? JSON.parse(execResult.stdout) : null;
|
|
750
812
|
} catch {
|
|
751
813
|
throw new RoutineTemplateError(`JSON parse error in step "${step.id}": stdout is not valid JSON`, {
|
|
752
814
|
step: step.id,
|
|
753
|
-
stdout: (
|
|
815
|
+
stdout: (execResult.stdout ?? '').slice(0, 200)
|
|
754
816
|
});
|
|
755
817
|
}
|
|
756
818
|
}
|
|
@@ -758,8 +820,8 @@ export async function runRoutine(def: RoutineDefinition, vars: Record<string, st
|
|
|
758
820
|
stepsById[step.id] = res;
|
|
759
821
|
steps.push(res);
|
|
760
822
|
|
|
761
|
-
if (
|
|
762
|
-
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime,
|
|
823
|
+
if (execResult.exitCode !== 0 && !step.continueOnError) {
|
|
824
|
+
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, execResult.exitCode);
|
|
763
825
|
}
|
|
764
826
|
continue;
|
|
765
827
|
} catch (err) {
|
|
@@ -799,6 +861,12 @@ function normalizeError(err: unknown): { code: string; message: string; details?
|
|
|
799
861
|
if (err instanceof ExecuteToolError) {
|
|
800
862
|
return { code: err.code, message: err.message, details: err.details };
|
|
801
863
|
}
|
|
864
|
+
if (err instanceof RemoteConnectionError) {
|
|
865
|
+
return { code: 'NETWORK_ERROR', message: err.message, details: { remote: err.remoteName, url: err.url } };
|
|
866
|
+
}
|
|
867
|
+
if (err instanceof RemoteApiError) {
|
|
868
|
+
return { code: err.code, message: err.message, details: err.details };
|
|
869
|
+
}
|
|
802
870
|
if (err instanceof RoutineTemplateError) {
|
|
803
871
|
return { code: 'INVALID_INPUT', message: err.message, details: err.details };
|
|
804
872
|
}
|
package/src/core/routines.ts
CHANGED
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Resolution order:
|
|
9
9
|
* - local before global (unless globalOnly)
|
|
10
|
-
* - within a scope: .routine.json
|
|
10
|
+
* - within a scope: .routine.yaml > .routine.yml > .routine.json > .routine.sh
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
|
14
14
|
import { resolve } from 'path';
|
|
15
|
+
import { parse as parseYaml } from 'yaml';
|
|
15
16
|
import { ensureCli4aiHome, ensureLocalDir, ROUTINES_DIR, LOCAL_ROUTINES_DIR } from './config.js';
|
|
16
17
|
import { validateScheduleConfig, type RoutineSchedule } from './routine-engine.js';
|
|
17
18
|
|
|
18
|
-
export type RoutineKind = 'json' | 'bash';
|
|
19
|
+
export type RoutineKind = 'yaml' | 'json' | 'bash';
|
|
19
20
|
export type RoutineScope = 'local' | 'global';
|
|
20
21
|
|
|
21
22
|
export interface RoutineInfo {
|
|
@@ -30,6 +31,8 @@ export interface ResolveRoutineOptions {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
const ROUTINE_FILES = [
|
|
34
|
+
{ kind: 'yaml' as const, suffix: '.routine.yaml' },
|
|
35
|
+
{ kind: 'yaml' as const, suffix: '.routine.yml' },
|
|
33
36
|
{ kind: 'json' as const, suffix: '.routine.json' },
|
|
34
37
|
{ kind: 'bash' as const, suffix: '.routine.sh' }
|
|
35
38
|
] as const;
|
|
@@ -127,13 +130,13 @@ export function getScheduledRoutines(projectDir?: string): ScheduledRoutineInfo[
|
|
|
127
130
|
const results: ScheduledRoutineInfo[] = [];
|
|
128
131
|
const seen = new Set<string>();
|
|
129
132
|
|
|
130
|
-
// Collect all JSON
|
|
133
|
+
// Collect all structured routines (YAML and JSON - bash scripts cannot have schedules)
|
|
131
134
|
const allRoutines: RoutineInfo[] = [];
|
|
132
135
|
|
|
133
136
|
if (projectDir) {
|
|
134
|
-
allRoutines.push(...getLocalRoutines(projectDir).filter(r => r.kind === 'json'));
|
|
137
|
+
allRoutines.push(...getLocalRoutines(projectDir).filter(r => r.kind === 'yaml' || r.kind === 'json'));
|
|
135
138
|
}
|
|
136
|
-
allRoutines.push(...getGlobalRoutines().filter(r => r.kind === 'json'));
|
|
139
|
+
allRoutines.push(...getGlobalRoutines().filter(r => r.kind === 'yaml' || r.kind === 'json'));
|
|
137
140
|
|
|
138
141
|
for (const routine of allRoutines) {
|
|
139
142
|
// Skip if we've already processed a routine with this name (local takes precedence)
|
|
@@ -142,7 +145,7 @@ export function getScheduledRoutines(projectDir?: string): ScheduledRoutineInfo[
|
|
|
142
145
|
|
|
143
146
|
try {
|
|
144
147
|
const content = readFileSync(routine.path, 'utf-8');
|
|
145
|
-
const data = JSON.parse(content);
|
|
148
|
+
const data = routine.kind === 'yaml' ? parseYaml(content) : JSON.parse(content);
|
|
146
149
|
|
|
147
150
|
if (data.schedule && typeof data.schedule === 'object') {
|
|
148
151
|
// Check if schedule is enabled (defaults to true)
|
package/src/core/scheduler.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* └── scheduler.log # Daemon logs
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'fs';
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, appendFileSync } from 'fs';
|
|
15
15
|
import { resolve, join } from 'path';
|
|
16
16
|
import parser from 'cron-parser';
|
|
17
17
|
import { SCHEDULER_DIR, ensureCli4aiHome } from './config.js';
|
|
@@ -282,7 +282,7 @@ export function appendSchedulerLog(level: LogLevel, message: string): void {
|
|
|
282
282
|
ensureSchedulerDirs();
|
|
283
283
|
const timestamp = new Date().toISOString();
|
|
284
284
|
const line = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
|
|
285
|
-
|
|
285
|
+
appendFileSync(SCHEDULER_LOG_FILE, line);
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli4ai Remote Service
|
|
3
|
+
*
|
|
4
|
+
* HTTP server that exposes cli4ai functionality for remote execution.
|
|
5
|
+
* Run with `cli4ai serve` to start the service.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer, IncomingMessage, ServerResponse } from 'http';
|
|
9
|
+
import { hostname } from 'os';
|
|
10
|
+
import { log, output } from '../lib/cli.js';
|
|
11
|
+
import { executeTool, ExecuteToolError, type ScopeLevel } from '../core/execute.js';
|
|
12
|
+
import { findPackage, getGlobalPackages, getLocalPackages } from '../core/config.js';
|
|
13
|
+
import { loadManifest } from '../core/manifest.js';
|
|
14
|
+
import {
|
|
15
|
+
loadRoutineDefinition,
|
|
16
|
+
runRoutine,
|
|
17
|
+
type RoutineRunSummary
|
|
18
|
+
} from '../core/routine-engine.js';
|
|
19
|
+
import { resolveRoutine } from '../core/routines.js';
|
|
20
|
+
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
22
|
+
// TYPES
|
|
23
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
export interface ServiceConfig {
|
|
26
|
+
/** Port to listen on (default: 4100) */
|
|
27
|
+
port: number;
|
|
28
|
+
/** Host to bind to (default: 0.0.0.0) */
|
|
29
|
+
host: string;
|
|
30
|
+
/** API key for authentication (optional but recommended) */
|
|
31
|
+
apiKey?: string;
|
|
32
|
+
/** Working directory for command execution */
|
|
33
|
+
cwd: string;
|
|
34
|
+
/** Allowed scope levels (defaults to ['read', 'write', 'full']) */
|
|
35
|
+
allowedScopes?: ScopeLevel[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RunToolRequest {
|
|
39
|
+
/** Package name */
|
|
40
|
+
package: string;
|
|
41
|
+
/** Command within the package */
|
|
42
|
+
command?: string;
|
|
43
|
+
/** Arguments to pass */
|
|
44
|
+
args?: string[];
|
|
45
|
+
/** Environment variables */
|
|
46
|
+
env?: Record<string, string>;
|
|
47
|
+
/** Standard input to pass to the tool */
|
|
48
|
+
stdin?: string;
|
|
49
|
+
/** Timeout in milliseconds */
|
|
50
|
+
timeout?: number;
|
|
51
|
+
/** Scope level for execution */
|
|
52
|
+
scope?: ScopeLevel;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RunToolResponse {
|
|
56
|
+
success: boolean;
|
|
57
|
+
exitCode: number;
|
|
58
|
+
stdout?: string;
|
|
59
|
+
stderr?: string;
|
|
60
|
+
durationMs: number;
|
|
61
|
+
error?: {
|
|
62
|
+
code: string;
|
|
63
|
+
message: string;
|
|
64
|
+
details?: Record<string, unknown>;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RunRoutineRequest {
|
|
69
|
+
/** Routine name */
|
|
70
|
+
routine: string;
|
|
71
|
+
/** Variables to pass to the routine */
|
|
72
|
+
vars?: Record<string, string>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ListPackagesResponse {
|
|
76
|
+
packages: Array<{
|
|
77
|
+
name: string;
|
|
78
|
+
version: string;
|
|
79
|
+
path: string;
|
|
80
|
+
source: 'local' | 'registry';
|
|
81
|
+
}>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface PackageInfoResponse {
|
|
85
|
+
name: string;
|
|
86
|
+
version: string;
|
|
87
|
+
description?: string;
|
|
88
|
+
commands?: Record<string, { description: string }>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface HealthResponse {
|
|
92
|
+
status: 'ok';
|
|
93
|
+
hostname: string;
|
|
94
|
+
version: string;
|
|
95
|
+
uptime: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
99
|
+
// HELPERS
|
|
100
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
101
|
+
|
|
102
|
+
function parseBody(req: IncomingMessage): Promise<string> {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const chunks: Buffer[] = [];
|
|
105
|
+
req.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
106
|
+
req.on('error', reject);
|
|
107
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sendJson(res: ServerResponse, status: number, data: unknown): void {
|
|
112
|
+
const body = JSON.stringify(data);
|
|
113
|
+
res.writeHead(status, {
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
'Content-Length': Buffer.byteLength(body)
|
|
116
|
+
});
|
|
117
|
+
res.end(body);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sendError(res: ServerResponse, status: number, code: string, message: string, details?: Record<string, unknown>): void {
|
|
121
|
+
sendJson(res, status, { error: { code, message, details } });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
125
|
+
// REQUEST HANDLERS
|
|
126
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
127
|
+
|
|
128
|
+
async function handleHealth(config: ServiceConfig, startTime: number): Promise<HealthResponse> {
|
|
129
|
+
return {
|
|
130
|
+
status: 'ok',
|
|
131
|
+
hostname: hostname(),
|
|
132
|
+
version: '1.0.0',
|
|
133
|
+
uptime: Math.floor((Date.now() - startTime) / 1000)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function handleListPackages(config: ServiceConfig): Promise<ListPackagesResponse> {
|
|
138
|
+
const localPkgs = getLocalPackages(config.cwd);
|
|
139
|
+
const globalPkgs = getGlobalPackages();
|
|
140
|
+
|
|
141
|
+
const packages = [
|
|
142
|
+
...localPkgs.map(p => ({ name: p.name, version: p.version, path: p.path, source: p.source })),
|
|
143
|
+
...globalPkgs.map(p => ({ name: p.name, version: p.version, path: p.path, source: p.source }))
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// Deduplicate by name (local takes precedence)
|
|
147
|
+
const seen = new Set<string>();
|
|
148
|
+
const unique = packages.filter(p => {
|
|
149
|
+
if (seen.has(p.name)) return false;
|
|
150
|
+
seen.add(p.name);
|
|
151
|
+
return true;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return { packages: unique };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function handlePackageInfo(config: ServiceConfig, packageName: string): Promise<PackageInfoResponse | null> {
|
|
158
|
+
const pkg = findPackage(packageName, config.cwd);
|
|
159
|
+
if (!pkg) return null;
|
|
160
|
+
|
|
161
|
+
const manifest = loadManifest(pkg.path);
|
|
162
|
+
|
|
163
|
+
const commands: Record<string, { description: string }> = {};
|
|
164
|
+
if (manifest.commands) {
|
|
165
|
+
for (const [name, cmd] of Object.entries(manifest.commands)) {
|
|
166
|
+
commands[name] = { description: cmd.description };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
name: manifest.name,
|
|
172
|
+
version: manifest.version,
|
|
173
|
+
description: manifest.description,
|
|
174
|
+
commands
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function handleRunTool(config: ServiceConfig, request: RunToolRequest): Promise<RunToolResponse> {
|
|
179
|
+
const startTime = Date.now();
|
|
180
|
+
|
|
181
|
+
// Validate scope
|
|
182
|
+
const scope = request.scope ?? 'full';
|
|
183
|
+
const allowedScopes = config.allowedScopes ?? ['read', 'write', 'full'];
|
|
184
|
+
if (!allowedScopes.includes(scope)) {
|
|
185
|
+
return {
|
|
186
|
+
success: false,
|
|
187
|
+
exitCode: 1,
|
|
188
|
+
durationMs: Date.now() - startTime,
|
|
189
|
+
error: {
|
|
190
|
+
code: 'FORBIDDEN',
|
|
191
|
+
message: `Scope "${scope}" is not allowed on this remote`,
|
|
192
|
+
details: { allowedScopes }
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const result = await executeTool({
|
|
199
|
+
packageName: request.package,
|
|
200
|
+
command: request.command,
|
|
201
|
+
args: request.args ?? [],
|
|
202
|
+
cwd: config.cwd,
|
|
203
|
+
env: request.env,
|
|
204
|
+
stdin: request.stdin,
|
|
205
|
+
capture: 'pipe',
|
|
206
|
+
timeoutMs: request.timeout,
|
|
207
|
+
scope,
|
|
208
|
+
teeStderr: false
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
success: result.exitCode === 0,
|
|
213
|
+
exitCode: result.exitCode,
|
|
214
|
+
stdout: result.stdout,
|
|
215
|
+
stderr: result.stderr,
|
|
216
|
+
durationMs: result.durationMs
|
|
217
|
+
};
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const durationMs = Date.now() - startTime;
|
|
220
|
+
|
|
221
|
+
if (err instanceof ExecuteToolError) {
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
exitCode: 1,
|
|
225
|
+
durationMs,
|
|
226
|
+
error: {
|
|
227
|
+
code: err.code,
|
|
228
|
+
message: err.message,
|
|
229
|
+
details: err.details
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
success: false,
|
|
236
|
+
exitCode: 1,
|
|
237
|
+
durationMs,
|
|
238
|
+
error: {
|
|
239
|
+
code: 'API_ERROR',
|
|
240
|
+
message: err instanceof Error ? err.message : String(err)
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function handleRunRoutine(config: ServiceConfig, request: RunRoutineRequest): Promise<RoutineRunSummary | null> {
|
|
247
|
+
const resolved = resolveRoutine(request.routine, config.cwd);
|
|
248
|
+
if (!resolved) return null;
|
|
249
|
+
|
|
250
|
+
const def = loadRoutineDefinition(resolved.path);
|
|
251
|
+
const result = await runRoutine(def, request.vars ?? {}, config.cwd);
|
|
252
|
+
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
257
|
+
// MAIN SERVER
|
|
258
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
259
|
+
|
|
260
|
+
export function createService(config: ServiceConfig): ReturnType<typeof createServer> {
|
|
261
|
+
const startTime = Date.now();
|
|
262
|
+
|
|
263
|
+
const server = createServer(async (req, res) => {
|
|
264
|
+
const method = req.method ?? 'GET';
|
|
265
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
266
|
+
const path = url.pathname;
|
|
267
|
+
|
|
268
|
+
// CORS headers for cross-origin requests
|
|
269
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
270
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
271
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
|
272
|
+
|
|
273
|
+
// Handle preflight
|
|
274
|
+
if (method === 'OPTIONS') {
|
|
275
|
+
res.writeHead(204);
|
|
276
|
+
res.end();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Authentication check
|
|
281
|
+
if (config.apiKey) {
|
|
282
|
+
const providedKey = req.headers['x-api-key'] ||
|
|
283
|
+
req.headers['authorization']?.replace(/^Bearer\s+/i, '');
|
|
284
|
+
|
|
285
|
+
if (providedKey !== config.apiKey) {
|
|
286
|
+
sendError(res, 401, 'UNAUTHORIZED', 'Invalid or missing API key');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
// Route: GET /health
|
|
293
|
+
if (method === 'GET' && path === '/health') {
|
|
294
|
+
const data = await handleHealth(config, startTime);
|
|
295
|
+
sendJson(res, 200, data);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Route: GET /packages
|
|
300
|
+
if (method === 'GET' && path === '/packages') {
|
|
301
|
+
const data = await handleListPackages(config);
|
|
302
|
+
sendJson(res, 200, data);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Route: GET /packages/:name
|
|
307
|
+
if (method === 'GET' && path.startsWith('/packages/')) {
|
|
308
|
+
const packageName = path.slice('/packages/'.length);
|
|
309
|
+
const data = await handlePackageInfo(config, packageName);
|
|
310
|
+
if (!data) {
|
|
311
|
+
sendError(res, 404, 'NOT_FOUND', `Package not found: ${packageName}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
sendJson(res, 200, data);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Route: POST /run
|
|
319
|
+
if (method === 'POST' && path === '/run') {
|
|
320
|
+
const body = await parseBody(req);
|
|
321
|
+
let request: RunToolRequest;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
request = JSON.parse(body);
|
|
325
|
+
} catch {
|
|
326
|
+
sendError(res, 400, 'PARSE_ERROR', 'Invalid JSON body');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!request.package || typeof request.package !== 'string') {
|
|
331
|
+
sendError(res, 400, 'INVALID_INPUT', 'Missing required field: package');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const data = await handleRunTool(config, request);
|
|
336
|
+
sendJson(res, data.success ? 200 : 500, data);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Route: POST /routines/:name/run
|
|
341
|
+
if (method === 'POST' && path.match(/^\/routines\/[^/]+\/run$/)) {
|
|
342
|
+
const routineName = path.split('/')[2];
|
|
343
|
+
const body = await parseBody(req);
|
|
344
|
+
let request: RunRoutineRequest;
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
request = body ? JSON.parse(body) : {};
|
|
348
|
+
request.routine = routineName;
|
|
349
|
+
} catch {
|
|
350
|
+
sendError(res, 400, 'PARSE_ERROR', 'Invalid JSON body');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const data = await handleRunRoutine(config, request);
|
|
355
|
+
if (!data) {
|
|
356
|
+
sendError(res, 404, 'NOT_FOUND', `Routine not found: ${routineName}`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
sendJson(res, data.status === 'success' ? 200 : 500, data);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 404 for unknown routes
|
|
365
|
+
sendError(res, 404, 'NOT_FOUND', `Unknown route: ${method} ${path}`);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error('Request error:', err);
|
|
368
|
+
sendError(res, 500, 'API_ERROR', err instanceof Error ? err.message : String(err));
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return server;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface StartServiceOptions {
|
|
376
|
+
port?: number;
|
|
377
|
+
host?: string;
|
|
378
|
+
apiKey?: string;
|
|
379
|
+
cwd?: string;
|
|
380
|
+
allowedScopes?: ScopeLevel[];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function startService(options: StartServiceOptions = {}): Promise<void> {
|
|
384
|
+
const config: ServiceConfig = {
|
|
385
|
+
port: options.port ?? 4100,
|
|
386
|
+
host: options.host ?? '0.0.0.0',
|
|
387
|
+
apiKey: options.apiKey,
|
|
388
|
+
cwd: options.cwd ?? process.cwd(),
|
|
389
|
+
allowedScopes: options.allowedScopes
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const server = createService(config);
|
|
393
|
+
|
|
394
|
+
return new Promise((resolve, reject) => {
|
|
395
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
396
|
+
if (err.code === 'EADDRINUSE') {
|
|
397
|
+
reject(new Error(`Port ${config.port} is already in use`));
|
|
398
|
+
} else {
|
|
399
|
+
reject(err);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
server.listen(config.port, config.host, () => {
|
|
404
|
+
log(`cli4ai service running on http://${config.host}:${config.port}`);
|
|
405
|
+
log(`Hostname: ${hostname()}`);
|
|
406
|
+
log(`Working directory: ${config.cwd}`);
|
|
407
|
+
if (config.apiKey) {
|
|
408
|
+
log(`Authentication: API key required`);
|
|
409
|
+
} else {
|
|
410
|
+
log(`Authentication: None (not recommended for production)`);
|
|
411
|
+
}
|
|
412
|
+
log('');
|
|
413
|
+
log('Endpoints:');
|
|
414
|
+
log(' GET /health - Service health check');
|
|
415
|
+
log(' GET /packages - List available packages');
|
|
416
|
+
log(' GET /packages/:name - Get package info');
|
|
417
|
+
log(' POST /run - Execute a tool');
|
|
418
|
+
log(' POST /routines/:name/run - Run a routine');
|
|
419
|
+
log('');
|
|
420
|
+
log('Press Ctrl+C to stop');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Handle graceful shutdown
|
|
424
|
+
const shutdown = () => {
|
|
425
|
+
log('\nShutting down...');
|
|
426
|
+
server.close(() => {
|
|
427
|
+
resolve();
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
process.on('SIGINT', shutdown);
|
|
432
|
+
process.on('SIGTERM', shutdown);
|
|
433
|
+
});
|
|
434
|
+
}
|