@oml/cli 0.12.0 → 0.14.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/README.md +29 -21
- package/out/{auth.d.ts → auth/auth.d.ts} +7 -0
- package/out/{auth.js → auth/auth.js} +42 -3
- package/out/auth/auth.js.map +1 -0
- package/out/{platform-constants.js → auth/constants.js} +1 -1
- package/out/auth/constants.js.map +1 -0
- package/out/{platform.d.ts → auth/platform.d.ts} +5 -7
- package/out/{platform.js → auth/platform.js} +39 -17
- package/out/auth/platform.js.map +1 -0
- package/out/cli.d.ts +6 -0
- package/out/cli.js +214 -59
- package/out/cli.js.map +1 -1
- package/out/commands/export.d.ts +8 -0
- package/out/commands/export.js +27 -0
- package/out/commands/export.js.map +1 -0
- package/out/commands/lint.d.ts +22 -2
- package/out/commands/lint.js +64 -18
- package/out/commands/lint.js.map +1 -1
- package/out/commands/reason.d.ts +2 -9
- package/out/commands/reason.js +52 -48
- package/out/commands/reason.js.map +1 -1
- package/out/commands/render.d.ts +1 -9
- package/out/commands/render.js +15 -726
- package/out/commands/render.js.map +1 -1
- package/out/commands/server/actions.d.ts +22 -0
- package/out/commands/server/actions.js +394 -0
- package/out/commands/server/actions.js.map +1 -0
- package/out/commands/server/require.d.ts +1 -0
- package/out/commands/server/require.js +89 -0
- package/out/commands/server/require.js.map +1 -0
- package/out/commands/server/rest.d.ts +2 -0
- package/out/commands/server/rest.js +117 -0
- package/out/commands/server/rest.js.map +1 -0
- package/out/commands/validate.d.ts +3 -3
- package/out/commands/validate.js +35 -171
- package/out/commands/validate.js.map +1 -1
- package/package.json +5 -7
- package/src/{auth.ts → auth/auth.ts} +54 -3
- package/src/{platform.ts → auth/platform.ts} +41 -17
- package/src/cli.ts +249 -59
- package/src/commands/export.ts +54 -0
- package/src/commands/lint.ts +88 -18
- package/src/commands/reason.ts +69 -56
- package/src/commands/render.ts +23 -995
- package/src/commands/server/actions.ts +480 -0
- package/src/commands/server/require.ts +99 -0
- package/src/commands/server/rest.ts +135 -0
- package/src/commands/validate.ts +46 -207
- package/out/auth.js.map +0 -1
- package/out/backend/backend-types.d.ts +0 -21
- package/out/backend/backend-types.js +0 -3
- package/out/backend/backend-types.js.map +0 -1
- package/out/backend/create-backend.d.ts +0 -2
- package/out/backend/create-backend.js +0 -6
- package/out/backend/create-backend.js.map +0 -1
- package/out/backend/direct-backend.d.ts +0 -20
- package/out/backend/direct-backend.js +0 -150
- package/out/backend/direct-backend.js.map +0 -1
- package/out/backend/reasoned-output.d.ts +0 -38
- package/out/backend/reasoned-output.js +0 -568
- package/out/backend/reasoned-output.js.map +0 -1
- package/out/commands/closure.d.ts +0 -33
- package/out/commands/closure.js +0 -537
- package/out/commands/closure.js.map +0 -1
- package/out/commands/compile.d.ts +0 -11
- package/out/commands/compile.js +0 -63
- package/out/commands/compile.js.map +0 -1
- package/out/platform-constants.js.map +0 -1
- package/out/platform.js.map +0 -1
- package/src/backend/backend-types.ts +0 -27
- package/src/backend/create-backend.ts +0 -8
- package/src/backend/direct-backend.ts +0 -169
- package/src/backend/reasoned-output.ts +0 -697
- package/src/commands/closure.ts +0 -624
- package/src/commands/compile.ts +0 -88
- /package/out/{platform-constants.d.ts → auth/constants.d.ts} +0 -0
- /package/src/{platform-constants.ts → auth/constants.ts} +0 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// Copyright (c) 2026 Modelware. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import * as net from 'node:net';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import * as fs from 'node:fs/promises';
|
|
7
|
+
import { constants as FsConstants } from 'node:fs';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { spawn, execFile, type ChildProcess } from 'node:child_process';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
14
|
+
const STARTUP_TIMEOUT_MS = 15_000;
|
|
15
|
+
const SHUTDOWN_TIMEOUT_MS = 3000;
|
|
16
|
+
const POLL_INTERVAL_MS = 100;
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
type StartServerOptions = {
|
|
20
|
+
port?: number | string;
|
|
21
|
+
workspace?: string;
|
|
22
|
+
auth?: {
|
|
23
|
+
accessToken: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ServerStatePaths = {
|
|
28
|
+
dir: string;
|
|
29
|
+
lockFile: string;
|
|
30
|
+
workspaceRoot: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ServerLockState = {
|
|
34
|
+
pid: number;
|
|
35
|
+
port: number;
|
|
36
|
+
owner?: 'daemon' | 'extension';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function workspaceHash(workspaceRoot: string): string {
|
|
40
|
+
return createHash('sha256').update(path.resolve(workspaceRoot)).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getServerStatePaths(workspace?: string): ServerStatePaths {
|
|
44
|
+
const workspaceRoot = path.resolve(workspace ?? process.cwd());
|
|
45
|
+
const dir = path.join(os.homedir(), '.oml', 'workspaces', workspaceHash(workspaceRoot));
|
|
46
|
+
return {
|
|
47
|
+
dir,
|
|
48
|
+
lockFile: path.join(dir, 'server.lock'),
|
|
49
|
+
workspaceRoot,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function cleanupStateFile(paths: ServerStatePaths): Promise<void> {
|
|
54
|
+
await fs.rm(paths.lockFile, { force: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseServerLock(raw: string): ServerLockState | undefined {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(raw) as { pid?: unknown; port?: unknown; owner?: unknown };
|
|
60
|
+
const pid = Number(parsed.pid);
|
|
61
|
+
const port = Number(parsed.port);
|
|
62
|
+
if (!Number.isFinite(pid) || !Number.isFinite(port)) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const pidInt = Math.trunc(pid);
|
|
66
|
+
const portInt = Math.trunc(port);
|
|
67
|
+
if (pidInt <= 0 || portInt <= 0 || portInt > 65535) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const owner = parsed.owner === 'daemon' || parsed.owner === 'extension'
|
|
71
|
+
? parsed.owner
|
|
72
|
+
: undefined;
|
|
73
|
+
return { pid: pidInt, port: portInt, owner };
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readServerLock(lockFile: string): Promise<ServerLockState | undefined> {
|
|
80
|
+
try {
|
|
81
|
+
const raw = (await fs.readFile(lockFile, 'utf-8')).trim();
|
|
82
|
+
if (!raw) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return parseServerLock(raw);
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isProcessAlive(pid: number): boolean {
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveServerMainScript(): string {
|
|
101
|
+
const require = createRequire(import.meta.url);
|
|
102
|
+
const serverPackageJson = require.resolve('@oml/server/package.json');
|
|
103
|
+
const serverPackageDir = path.dirname(serverPackageJson);
|
|
104
|
+
return path.join(serverPackageDir, 'out', 'cli.js');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function canReadFile(filePath: string): Promise<boolean> {
|
|
108
|
+
return fs.access(filePath, FsConstants.R_OK).then(
|
|
109
|
+
() => true,
|
|
110
|
+
() => false,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function waitForPort(host: string, port: number, timeoutMs: number): Promise<boolean> {
|
|
115
|
+
const deadline = Date.now() + timeoutMs;
|
|
116
|
+
return new Promise<boolean>((resolve) => {
|
|
117
|
+
const attempt = (): void => {
|
|
118
|
+
const socket = new net.Socket();
|
|
119
|
+
let settled = false;
|
|
120
|
+
const finish = (value: boolean): void => {
|
|
121
|
+
if (settled) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
settled = true;
|
|
125
|
+
socket.destroy();
|
|
126
|
+
resolve(value);
|
|
127
|
+
};
|
|
128
|
+
socket.once('connect', () => finish(true));
|
|
129
|
+
socket.once('error', () => {
|
|
130
|
+
socket.destroy();
|
|
131
|
+
if (Date.now() >= deadline) {
|
|
132
|
+
resolve(false);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
setTimeout(attempt, POLL_INTERVAL_MS);
|
|
136
|
+
});
|
|
137
|
+
socket.setTimeout(POLL_INTERVAL_MS, () => {
|
|
138
|
+
socket.destroy();
|
|
139
|
+
if (Date.now() >= deadline) {
|
|
140
|
+
resolve(false);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
setTimeout(attempt, POLL_INTERVAL_MS);
|
|
144
|
+
});
|
|
145
|
+
socket.connect(port, host);
|
|
146
|
+
};
|
|
147
|
+
attempt();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function assertPortAvailable(host: string, port: number): Promise<void> {
|
|
152
|
+
return new Promise<void>((resolve, reject) => {
|
|
153
|
+
const server = net.createServer();
|
|
154
|
+
const cleanup = (): void => {
|
|
155
|
+
server.removeAllListeners();
|
|
156
|
+
};
|
|
157
|
+
server.once('error', (error: NodeJS.ErrnoException) => {
|
|
158
|
+
cleanup();
|
|
159
|
+
if (error.code === 'EADDRINUSE') {
|
|
160
|
+
reject(new Error(`Port ${port} is already in use on ${host}. Stop the existing service or choose a different port.`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
reject(error);
|
|
164
|
+
});
|
|
165
|
+
server.listen(port, host, () => {
|
|
166
|
+
server.close((closeError) => {
|
|
167
|
+
cleanup();
|
|
168
|
+
if (closeError) {
|
|
169
|
+
reject(closeError);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
resolve();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
179
|
+
const deadline = Date.now() + timeoutMs;
|
|
180
|
+
while (Date.now() < deadline) {
|
|
181
|
+
if (!isProcessAlive(pid)) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
185
|
+
}
|
|
186
|
+
return !isProcessAlive(pid);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeStartPort(value: number | string | undefined): number {
|
|
190
|
+
if (value === undefined) {
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
const numeric = Number(value);
|
|
194
|
+
if (!Number.isFinite(numeric)) {
|
|
195
|
+
throw new Error(`Invalid port '${value}'. Expected an integer between 1 and 65535.`);
|
|
196
|
+
}
|
|
197
|
+
const intPort = Math.trunc(numeric);
|
|
198
|
+
if (intPort < 0 || intPort > 65535) {
|
|
199
|
+
throw new Error(`Invalid port '${value}'. Expected an integer between 1 and 65535.`);
|
|
200
|
+
}
|
|
201
|
+
return intPort;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function inspectListeningProcess(port: number): Promise<string> {
|
|
205
|
+
const args = ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'];
|
|
206
|
+
try {
|
|
207
|
+
const { stdout } = await execFileAsync('lsof', args);
|
|
208
|
+
return stdout.trim();
|
|
209
|
+
} catch (error) {
|
|
210
|
+
const failed = error as NodeJS.ErrnoException & { stdout?: string };
|
|
211
|
+
if (failed?.code === 'ENOENT') {
|
|
212
|
+
throw new Error("Cannot inspect ports because 'lsof' is not installed or not on PATH.");
|
|
213
|
+
}
|
|
214
|
+
return typeof failed?.stdout === 'string' ? failed.stdout.trim() : '';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function spawnServerProcess(
|
|
219
|
+
serverMainScript: string,
|
|
220
|
+
port: number,
|
|
221
|
+
detached: boolean,
|
|
222
|
+
stdio: 'ignore' | 'inherit',
|
|
223
|
+
options: {
|
|
224
|
+
workspace?: string;
|
|
225
|
+
auth?: { accessToken: string };
|
|
226
|
+
},
|
|
227
|
+
): ChildProcess {
|
|
228
|
+
const args = [serverMainScript, `--port=${port}`];
|
|
229
|
+
|
|
230
|
+
if (options.workspace) {
|
|
231
|
+
args.push(`--workspace=${path.resolve(options.workspace)}`);
|
|
232
|
+
}
|
|
233
|
+
if (options.auth?.accessToken) {
|
|
234
|
+
args.push(`--token=${options.auth.accessToken}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return spawn(process.execPath, args, {
|
|
238
|
+
detached,
|
|
239
|
+
stdio: [stdio, stdio, stdio],
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function ensureNotAlreadyRunning(paths: ServerStatePaths): Promise<void> {
|
|
244
|
+
const state = await readServerLock(paths.lockFile);
|
|
245
|
+
if (!state) {
|
|
246
|
+
await cleanupStateFile(paths);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (isProcessAlive(state.pid)) {
|
|
250
|
+
throw new Error(`Server already running for workspace '${paths.workspaceRoot}' (pid ${state.pid} on port :${state.port}).`);
|
|
251
|
+
}
|
|
252
|
+
await cleanupStateFile(paths);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function waitForServerLock(paths: ServerStatePaths, pid: number, timeoutMs: number): Promise<ServerLockState | undefined> {
|
|
256
|
+
const deadline = Date.now() + timeoutMs;
|
|
257
|
+
while (Date.now() < deadline) {
|
|
258
|
+
const state = await readServerLock(paths.lockFile);
|
|
259
|
+
if (state && state.pid === pid) {
|
|
260
|
+
return state;
|
|
261
|
+
}
|
|
262
|
+
if (!isProcessAlive(pid)) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function serverStartAction(portArg: number | string | undefined, options: StartServerOptions): Promise<void> {
|
|
271
|
+
if (portArg !== undefined && options.port !== undefined && String(portArg) !== String(options.port)) {
|
|
272
|
+
throw new Error(`Conflicting port values '${portArg}' and '${options.port}'. Use either the positional port or --port, not both.`);
|
|
273
|
+
}
|
|
274
|
+
const port = normalizeStartPort(options.port ?? portArg);
|
|
275
|
+
const serverMainScript = resolveServerMainScript();
|
|
276
|
+
const scriptExists = await canReadFile(serverMainScript);
|
|
277
|
+
if (!scriptExists) {
|
|
278
|
+
throw new Error(`Server entrypoint is missing: ${serverMainScript}. Build the workspace first.`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const paths = getServerStatePaths(options.workspace);
|
|
282
|
+
await ensureNotAlreadyRunning(paths);
|
|
283
|
+
if (port > 0) {
|
|
284
|
+
await assertPortAvailable(DEFAULT_HOST, port);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await serverStartDetached(serverMainScript, port, paths, options);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function serverStartDetached(
|
|
291
|
+
serverMainScript: string,
|
|
292
|
+
port: number,
|
|
293
|
+
paths: ServerStatePaths,
|
|
294
|
+
options: StartServerOptions
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
const child = spawnServerProcess(serverMainScript, port, true, 'ignore', {
|
|
297
|
+
workspace: options.workspace,
|
|
298
|
+
auth: options.auth,
|
|
299
|
+
});
|
|
300
|
+
if (!child.pid) {
|
|
301
|
+
throw new Error('Failed to launch server process.');
|
|
302
|
+
}
|
|
303
|
+
child.unref();
|
|
304
|
+
|
|
305
|
+
const state = await waitForServerLock(paths, child.pid, STARTUP_TIMEOUT_MS);
|
|
306
|
+
if (!state) {
|
|
307
|
+
if (isProcessAlive(child.pid)) {
|
|
308
|
+
process.kill(child.pid, 'SIGTERM');
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`Server did not become ready within ${STARTUP_TIMEOUT_MS} ms.`);
|
|
311
|
+
}
|
|
312
|
+
const ready = await waitForPort(DEFAULT_HOST, state.port, STARTUP_TIMEOUT_MS);
|
|
313
|
+
if (!ready) {
|
|
314
|
+
if (isProcessAlive(child.pid)) {
|
|
315
|
+
process.kill(child.pid, 'SIGTERM');
|
|
316
|
+
}
|
|
317
|
+
throw new Error(`Server did not become reachable on ${DEFAULT_HOST}:${state.port} within ${STARTUP_TIMEOUT_MS} ms.`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const endpoint = `http://${DEFAULT_HOST}:${state.port}`;
|
|
321
|
+
console.log(`OML server started on ${endpoint} (pid ${state.pid})`);
|
|
322
|
+
console.log("Use 'oml server stop' to stop the server");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function serverStopAction(): Promise<void> {
|
|
326
|
+
const paths = getServerStatePaths();
|
|
327
|
+
const state = await readServerLock(paths.lockFile);
|
|
328
|
+
if (!state) {
|
|
329
|
+
await cleanupStateFile(paths);
|
|
330
|
+
process.stdout.write('Server is not running.\n');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!isProcessAlive(state.pid)) {
|
|
334
|
+
await cleanupStateFile(paths);
|
|
335
|
+
process.stdout.write('Server is not running.\n');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const owner = state.owner ?? 'unknown';
|
|
339
|
+
if (owner !== 'daemon') {
|
|
340
|
+
process.stdout.write(`Server is running (pid ${state.pid}, port ${state.port}) and managed by ${owner}; not stopping.\n`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
process.kill(state.pid, 'SIGTERM');
|
|
345
|
+
const exited = await waitForProcessExit(state.pid, SHUTDOWN_TIMEOUT_MS);
|
|
346
|
+
if (!exited) {
|
|
347
|
+
throw new Error(`Timed out waiting for server process ${state.pid} to stop.`);
|
|
348
|
+
}
|
|
349
|
+
await cleanupStateFile(paths);
|
|
350
|
+
process.stdout.write(`Server stopped (pid ${state.pid}).\n`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function serverStatusAction(): Promise<void> {
|
|
354
|
+
const paths = getServerStatePaths();
|
|
355
|
+
const state = await readServerLock(paths.lockFile);
|
|
356
|
+
if (!state || !isProcessAlive(state.pid)) {
|
|
357
|
+
if (state) {
|
|
358
|
+
await cleanupStateFile(paths);
|
|
359
|
+
}
|
|
360
|
+
process.stdout.write('Server is not running.\n');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const output = await inspectListeningProcess(state.port);
|
|
364
|
+
if (!output) {
|
|
365
|
+
process.stdout.write(`No listening process found on port ${state.port}.\n`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
process.stdout.write(`${output}\n`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export type RunServerOptions = {
|
|
372
|
+
port?: number | string;
|
|
373
|
+
workspace?: string;
|
|
374
|
+
auth: {
|
|
375
|
+
accessToken: string;
|
|
376
|
+
refreshToken: string;
|
|
377
|
+
expiresAtMs: number;
|
|
378
|
+
onRefresh: (newAccessToken: string, newRefreshToken: string, newExpiresAtMs: number) => Promise<void>;
|
|
379
|
+
};
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
function sendLspNotification(childStdin: NodeJS.WritableStream, method: string, params: unknown): void {
|
|
383
|
+
const message = JSON.stringify({ jsonrpc: '2.0', method, params });
|
|
384
|
+
const header = `Content-Length: ${Buffer.byteLength(message, 'utf-8')}\r\n\r\n`;
|
|
385
|
+
childStdin.write(header + message, 'utf-8');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function serverRunAction(portArg: number | string | undefined, options: RunServerOptions): Promise<void> {
|
|
389
|
+
if (process.env.OML_PLATFORM_API_KEY?.trim()) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
'OML_PLATFORM_API_KEY is set but oml server run requires interactive authentication. ' +
|
|
392
|
+
'Use \'oml server start\' for non-interactive CI/CD mode with an API key.'
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (portArg !== undefined && options.port !== undefined && String(portArg) !== String(options.port)) {
|
|
397
|
+
throw new Error(`Conflicting port values '${portArg}' and '${options.port}'. Use either the positional port or --port, not both.`);
|
|
398
|
+
}
|
|
399
|
+
const port = normalizeStartPort(options.port ?? portArg);
|
|
400
|
+
const serverMainScript = resolveServerMainScript();
|
|
401
|
+
const scriptExists = await canReadFile(serverMainScript);
|
|
402
|
+
if (!scriptExists) {
|
|
403
|
+
throw new Error(`Server entrypoint is missing: ${serverMainScript}. Build the workspace first.`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const paths = getServerStatePaths(options.workspace);
|
|
407
|
+
await ensureNotAlreadyRunning(paths);
|
|
408
|
+
if (port > 0) {
|
|
409
|
+
await assertPortAvailable(DEFAULT_HOST, port);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const args = [serverMainScript, `--port=${port}`];
|
|
413
|
+
if (options.workspace) {
|
|
414
|
+
args.push(`--workspace=${path.resolve(options.workspace)}`);
|
|
415
|
+
}
|
|
416
|
+
args.push(`--token=${options.auth.accessToken}`);
|
|
417
|
+
|
|
418
|
+
const child = spawn(process.execPath, args, {
|
|
419
|
+
detached: false,
|
|
420
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (!child.pid || !child.stdin) {
|
|
424
|
+
throw new Error('Failed to launch server process.');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const state = await waitForServerLock(paths, child.pid, STARTUP_TIMEOUT_MS);
|
|
428
|
+
if (!state) {
|
|
429
|
+
child.kill('SIGTERM');
|
|
430
|
+
throw new Error(`Server did not become ready within ${STARTUP_TIMEOUT_MS} ms.`);
|
|
431
|
+
}
|
|
432
|
+
const ready = await waitForPort(DEFAULT_HOST, state.port, STARTUP_TIMEOUT_MS);
|
|
433
|
+
if (!ready) {
|
|
434
|
+
child.kill('SIGTERM');
|
|
435
|
+
throw new Error(`Server did not become reachable on ${DEFAULT_HOST}:${state.port} within ${STARTUP_TIMEOUT_MS} ms.`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
process.stdout.write(`OML server running on http://${DEFAULT_HOST}:${state.port} (pid ${state.pid})\n`);
|
|
439
|
+
process.stdout.write('Press Ctrl-C to stop.\n');
|
|
440
|
+
|
|
441
|
+
const stdin = child.stdin;
|
|
442
|
+
const REFRESH_INTERVAL_MS = 60 * 60 * 1000;
|
|
443
|
+
let currentRefreshToken = options.auth.refreshToken;
|
|
444
|
+
let currentExpiresAtMs = options.auth.expiresAtMs;
|
|
445
|
+
|
|
446
|
+
const refreshTimer = setInterval(async () => {
|
|
447
|
+
try {
|
|
448
|
+
const leeway = 5 * 60 * 1000;
|
|
449
|
+
if (Date.now() + leeway < currentExpiresAtMs) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const { refreshSupabaseAccessToken } = await import('@oml/platform');
|
|
453
|
+
const supabaseUrl = process.env.OML_SUPABASE_URL?.trim() ?? '';
|
|
454
|
+
const supabaseAnonKey = process.env.OML_SUPABASE_ANON_KEY?.trim() ?? '';
|
|
455
|
+
const refreshed = await refreshSupabaseAccessToken(supabaseUrl, supabaseAnonKey, currentRefreshToken);
|
|
456
|
+
currentRefreshToken = refreshed.refresh_token;
|
|
457
|
+
currentExpiresAtMs = Date.now() + refreshed.expires_in * 1000;
|
|
458
|
+
sendLspNotification(stdin, '$/tokenRefreshed', { accessToken: refreshed.access_token });
|
|
459
|
+
await options.auth.onRefresh(refreshed.access_token, refreshed.refresh_token, currentExpiresAtMs);
|
|
460
|
+
} catch {
|
|
461
|
+
// Refresh failure is non-fatal — server continues with cached token.
|
|
462
|
+
}
|
|
463
|
+
}, REFRESH_INTERVAL_MS);
|
|
464
|
+
|
|
465
|
+
await new Promise<void>((resolve) => {
|
|
466
|
+
const shutdown = (): void => {
|
|
467
|
+
clearInterval(refreshTimer);
|
|
468
|
+
child.kill('SIGTERM');
|
|
469
|
+
resolve();
|
|
470
|
+
};
|
|
471
|
+
process.once('SIGINT', shutdown);
|
|
472
|
+
process.once('SIGTERM', shutdown);
|
|
473
|
+
child.once('exit', () => {
|
|
474
|
+
clearInterval(refreshTimer);
|
|
475
|
+
resolve();
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
await cleanupStateFile(paths);
|
|
480
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Copyright (c) 2026 Modelware. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import * as net from 'node:net';
|
|
5
|
+
import * as os from 'node:os';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
10
|
+
const CONNECT_TIMEOUT_MS = 400;
|
|
11
|
+
const START_SERVER_HINT = "start server first (run 'oml server start')";
|
|
12
|
+
|
|
13
|
+
type ServerState = {
|
|
14
|
+
pid: number;
|
|
15
|
+
port: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function workspaceHash(workspaceRoot: string): string {
|
|
19
|
+
return createHash('sha256').update(path.resolve(workspaceRoot)).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function serverLockFileForWorkspace(workspaceRoot: string): string {
|
|
23
|
+
return path.join(os.homedir(), '.oml', 'workspaces', workspaceHash(workspaceRoot), 'server.lock');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseServerState(raw: string): ServerState | undefined {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(raw) as { pid?: unknown; port?: unknown };
|
|
29
|
+
const pid = Number(parsed.pid);
|
|
30
|
+
const port = Number(parsed.port);
|
|
31
|
+
if (!Number.isFinite(pid) || !Number.isFinite(port)) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const pidInt = Math.trunc(pid);
|
|
35
|
+
const portInt = Math.trunc(port);
|
|
36
|
+
if (pidInt <= 0 || portInt <= 0 || portInt > 65535) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
return { pid: pidInt, port: portInt };
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isProcessAlive(pid: number): boolean {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isPortReachable(host: string, port: number): Promise<boolean> {
|
|
55
|
+
return new Promise<boolean>((resolve) => {
|
|
56
|
+
const socket = new net.Socket();
|
|
57
|
+
let settled = false;
|
|
58
|
+
const finish = (value: boolean): void => {
|
|
59
|
+
if (settled) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
settled = true;
|
|
63
|
+
socket.destroy();
|
|
64
|
+
resolve(value);
|
|
65
|
+
};
|
|
66
|
+
socket.once('connect', () => finish(true));
|
|
67
|
+
socket.once('error', () => finish(false));
|
|
68
|
+
socket.setTimeout(CONNECT_TIMEOUT_MS, () => finish(false));
|
|
69
|
+
socket.connect(port, host);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readRunningState(workspaceRoot = process.cwd()): Promise<ServerState | undefined> {
|
|
74
|
+
const lockFile = serverLockFileForWorkspace(path.resolve(workspaceRoot));
|
|
75
|
+
try {
|
|
76
|
+
const raw = (await fs.readFile(lockFile, 'utf-8')).trim();
|
|
77
|
+
if (!raw) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const state = parseServerState(raw);
|
|
81
|
+
if (!state || !isProcessAlive(state.pid)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return state;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function assertServerRunning(): Promise<void> {
|
|
91
|
+
const state = await readRunningState();
|
|
92
|
+
if (!state) {
|
|
93
|
+
throw new Error(START_SERVER_HINT);
|
|
94
|
+
}
|
|
95
|
+
const reachable = await isPortReachable(DEFAULT_HOST, state.port);
|
|
96
|
+
if (!reachable) {
|
|
97
|
+
throw new Error(START_SERVER_HINT);
|
|
98
|
+
}
|
|
99
|
+
}
|