@openspecui/server 0.9.0 → 1.0.2
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/dist/index.mjs +1070 -127
- package/package.json +6 -4
package/dist/index.mjs
CHANGED
|
@@ -4,12 +4,179 @@ import { cors } from "hono/cors";
|
|
|
4
4
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
|
5
5
|
import { applyWSSHandler } from "@trpc/server/adapters/ws";
|
|
6
6
|
import { WebSocketServer } from "ws";
|
|
7
|
-
import { CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, ReactiveContext, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, initWatcherPool, sniffGlobalCli } from "@openspecui/core";
|
|
7
|
+
import { ApplyInstructionsSchema, ArtifactInstructionsSchema, ChangeStatusSchema, CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, SchemaDetailSchema, SchemaInfoSchema, SchemaResolutionSchema, TemplatesSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, initWatcherPool, isWatcherPoolInitialized, reactiveReadDir, reactiveReadFile, reactiveStat, sniffGlobalCli } from "@openspecui/core";
|
|
8
8
|
import { initTRPC } from "@trpc/server";
|
|
9
9
|
import { observable } from "@trpc/server/observable";
|
|
10
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join, matchesGlob, relative, resolve, sep } from "node:path";
|
|
10
12
|
import { z } from "zod";
|
|
13
|
+
import { parse } from "yaml";
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
11
15
|
import { createServer as createServer$1 } from "node:net";
|
|
16
|
+
import * as pty from "@lydell/node-pty";
|
|
17
|
+
import { EventEmitter as EventEmitter$1 } from "events";
|
|
12
18
|
|
|
19
|
+
//#region src/cli-stream-observable.ts
|
|
20
|
+
/**
|
|
21
|
+
* 创建安全的 CLI 流式 observable
|
|
22
|
+
*
|
|
23
|
+
* 解决的问题:
|
|
24
|
+
* 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
|
|
25
|
+
* 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
|
|
26
|
+
* 3. 确保取消时正确清理资源
|
|
27
|
+
*
|
|
28
|
+
* @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
|
|
29
|
+
*/
|
|
30
|
+
function createCliStreamObservable(startStream) {
|
|
31
|
+
return observable((emit) => {
|
|
32
|
+
let cancel;
|
|
33
|
+
let completed = false;
|
|
34
|
+
/**
|
|
35
|
+
* 安全的事件处理器
|
|
36
|
+
* - 检查是否已完成,防止重复调用
|
|
37
|
+
* - 使用 try-catch 防止异常导致服务器崩溃
|
|
38
|
+
*/
|
|
39
|
+
const safeEventHandler = (event) => {
|
|
40
|
+
if (completed) return;
|
|
41
|
+
try {
|
|
42
|
+
emit.next(event);
|
|
43
|
+
if (event.type === "exit") {
|
|
44
|
+
completed = true;
|
|
45
|
+
emit.complete();
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error("[CLI Stream] Error emitting event:", err);
|
|
49
|
+
if (!completed) {
|
|
50
|
+
completed = true;
|
|
51
|
+
try {
|
|
52
|
+
emit.error(err instanceof Error ? err : new Error(String(err)));
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
startStream(safeEventHandler).then((cancelFn) => {
|
|
58
|
+
cancel = cancelFn;
|
|
59
|
+
}).catch((err) => {
|
|
60
|
+
console.error("[CLI Stream] Error starting stream:", err);
|
|
61
|
+
if (!completed) {
|
|
62
|
+
completed = true;
|
|
63
|
+
try {
|
|
64
|
+
emit.error(err instanceof Error ? err : new Error(String(err)));
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
return () => {
|
|
69
|
+
completed = true;
|
|
70
|
+
cancel?.();
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/opsx-schema.ts
|
|
77
|
+
const SchemaYamlArtifactSchema = z.object({
|
|
78
|
+
id: z.string(),
|
|
79
|
+
generates: z.string(),
|
|
80
|
+
description: z.string().optional(),
|
|
81
|
+
template: z.string().optional(),
|
|
82
|
+
instruction: z.string().optional(),
|
|
83
|
+
requires: z.array(z.string()).optional()
|
|
84
|
+
});
|
|
85
|
+
const SchemaYamlSchema = z.object({
|
|
86
|
+
name: z.string(),
|
|
87
|
+
version: z.union([z.string(), z.number()]).optional(),
|
|
88
|
+
description: z.string().optional(),
|
|
89
|
+
artifacts: z.array(SchemaYamlArtifactSchema),
|
|
90
|
+
apply: z.object({
|
|
91
|
+
requires: z.array(z.string()).optional(),
|
|
92
|
+
tracks: z.string().optional(),
|
|
93
|
+
instruction: z.string().optional()
|
|
94
|
+
}).optional()
|
|
95
|
+
});
|
|
96
|
+
function parseSchemaYaml(content) {
|
|
97
|
+
const raw = parse(content);
|
|
98
|
+
const parsed = SchemaYamlSchema.safeParse(raw);
|
|
99
|
+
if (!parsed.success) throw new Error(`Invalid schema.yaml: ${parsed.error.message}`);
|
|
100
|
+
const { artifacts, apply, name, description, version } = parsed.data;
|
|
101
|
+
const detail = {
|
|
102
|
+
name,
|
|
103
|
+
description,
|
|
104
|
+
version,
|
|
105
|
+
artifacts: artifacts.map((artifact) => ({
|
|
106
|
+
id: artifact.id,
|
|
107
|
+
outputPath: artifact.generates,
|
|
108
|
+
description: artifact.description,
|
|
109
|
+
template: artifact.template,
|
|
110
|
+
instruction: artifact.instruction,
|
|
111
|
+
requires: artifact.requires ?? []
|
|
112
|
+
})),
|
|
113
|
+
applyRequires: apply?.requires ?? [],
|
|
114
|
+
applyTracks: apply?.tracks,
|
|
115
|
+
applyInstruction: apply?.instruction
|
|
116
|
+
};
|
|
117
|
+
const validated = SchemaDetailSchema.safeParse(detail);
|
|
118
|
+
if (!validated.success) throw new Error(`Invalid schema detail: ${validated.error.message}`);
|
|
119
|
+
return validated.data;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/reactive-kv.ts
|
|
124
|
+
/**
|
|
125
|
+
* In-memory reactive key-value store.
|
|
126
|
+
*
|
|
127
|
+
* - No disk persistence — devices own their own storage (e.g. IndexedDB).
|
|
128
|
+
* - Emits 'change' events when values are set or deleted.
|
|
129
|
+
* - Designed for cross-device sync scenarios where the server acts as a
|
|
130
|
+
* transient rendezvous point.
|
|
131
|
+
*/
|
|
132
|
+
var ReactiveKV = class {
|
|
133
|
+
store = /* @__PURE__ */ new Map();
|
|
134
|
+
emitter = new EventEmitter();
|
|
135
|
+
constructor() {
|
|
136
|
+
this.emitter.setMaxListeners(200);
|
|
137
|
+
}
|
|
138
|
+
get(key) {
|
|
139
|
+
return this.store.get(key);
|
|
140
|
+
}
|
|
141
|
+
set(key, value) {
|
|
142
|
+
this.store.set(key, value);
|
|
143
|
+
this.emitter.emit("change", key);
|
|
144
|
+
this.emitter.emit(`change:${key}`, value);
|
|
145
|
+
}
|
|
146
|
+
delete(key) {
|
|
147
|
+
const existed = this.store.delete(key);
|
|
148
|
+
if (existed) {
|
|
149
|
+
this.emitter.emit("change", key);
|
|
150
|
+
this.emitter.emit(`change:${key}`, void 0);
|
|
151
|
+
}
|
|
152
|
+
return existed;
|
|
153
|
+
}
|
|
154
|
+
has(key) {
|
|
155
|
+
return this.store.has(key);
|
|
156
|
+
}
|
|
157
|
+
keys() {
|
|
158
|
+
return [...this.store.keys()];
|
|
159
|
+
}
|
|
160
|
+
/** Subscribe to changes for a specific key. Returns unsubscribe function. */
|
|
161
|
+
onKey(key, listener) {
|
|
162
|
+
const event = `change:${key}`;
|
|
163
|
+
this.emitter.on(event, listener);
|
|
164
|
+
return () => {
|
|
165
|
+
this.emitter.off(event, listener);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** Subscribe to all changes. Returns unsubscribe function. */
|
|
169
|
+
onChange(listener) {
|
|
170
|
+
this.emitter.on("change", listener);
|
|
171
|
+
return () => {
|
|
172
|
+
this.emitter.off("change", listener);
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/** Singleton instance shared across the server lifetime */
|
|
177
|
+
const reactiveKV = new ReactiveKV();
|
|
178
|
+
|
|
179
|
+
//#endregion
|
|
13
180
|
//#region src/reactive-subscription.ts
|
|
14
181
|
/**
|
|
15
182
|
* 创建响应式订阅
|
|
@@ -67,85 +234,187 @@ function createReactiveSubscriptionWithInput(task) {
|
|
|
67
234
|
};
|
|
68
235
|
}
|
|
69
236
|
|
|
70
|
-
//#endregion
|
|
71
|
-
//#region src/cli-stream-observable.ts
|
|
72
|
-
/**
|
|
73
|
-
* 创建安全的 CLI 流式 observable
|
|
74
|
-
*
|
|
75
|
-
* 解决的问题:
|
|
76
|
-
* 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
|
|
77
|
-
* 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
|
|
78
|
-
* 3. 确保取消时正确清理资源
|
|
79
|
-
*
|
|
80
|
-
* @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
|
|
81
|
-
*/
|
|
82
|
-
function createCliStreamObservable(startStream) {
|
|
83
|
-
return observable((emit) => {
|
|
84
|
-
let cancel;
|
|
85
|
-
let completed = false;
|
|
86
|
-
/**
|
|
87
|
-
* 安全的事件处理器
|
|
88
|
-
* - 检查是否已完成,防止重复调用
|
|
89
|
-
* - 使用 try-catch 防止异常导致服务器崩溃
|
|
90
|
-
*/
|
|
91
|
-
const safeEventHandler = (event) => {
|
|
92
|
-
if (completed) return;
|
|
93
|
-
try {
|
|
94
|
-
emit.next(event);
|
|
95
|
-
if (event.type === "exit") {
|
|
96
|
-
completed = true;
|
|
97
|
-
emit.complete();
|
|
98
|
-
}
|
|
99
|
-
} catch (err) {
|
|
100
|
-
console.error("[CLI Stream] Error emitting event:", err);
|
|
101
|
-
if (!completed) {
|
|
102
|
-
completed = true;
|
|
103
|
-
try {
|
|
104
|
-
emit.error(err instanceof Error ? err : new Error(String(err)));
|
|
105
|
-
} catch {}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
startStream(safeEventHandler).then((cancelFn) => {
|
|
110
|
-
cancel = cancelFn;
|
|
111
|
-
}).catch((err) => {
|
|
112
|
-
console.error("[CLI Stream] Error starting stream:", err);
|
|
113
|
-
if (!completed) {
|
|
114
|
-
completed = true;
|
|
115
|
-
try {
|
|
116
|
-
emit.error(err instanceof Error ? err : new Error(String(err)));
|
|
117
|
-
} catch {}
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
return () => {
|
|
121
|
-
completed = true;
|
|
122
|
-
cancel?.();
|
|
123
|
-
};
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
237
|
//#endregion
|
|
128
238
|
//#region src/router.ts
|
|
129
239
|
const t = initTRPC.context().create();
|
|
130
240
|
const router = t.router;
|
|
131
241
|
const publicProcedure = t.procedure;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
242
|
+
function requireChangeId(changeId) {
|
|
243
|
+
if (!changeId) throw new Error("change is required");
|
|
244
|
+
return changeId;
|
|
245
|
+
}
|
|
246
|
+
function ensureEditableSource(source, label) {
|
|
247
|
+
if (source === "package") throw new Error(`${label} is read-only (package source)`);
|
|
248
|
+
}
|
|
249
|
+
function parseCliJson(raw, schema, label) {
|
|
250
|
+
const trimmed = raw.trim();
|
|
251
|
+
if (!trimmed) throw new Error(`${label} returned empty output`);
|
|
252
|
+
let parsed;
|
|
253
|
+
try {
|
|
254
|
+
parsed = JSON.parse(trimmed);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
257
|
+
throw new Error(`${label} returned invalid JSON: ${message}`);
|
|
258
|
+
}
|
|
259
|
+
const result = schema.safeParse(parsed);
|
|
260
|
+
if (!result.success) throw new Error(`${label} returned unexpected JSON: ${result.error.message}`);
|
|
261
|
+
return result.data;
|
|
262
|
+
}
|
|
263
|
+
function resolveEntryPath(root, entryPath) {
|
|
264
|
+
const normalizedRoot = resolve(root);
|
|
265
|
+
const resolvedPath = resolve(normalizedRoot, entryPath);
|
|
266
|
+
const rootPrefix = normalizedRoot + sep;
|
|
267
|
+
if (resolvedPath !== normalizedRoot && !resolvedPath.startsWith(rootPrefix)) throw new Error("Invalid path: outside schema root");
|
|
268
|
+
return resolvedPath;
|
|
269
|
+
}
|
|
270
|
+
function toRelativePath(root, absolutePath) {
|
|
271
|
+
return relative(root, absolutePath).split(sep).join("/");
|
|
272
|
+
}
|
|
273
|
+
async function readEntriesUnderRoot(root) {
|
|
274
|
+
if (!(await reactiveStat(root))?.isDirectory) return [];
|
|
275
|
+
const collectEntries = async (dir) => {
|
|
276
|
+
const names = await reactiveReadDir(dir, { includeHidden: false });
|
|
277
|
+
const entries = [];
|
|
278
|
+
for (const name of names) {
|
|
279
|
+
const fullPath = join(dir, name);
|
|
280
|
+
const statInfo = await reactiveStat(fullPath);
|
|
281
|
+
if (!statInfo) continue;
|
|
282
|
+
const relativePath = toRelativePath(root, fullPath);
|
|
283
|
+
if (statInfo.isDirectory) {
|
|
284
|
+
entries.push({
|
|
285
|
+
path: relativePath,
|
|
286
|
+
type: "directory"
|
|
287
|
+
});
|
|
288
|
+
entries.push(...await collectEntries(fullPath));
|
|
289
|
+
} else {
|
|
290
|
+
const content = await reactiveReadFile(fullPath);
|
|
291
|
+
const size = content ? Buffer.byteLength(content, "utf-8") : void 0;
|
|
292
|
+
entries.push({
|
|
293
|
+
path: relativePath,
|
|
294
|
+
type: "file",
|
|
295
|
+
content: content ?? void 0,
|
|
296
|
+
size
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return entries;
|
|
301
|
+
};
|
|
302
|
+
return collectEntries(root);
|
|
303
|
+
}
|
|
304
|
+
async function touchOpsxProjectDeps(projectDir) {
|
|
305
|
+
const openspecDir = join(projectDir, "openspec");
|
|
306
|
+
await reactiveReadFile(join(openspecDir, "config.yaml"));
|
|
307
|
+
const schemaRoot = join(openspecDir, "schemas");
|
|
308
|
+
const schemaDirs = await reactiveReadDir(schemaRoot, {
|
|
309
|
+
directoriesOnly: true,
|
|
310
|
+
includeHidden: true
|
|
311
|
+
});
|
|
312
|
+
await Promise.all(schemaDirs.map((name) => reactiveReadFile(join(schemaRoot, name, "schema.yaml"))));
|
|
313
|
+
await reactiveReadDir(join(openspecDir, "changes"), {
|
|
314
|
+
directoriesOnly: true,
|
|
315
|
+
includeHidden: true,
|
|
316
|
+
exclude: ["archive"]
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
async function touchOpsxChangeDeps(projectDir, changeId) {
|
|
320
|
+
const changeDir = join(projectDir, "openspec", "changes", changeId);
|
|
321
|
+
await reactiveReadDir(changeDir, { includeHidden: true });
|
|
322
|
+
await reactiveReadFile(join(changeDir, ".openspec.yaml"));
|
|
323
|
+
}
|
|
324
|
+
async function readGlobArtifactFiles(projectDir, changeId, outputPath) {
|
|
325
|
+
return (await readEntriesUnderRoot(join(projectDir, "openspec", "changes", changeId))).filter((entry) => entry.type === "file" && matchesGlob(entry.path, outputPath)).map((entry) => ({
|
|
326
|
+
path: entry.path,
|
|
327
|
+
type: "file",
|
|
328
|
+
content: entry.content ?? ""
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
async function fetchOpsxStatus(ctx, input) {
|
|
332
|
+
const changeId = requireChangeId(input.change);
|
|
333
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
334
|
+
await touchOpsxChangeDeps(ctx.projectDir, changeId);
|
|
335
|
+
const args = [
|
|
336
|
+
"status",
|
|
337
|
+
"--json",
|
|
338
|
+
"--change",
|
|
339
|
+
changeId
|
|
340
|
+
];
|
|
341
|
+
if (input.schema) args.push("--schema", input.schema);
|
|
342
|
+
const result = await ctx.cliExecutor.execute(args);
|
|
343
|
+
if (!result.success) throw new Error(result.stderr || `openspec status failed (exit ${result.exitCode ?? "null"})`);
|
|
344
|
+
const status = parseCliJson(result.stdout, ChangeStatusSchema, "openspec status");
|
|
345
|
+
const changeRelDir = `openspec/changes/${changeId}`;
|
|
346
|
+
for (const artifact of status.artifacts) artifact.relativePath = `${changeRelDir}/${artifact.outputPath}`;
|
|
347
|
+
return status;
|
|
348
|
+
}
|
|
349
|
+
async function fetchOpsxStatusList(ctx) {
|
|
350
|
+
const changeIds = await reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
|
|
351
|
+
directoriesOnly: true,
|
|
352
|
+
includeHidden: false,
|
|
353
|
+
exclude: ["archive"]
|
|
354
|
+
});
|
|
355
|
+
return await Promise.all(changeIds.map((changeId) => fetchOpsxStatus(ctx, { change: changeId })));
|
|
356
|
+
}
|
|
357
|
+
async function fetchOpsxInstructions(ctx, input) {
|
|
358
|
+
const changeId = requireChangeId(input.change);
|
|
359
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
360
|
+
await touchOpsxChangeDeps(ctx.projectDir, changeId);
|
|
361
|
+
const args = [
|
|
362
|
+
"instructions",
|
|
363
|
+
input.artifact,
|
|
364
|
+
"--json",
|
|
365
|
+
"--change",
|
|
366
|
+
changeId
|
|
367
|
+
];
|
|
368
|
+
if (input.schema) args.push("--schema", input.schema);
|
|
369
|
+
const result = await ctx.cliExecutor.execute(args);
|
|
370
|
+
if (!result.success) throw new Error(result.stderr || `openspec instructions failed (exit ${result.exitCode ?? "null"})`);
|
|
371
|
+
return parseCliJson(result.stdout, ArtifactInstructionsSchema, "openspec instructions");
|
|
372
|
+
}
|
|
373
|
+
async function fetchOpsxApplyInstructions(ctx, input) {
|
|
374
|
+
const changeId = requireChangeId(input.change);
|
|
375
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
376
|
+
await touchOpsxChangeDeps(ctx.projectDir, changeId);
|
|
377
|
+
const args = [
|
|
378
|
+
"instructions",
|
|
379
|
+
"apply",
|
|
380
|
+
"--json",
|
|
381
|
+
"--change",
|
|
382
|
+
changeId
|
|
383
|
+
];
|
|
384
|
+
if (input.schema) args.push("--schema", input.schema);
|
|
385
|
+
const result = await ctx.cliExecutor.execute(args);
|
|
386
|
+
if (!result.success) throw new Error(result.stderr || `openspec instructions apply failed (exit ${result.exitCode ?? "null"})`);
|
|
387
|
+
return parseCliJson(result.stdout, ApplyInstructionsSchema, "openspec instructions apply");
|
|
388
|
+
}
|
|
389
|
+
async function fetchOpsxSchemas(ctx) {
|
|
390
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
391
|
+
const result = await ctx.cliExecutor.schemas();
|
|
392
|
+
if (!result.success) throw new Error(result.stderr || `openspec schemas failed (exit ${result.exitCode ?? "null"})`);
|
|
393
|
+
return parseCliJson(result.stdout, z.array(SchemaInfoSchema), "openspec schemas");
|
|
394
|
+
}
|
|
395
|
+
async function fetchOpsxSchemaResolution(ctx, name) {
|
|
396
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
397
|
+
const result = await ctx.cliExecutor.schemaWhich(name);
|
|
398
|
+
if (!result.success) throw new Error(result.stderr || `openspec schema which failed (exit ${result.exitCode ?? "null"})`);
|
|
399
|
+
return parseCliJson(result.stdout, SchemaResolutionSchema, "openspec schema which");
|
|
400
|
+
}
|
|
401
|
+
async function fetchOpsxTemplates(ctx, schema) {
|
|
402
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
403
|
+
const result = await ctx.cliExecutor.templates(schema);
|
|
404
|
+
if (!result.success) throw new Error(result.stderr || `openspec templates failed (exit ${result.exitCode ?? "null"})`);
|
|
405
|
+
return parseCliJson(result.stdout, TemplatesSchema, "openspec templates");
|
|
406
|
+
}
|
|
407
|
+
async function fetchOpsxTemplateContents(ctx, schema) {
|
|
408
|
+
const templates = await fetchOpsxTemplates(ctx, schema);
|
|
409
|
+
const entries = await Promise.all(Object.entries(templates).map(async ([artifactId, info]) => {
|
|
410
|
+
return [artifactId, {
|
|
411
|
+
content: await reactiveReadFile(info.path),
|
|
412
|
+
path: info.path,
|
|
413
|
+
source: info.source
|
|
414
|
+
}];
|
|
415
|
+
}));
|
|
416
|
+
return Object.fromEntries(entries);
|
|
417
|
+
}
|
|
149
418
|
/**
|
|
150
419
|
* Spec router - spec CRUD operations
|
|
151
420
|
*/
|
|
@@ -223,17 +492,8 @@ const changeRouter = router({
|
|
|
223
492
|
if (!await ctx.adapter.toggleTask(input.changeId, input.taskIndex, input.completed)) throw new Error(`Failed to toggle task ${input.taskIndex} in change ${input.changeId}`);
|
|
224
493
|
return { success: true };
|
|
225
494
|
}),
|
|
226
|
-
subscribe: publicProcedure.subscription(({ ctx }) => {
|
|
227
|
-
return createReactiveSubscription(() => ctx.adapter.listChangesWithMeta());
|
|
228
|
-
}),
|
|
229
|
-
subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
|
|
230
|
-
return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChange(id))(input.id);
|
|
231
|
-
}),
|
|
232
495
|
subscribeFiles: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
|
|
233
496
|
return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChangeFiles(id))(input.id);
|
|
234
|
-
}),
|
|
235
|
-
subscribeRaw: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
|
|
236
|
-
return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChangeRaw(id))(input.id);
|
|
237
497
|
})
|
|
238
498
|
});
|
|
239
499
|
/**
|
|
@@ -244,31 +504,6 @@ const initRouter = router({ init: publicProcedure.mutation(async ({ ctx }) => {
|
|
|
244
504
|
return { success: true };
|
|
245
505
|
}) });
|
|
246
506
|
/**
|
|
247
|
-
* Project router - project-level files (project.md, AGENTS.md)
|
|
248
|
-
*/
|
|
249
|
-
const projectRouter = router({
|
|
250
|
-
getProjectMd: publicProcedure.query(async ({ ctx }) => {
|
|
251
|
-
return ctx.adapter.readProjectMd();
|
|
252
|
-
}),
|
|
253
|
-
getAgentsMd: publicProcedure.query(async ({ ctx }) => {
|
|
254
|
-
return ctx.adapter.readAgentsMd();
|
|
255
|
-
}),
|
|
256
|
-
saveProjectMd: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
|
|
257
|
-
await ctx.adapter.writeProjectMd(input.content);
|
|
258
|
-
return { success: true };
|
|
259
|
-
}),
|
|
260
|
-
saveAgentsMd: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
|
|
261
|
-
await ctx.adapter.writeAgentsMd(input.content);
|
|
262
|
-
return { success: true };
|
|
263
|
-
}),
|
|
264
|
-
subscribeProjectMd: publicProcedure.subscription(({ ctx }) => {
|
|
265
|
-
return createReactiveSubscription(() => ctx.adapter.readProjectMd());
|
|
266
|
-
}),
|
|
267
|
-
subscribeAgentsMd: publicProcedure.subscription(({ ctx }) => {
|
|
268
|
-
return createReactiveSubscription(() => ctx.adapter.readAgentsMd());
|
|
269
|
-
})
|
|
270
|
-
});
|
|
271
|
-
/**
|
|
272
507
|
* Archive router - archived changes
|
|
273
508
|
*/
|
|
274
509
|
const archiveRouter = router({
|
|
@@ -378,20 +613,40 @@ const configRouter = router({
|
|
|
378
613
|
return getDefaultCliCommandString();
|
|
379
614
|
}),
|
|
380
615
|
update: publicProcedure.input(z.object({
|
|
381
|
-
cli: z.object({
|
|
382
|
-
|
|
616
|
+
cli: z.object({
|
|
617
|
+
command: z.string().nullable().optional(),
|
|
618
|
+
args: z.array(z.string()).nullable().optional()
|
|
619
|
+
}).optional(),
|
|
620
|
+
theme: z.enum([
|
|
383
621
|
"light",
|
|
384
622
|
"dark",
|
|
385
623
|
"system"
|
|
386
|
-
])
|
|
624
|
+
]).optional(),
|
|
625
|
+
terminal: z.object({
|
|
626
|
+
fontSize: z.number().min(8).max(32).optional(),
|
|
627
|
+
fontFamily: z.string().optional(),
|
|
628
|
+
cursorBlink: z.boolean().optional(),
|
|
629
|
+
cursorStyle: z.enum([
|
|
630
|
+
"block",
|
|
631
|
+
"underline",
|
|
632
|
+
"bar"
|
|
633
|
+
]).optional(),
|
|
634
|
+
scrollback: z.number().min(0).max(1e5).optional()
|
|
635
|
+
}).optional()
|
|
387
636
|
})).mutation(async ({ ctx, input }) => {
|
|
637
|
+
const hasCliCommand = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "command");
|
|
638
|
+
const hasCliArgs = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "args");
|
|
639
|
+
if (hasCliCommand && !hasCliArgs) {
|
|
640
|
+
await ctx.configManager.setCliCommand(input.cli?.command ?? "");
|
|
641
|
+
if (input.theme !== void 0 || input.terminal !== void 0) await ctx.configManager.writeConfig({
|
|
642
|
+
theme: input.theme,
|
|
643
|
+
terminal: input.terminal
|
|
644
|
+
});
|
|
645
|
+
return { success: true };
|
|
646
|
+
}
|
|
388
647
|
await ctx.configManager.writeConfig(input);
|
|
389
648
|
return { success: true };
|
|
390
649
|
}),
|
|
391
|
-
setCliCommand: publicProcedure.input(z.object({ command: z.string() })).mutation(async ({ ctx, input }) => {
|
|
392
|
-
await ctx.configManager.setCliCommand(input.command);
|
|
393
|
-
return { success: true };
|
|
394
|
-
}),
|
|
395
650
|
subscribe: publicProcedure.subscription(({ ctx }) => {
|
|
396
651
|
return createReactiveSubscription(() => ctx.configManager.readConfig());
|
|
397
652
|
})
|
|
@@ -501,18 +756,348 @@ const cliRouter = router({
|
|
|
501
756
|
})
|
|
502
757
|
});
|
|
503
758
|
/**
|
|
759
|
+
* OPSX router - CLI-driven workflow data
|
|
760
|
+
*/
|
|
761
|
+
const opsxRouter = router({
|
|
762
|
+
status: publicProcedure.input(z.object({
|
|
763
|
+
change: z.string().optional(),
|
|
764
|
+
schema: z.string().optional()
|
|
765
|
+
})).query(async ({ ctx, input }) => {
|
|
766
|
+
return fetchOpsxStatus(ctx, input);
|
|
767
|
+
}),
|
|
768
|
+
subscribeStatus: publicProcedure.input(z.object({
|
|
769
|
+
change: z.string().optional(),
|
|
770
|
+
schema: z.string().optional()
|
|
771
|
+
})).subscription(({ ctx, input }) => {
|
|
772
|
+
return createReactiveSubscription(() => fetchOpsxStatus(ctx, input));
|
|
773
|
+
}),
|
|
774
|
+
statusList: publicProcedure.query(async ({ ctx }) => {
|
|
775
|
+
return fetchOpsxStatusList(ctx);
|
|
776
|
+
}),
|
|
777
|
+
subscribeStatusList: publicProcedure.subscription(({ ctx }) => {
|
|
778
|
+
return createReactiveSubscription(() => fetchOpsxStatusList(ctx));
|
|
779
|
+
}),
|
|
780
|
+
instructions: publicProcedure.input(z.object({
|
|
781
|
+
change: z.string().optional(),
|
|
782
|
+
artifact: z.string(),
|
|
783
|
+
schema: z.string().optional()
|
|
784
|
+
})).query(async ({ ctx, input }) => {
|
|
785
|
+
return fetchOpsxInstructions(ctx, input);
|
|
786
|
+
}),
|
|
787
|
+
subscribeInstructions: publicProcedure.input(z.object({
|
|
788
|
+
change: z.string().optional(),
|
|
789
|
+
artifact: z.string(),
|
|
790
|
+
schema: z.string().optional()
|
|
791
|
+
})).subscription(({ ctx, input }) => {
|
|
792
|
+
return createReactiveSubscription(() => fetchOpsxInstructions(ctx, input));
|
|
793
|
+
}),
|
|
794
|
+
applyInstructions: publicProcedure.input(z.object({
|
|
795
|
+
change: z.string().optional(),
|
|
796
|
+
schema: z.string().optional()
|
|
797
|
+
})).query(async ({ ctx, input }) => {
|
|
798
|
+
return fetchOpsxApplyInstructions(ctx, input);
|
|
799
|
+
}),
|
|
800
|
+
subscribeApplyInstructions: publicProcedure.input(z.object({
|
|
801
|
+
change: z.string().optional(),
|
|
802
|
+
schema: z.string().optional()
|
|
803
|
+
})).subscription(({ ctx, input }) => {
|
|
804
|
+
return createReactiveSubscription(() => fetchOpsxApplyInstructions(ctx, input));
|
|
805
|
+
}),
|
|
806
|
+
schemas: publicProcedure.query(async ({ ctx }) => {
|
|
807
|
+
return fetchOpsxSchemas(ctx);
|
|
808
|
+
}),
|
|
809
|
+
subscribeSchemas: publicProcedure.subscription(({ ctx }) => {
|
|
810
|
+
return createReactiveSubscription(() => fetchOpsxSchemas(ctx));
|
|
811
|
+
}),
|
|
812
|
+
templates: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
|
|
813
|
+
return fetchOpsxTemplates(ctx, input?.schema);
|
|
814
|
+
}),
|
|
815
|
+
subscribeTemplates: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
|
|
816
|
+
return createReactiveSubscription(() => fetchOpsxTemplates(ctx, input?.schema));
|
|
817
|
+
}),
|
|
818
|
+
templateContents: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
|
|
819
|
+
return fetchOpsxTemplateContents(ctx, input?.schema);
|
|
820
|
+
}),
|
|
821
|
+
subscribeTemplateContents: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
|
|
822
|
+
return createReactiveSubscription(() => fetchOpsxTemplateContents(ctx, input?.schema));
|
|
823
|
+
}),
|
|
824
|
+
schemaResolution: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
|
|
825
|
+
return fetchOpsxSchemaResolution(ctx, input.name);
|
|
826
|
+
}),
|
|
827
|
+
subscribeSchemaResolution: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
|
|
828
|
+
return createReactiveSubscription(() => fetchOpsxSchemaResolution(ctx, input.name));
|
|
829
|
+
}),
|
|
830
|
+
schemaDetail: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
|
|
831
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
832
|
+
const schemaPath = join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml");
|
|
833
|
+
const content = await reactiveReadFile(schemaPath);
|
|
834
|
+
if (!content) throw new Error(`schema.yaml not found at ${schemaPath}`);
|
|
835
|
+
return parseSchemaYaml(content);
|
|
836
|
+
}),
|
|
837
|
+
subscribeSchemaDetail: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
|
|
838
|
+
return createReactiveSubscription(async () => {
|
|
839
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
840
|
+
const schemaPath = join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml");
|
|
841
|
+
const content = await reactiveReadFile(schemaPath);
|
|
842
|
+
if (!content) throw new Error(`schema.yaml not found at ${schemaPath}`);
|
|
843
|
+
return parseSchemaYaml(content);
|
|
844
|
+
});
|
|
845
|
+
}),
|
|
846
|
+
schemaFiles: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
|
|
847
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
848
|
+
return readEntriesUnderRoot((await fetchOpsxSchemaResolution(ctx, input.name)).path);
|
|
849
|
+
}),
|
|
850
|
+
subscribeSchemaFiles: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
|
|
851
|
+
return createReactiveSubscription(async () => {
|
|
852
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
853
|
+
return readEntriesUnderRoot((await fetchOpsxSchemaResolution(ctx, input.name)).path);
|
|
854
|
+
});
|
|
855
|
+
}),
|
|
856
|
+
schemaYaml: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
|
|
857
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
858
|
+
return reactiveReadFile(join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml"));
|
|
859
|
+
}),
|
|
860
|
+
subscribeSchemaYaml: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
|
|
861
|
+
return createReactiveSubscription(async () => {
|
|
862
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
863
|
+
return reactiveReadFile(join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml"));
|
|
864
|
+
});
|
|
865
|
+
}),
|
|
866
|
+
writeSchemaYaml: publicProcedure.input(z.object({
|
|
867
|
+
name: z.string(),
|
|
868
|
+
content: z.string()
|
|
869
|
+
})).mutation(async ({ ctx, input }) => {
|
|
870
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
871
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.name);
|
|
872
|
+
ensureEditableSource(resolution.source, "schema.yaml");
|
|
873
|
+
const schemaPath = join(resolution.path, "schema.yaml");
|
|
874
|
+
await mkdir(dirname(schemaPath), { recursive: true });
|
|
875
|
+
await writeFile(schemaPath, input.content, "utf-8");
|
|
876
|
+
return { success: true };
|
|
877
|
+
}),
|
|
878
|
+
writeSchemaFile: publicProcedure.input(z.object({
|
|
879
|
+
schema: z.string(),
|
|
880
|
+
path: z.string(),
|
|
881
|
+
content: z.string()
|
|
882
|
+
})).mutation(async ({ ctx, input }) => {
|
|
883
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
884
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
|
|
885
|
+
ensureEditableSource(resolution.source, "schema file");
|
|
886
|
+
if (!input.path.trim()) throw new Error("path is required");
|
|
887
|
+
const fullPath = resolveEntryPath(resolution.path, input.path);
|
|
888
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
889
|
+
await writeFile(fullPath, input.content, "utf-8");
|
|
890
|
+
return { success: true };
|
|
891
|
+
}),
|
|
892
|
+
createSchemaFile: publicProcedure.input(z.object({
|
|
893
|
+
schema: z.string(),
|
|
894
|
+
path: z.string(),
|
|
895
|
+
content: z.string().optional()
|
|
896
|
+
})).mutation(async ({ ctx, input }) => {
|
|
897
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
898
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
|
|
899
|
+
ensureEditableSource(resolution.source, "schema file");
|
|
900
|
+
if (!input.path.trim()) throw new Error("path is required");
|
|
901
|
+
const fullPath = resolveEntryPath(resolution.path, input.path);
|
|
902
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
903
|
+
await writeFile(fullPath, input.content ?? "", "utf-8");
|
|
904
|
+
return { success: true };
|
|
905
|
+
}),
|
|
906
|
+
createSchemaDirectory: publicProcedure.input(z.object({
|
|
907
|
+
schema: z.string(),
|
|
908
|
+
path: z.string()
|
|
909
|
+
})).mutation(async ({ ctx, input }) => {
|
|
910
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
911
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
|
|
912
|
+
ensureEditableSource(resolution.source, "schema directory");
|
|
913
|
+
if (!input.path.trim()) throw new Error("path is required");
|
|
914
|
+
await mkdir(resolveEntryPath(resolution.path, input.path), { recursive: true });
|
|
915
|
+
return { success: true };
|
|
916
|
+
}),
|
|
917
|
+
deleteSchemaEntry: publicProcedure.input(z.object({
|
|
918
|
+
schema: z.string(),
|
|
919
|
+
path: z.string()
|
|
920
|
+
})).mutation(async ({ ctx, input }) => {
|
|
921
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
922
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
|
|
923
|
+
ensureEditableSource(resolution.source, "schema entry");
|
|
924
|
+
if (!input.path.trim()) throw new Error("path is required");
|
|
925
|
+
const fullPath = resolveEntryPath(resolution.path, input.path);
|
|
926
|
+
if (fullPath === resolve(resolution.path)) throw new Error("cannot delete schema root");
|
|
927
|
+
await rm(fullPath, {
|
|
928
|
+
recursive: true,
|
|
929
|
+
force: true
|
|
930
|
+
});
|
|
931
|
+
return { success: true };
|
|
932
|
+
}),
|
|
933
|
+
templateContent: publicProcedure.input(z.object({
|
|
934
|
+
schema: z.string(),
|
|
935
|
+
artifactId: z.string()
|
|
936
|
+
})).query(async ({ ctx, input }) => {
|
|
937
|
+
const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
|
|
938
|
+
if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
|
|
939
|
+
return {
|
|
940
|
+
content: await reactiveReadFile(info.path),
|
|
941
|
+
path: info.path,
|
|
942
|
+
source: info.source
|
|
943
|
+
};
|
|
944
|
+
}),
|
|
945
|
+
subscribeTemplateContent: publicProcedure.input(z.object({
|
|
946
|
+
schema: z.string(),
|
|
947
|
+
artifactId: z.string()
|
|
948
|
+
})).subscription(({ ctx, input }) => {
|
|
949
|
+
return createReactiveSubscription(async () => {
|
|
950
|
+
const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
|
|
951
|
+
if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
|
|
952
|
+
return {
|
|
953
|
+
content: await reactiveReadFile(info.path),
|
|
954
|
+
path: info.path,
|
|
955
|
+
source: info.source
|
|
956
|
+
};
|
|
957
|
+
});
|
|
958
|
+
}),
|
|
959
|
+
writeTemplateContent: publicProcedure.input(z.object({
|
|
960
|
+
schema: z.string(),
|
|
961
|
+
artifactId: z.string(),
|
|
962
|
+
content: z.string()
|
|
963
|
+
})).mutation(async ({ ctx, input }) => {
|
|
964
|
+
const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
|
|
965
|
+
if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
|
|
966
|
+
ensureEditableSource(info.source, "template");
|
|
967
|
+
await mkdir(dirname(info.path), { recursive: true });
|
|
968
|
+
await writeFile(info.path, input.content, "utf-8");
|
|
969
|
+
return { success: true };
|
|
970
|
+
}),
|
|
971
|
+
deleteSchema: publicProcedure.input(z.object({ name: z.string() })).mutation(async ({ ctx, input }) => {
|
|
972
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
973
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.name);
|
|
974
|
+
ensureEditableSource(resolution.source, "schema");
|
|
975
|
+
await rm(resolution.path, {
|
|
976
|
+
recursive: true,
|
|
977
|
+
force: true
|
|
978
|
+
});
|
|
979
|
+
return { success: true };
|
|
980
|
+
}),
|
|
981
|
+
projectConfig: publicProcedure.query(async ({ ctx }) => {
|
|
982
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "config.yaml"));
|
|
983
|
+
}),
|
|
984
|
+
subscribeProjectConfig: publicProcedure.subscription(({ ctx }) => {
|
|
985
|
+
return createReactiveSubscription(async () => {
|
|
986
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "config.yaml"));
|
|
987
|
+
});
|
|
988
|
+
}),
|
|
989
|
+
writeProjectConfig: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
|
|
990
|
+
const openspecDir = join(ctx.projectDir, "openspec");
|
|
991
|
+
await mkdir(openspecDir, { recursive: true });
|
|
992
|
+
await writeFile(join(openspecDir, "config.yaml"), input.content, "utf-8");
|
|
993
|
+
return { success: true };
|
|
994
|
+
}),
|
|
995
|
+
listChanges: publicProcedure.query(async ({ ctx }) => {
|
|
996
|
+
return reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
|
|
997
|
+
directoriesOnly: true,
|
|
998
|
+
exclude: ["archive"],
|
|
999
|
+
includeHidden: false
|
|
1000
|
+
});
|
|
1001
|
+
}),
|
|
1002
|
+
subscribeChanges: publicProcedure.subscription(({ ctx }) => {
|
|
1003
|
+
return createReactiveSubscription(async () => {
|
|
1004
|
+
return reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
|
|
1005
|
+
directoriesOnly: true,
|
|
1006
|
+
exclude: ["archive"],
|
|
1007
|
+
includeHidden: false
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
}),
|
|
1011
|
+
changeMetadata: publicProcedure.input(z.object({ changeId: z.string() })).query(async ({ ctx, input }) => {
|
|
1012
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, ".openspec.yaml"));
|
|
1013
|
+
}),
|
|
1014
|
+
subscribeChangeMetadata: publicProcedure.input(z.object({ changeId: z.string() })).subscription(({ ctx, input }) => {
|
|
1015
|
+
return createReactiveSubscription(async () => {
|
|
1016
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, ".openspec.yaml"));
|
|
1017
|
+
});
|
|
1018
|
+
}),
|
|
1019
|
+
readArtifactOutput: publicProcedure.input(z.object({
|
|
1020
|
+
changeId: z.string(),
|
|
1021
|
+
outputPath: z.string()
|
|
1022
|
+
})).query(async ({ ctx, input }) => {
|
|
1023
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath));
|
|
1024
|
+
}),
|
|
1025
|
+
subscribeArtifactOutput: publicProcedure.input(z.object({
|
|
1026
|
+
changeId: z.string(),
|
|
1027
|
+
outputPath: z.string()
|
|
1028
|
+
})).subscription(({ ctx, input }) => {
|
|
1029
|
+
return createReactiveSubscription(async () => {
|
|
1030
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath));
|
|
1031
|
+
});
|
|
1032
|
+
}),
|
|
1033
|
+
readGlobArtifactFiles: publicProcedure.input(z.object({
|
|
1034
|
+
changeId: z.string(),
|
|
1035
|
+
outputPath: z.string()
|
|
1036
|
+
})).query(async ({ ctx, input }) => {
|
|
1037
|
+
return readGlobArtifactFiles(ctx.projectDir, input.changeId, input.outputPath);
|
|
1038
|
+
}),
|
|
1039
|
+
subscribeGlobArtifactFiles: publicProcedure.input(z.object({
|
|
1040
|
+
changeId: z.string(),
|
|
1041
|
+
outputPath: z.string()
|
|
1042
|
+
})).subscription(({ ctx, input }) => {
|
|
1043
|
+
return createReactiveSubscription(async () => {
|
|
1044
|
+
return readGlobArtifactFiles(ctx.projectDir, input.changeId, input.outputPath);
|
|
1045
|
+
});
|
|
1046
|
+
}),
|
|
1047
|
+
writeArtifactOutput: publicProcedure.input(z.object({
|
|
1048
|
+
changeId: z.string(),
|
|
1049
|
+
outputPath: z.string(),
|
|
1050
|
+
content: z.string()
|
|
1051
|
+
})).mutation(async ({ ctx, input }) => {
|
|
1052
|
+
await writeFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath), input.content, "utf-8");
|
|
1053
|
+
return { success: true };
|
|
1054
|
+
})
|
|
1055
|
+
});
|
|
1056
|
+
/**
|
|
1057
|
+
* KV router - in-memory reactive key-value store
|
|
1058
|
+
* No disk persistence — devices use IndexedDB for their own storage.
|
|
1059
|
+
*/
|
|
1060
|
+
const kvRouter = router({
|
|
1061
|
+
get: publicProcedure.input(z.object({ key: z.string() })).query(({ input }) => {
|
|
1062
|
+
return reactiveKV.get(input.key) ?? null;
|
|
1063
|
+
}),
|
|
1064
|
+
set: publicProcedure.input(z.object({
|
|
1065
|
+
key: z.string(),
|
|
1066
|
+
value: z.unknown()
|
|
1067
|
+
})).mutation(({ input }) => {
|
|
1068
|
+
reactiveKV.set(input.key, input.value);
|
|
1069
|
+
return { success: true };
|
|
1070
|
+
}),
|
|
1071
|
+
delete: publicProcedure.input(z.object({ key: z.string() })).mutation(({ input }) => {
|
|
1072
|
+
reactiveKV.delete(input.key);
|
|
1073
|
+
return { success: true };
|
|
1074
|
+
}),
|
|
1075
|
+
subscribe: publicProcedure.input(z.object({ key: z.string() })).subscription(({ input }) => {
|
|
1076
|
+
return observable((emit) => {
|
|
1077
|
+
const current = reactiveKV.get(input.key);
|
|
1078
|
+
emit.next(current ?? null);
|
|
1079
|
+
const unsub = reactiveKV.onKey(input.key, (value) => {
|
|
1080
|
+
emit.next(value ?? null);
|
|
1081
|
+
});
|
|
1082
|
+
return () => {
|
|
1083
|
+
unsub();
|
|
1084
|
+
};
|
|
1085
|
+
});
|
|
1086
|
+
})
|
|
1087
|
+
});
|
|
1088
|
+
/**
|
|
504
1089
|
* Main app router
|
|
505
1090
|
*/
|
|
506
1091
|
const appRouter = router({
|
|
507
|
-
dashboard: dashboardRouter,
|
|
508
1092
|
spec: specRouter,
|
|
509
1093
|
change: changeRouter,
|
|
510
1094
|
archive: archiveRouter,
|
|
511
|
-
project: projectRouter,
|
|
512
1095
|
init: initRouter,
|
|
513
1096
|
realtime: realtimeRouter,
|
|
514
1097
|
config: configRouter,
|
|
515
|
-
cli: cliRouter
|
|
1098
|
+
cli: cliRouter,
|
|
1099
|
+
opsx: opsxRouter,
|
|
1100
|
+
kv: kvRouter
|
|
516
1101
|
});
|
|
517
1102
|
|
|
518
1103
|
//#endregion
|
|
@@ -522,13 +1107,13 @@ const appRouter = router({
|
|
|
522
1107
|
* Uses default binding (both IPv4 and IPv6) to detect conflicts.
|
|
523
1108
|
*/
|
|
524
1109
|
function isPortAvailable(port) {
|
|
525
|
-
return new Promise((resolve) => {
|
|
1110
|
+
return new Promise((resolve$1) => {
|
|
526
1111
|
const server = createServer$1();
|
|
527
1112
|
server.once("error", () => {
|
|
528
|
-
resolve(false);
|
|
1113
|
+
resolve$1(false);
|
|
529
1114
|
});
|
|
530
1115
|
server.once("listening", () => {
|
|
531
|
-
server.close(() => resolve(true));
|
|
1116
|
+
server.close(() => resolve$1(true));
|
|
532
1117
|
});
|
|
533
1118
|
server.listen(port);
|
|
534
1119
|
});
|
|
@@ -550,6 +1135,340 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
|
550
1135
|
throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
|
|
551
1136
|
}
|
|
552
1137
|
|
|
1138
|
+
//#endregion
|
|
1139
|
+
//#region src/pty-manager.ts
|
|
1140
|
+
const DEFAULT_SCROLLBACK = 1e3;
|
|
1141
|
+
const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
1142
|
+
function detectPtyPlatform() {
|
|
1143
|
+
if (process.platform === "win32") return "windows";
|
|
1144
|
+
if (process.platform === "darwin") return "macos";
|
|
1145
|
+
return "common";
|
|
1146
|
+
}
|
|
1147
|
+
var PtySession = class extends EventEmitter$1 {
|
|
1148
|
+
id;
|
|
1149
|
+
command;
|
|
1150
|
+
args;
|
|
1151
|
+
platform;
|
|
1152
|
+
createdAt;
|
|
1153
|
+
process;
|
|
1154
|
+
titleInterval = null;
|
|
1155
|
+
lastTitle = "";
|
|
1156
|
+
buffer = [];
|
|
1157
|
+
bufferByteLength = 0;
|
|
1158
|
+
maxBufferLines;
|
|
1159
|
+
maxBufferBytes;
|
|
1160
|
+
isExited = false;
|
|
1161
|
+
exitCode = null;
|
|
1162
|
+
constructor(id, opts) {
|
|
1163
|
+
super();
|
|
1164
|
+
this.id = id;
|
|
1165
|
+
this.createdAt = Date.now();
|
|
1166
|
+
const shell = opts.command ?? process.env.SHELL ?? "/bin/sh";
|
|
1167
|
+
const args = opts.command ? opts.args ?? [] : [];
|
|
1168
|
+
this.command = shell;
|
|
1169
|
+
this.args = args;
|
|
1170
|
+
this.platform = opts.platform;
|
|
1171
|
+
this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
|
|
1172
|
+
this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
|
|
1173
|
+
this.process = pty.spawn(shell, args, {
|
|
1174
|
+
name: "xterm-256color",
|
|
1175
|
+
cols: opts.cols ?? 80,
|
|
1176
|
+
rows: opts.rows ?? 24,
|
|
1177
|
+
cwd: opts.cwd,
|
|
1178
|
+
env: {
|
|
1179
|
+
...process.env,
|
|
1180
|
+
TERM: "xterm-256color"
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
this.process.onData((data) => {
|
|
1184
|
+
this.appendBuffer(data);
|
|
1185
|
+
this.emit("data", data);
|
|
1186
|
+
});
|
|
1187
|
+
this.process.onExit(({ exitCode }) => {
|
|
1188
|
+
if (this.titleInterval) {
|
|
1189
|
+
clearInterval(this.titleInterval);
|
|
1190
|
+
this.titleInterval = null;
|
|
1191
|
+
}
|
|
1192
|
+
this.isExited = true;
|
|
1193
|
+
this.exitCode = exitCode;
|
|
1194
|
+
this.emit("exit", exitCode);
|
|
1195
|
+
});
|
|
1196
|
+
this.titleInterval = setInterval(() => {
|
|
1197
|
+
try {
|
|
1198
|
+
const title = this.process.process;
|
|
1199
|
+
if (title && title !== this.lastTitle) {
|
|
1200
|
+
this.lastTitle = title;
|
|
1201
|
+
this.emit("title", title);
|
|
1202
|
+
}
|
|
1203
|
+
} catch {}
|
|
1204
|
+
}, 1e3);
|
|
1205
|
+
}
|
|
1206
|
+
get title() {
|
|
1207
|
+
return this.lastTitle;
|
|
1208
|
+
}
|
|
1209
|
+
appendBuffer(data) {
|
|
1210
|
+
let chunk = data;
|
|
1211
|
+
if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
|
|
1212
|
+
this.buffer.push(chunk);
|
|
1213
|
+
this.bufferByteLength += chunk.length;
|
|
1214
|
+
while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
|
|
1215
|
+
const removed = this.buffer.shift();
|
|
1216
|
+
this.bufferByteLength -= removed.length;
|
|
1217
|
+
}
|
|
1218
|
+
while (this.buffer.length > this.maxBufferLines) {
|
|
1219
|
+
const removed = this.buffer.shift();
|
|
1220
|
+
this.bufferByteLength -= removed.length;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
getBuffer() {
|
|
1224
|
+
return this.buffer.join("");
|
|
1225
|
+
}
|
|
1226
|
+
write(data) {
|
|
1227
|
+
if (!this.isExited) this.process.write(data);
|
|
1228
|
+
}
|
|
1229
|
+
resize(cols, rows) {
|
|
1230
|
+
if (!this.isExited) this.process.resize(cols, rows);
|
|
1231
|
+
}
|
|
1232
|
+
close() {
|
|
1233
|
+
if (this.titleInterval) {
|
|
1234
|
+
clearInterval(this.titleInterval);
|
|
1235
|
+
this.titleInterval = null;
|
|
1236
|
+
}
|
|
1237
|
+
try {
|
|
1238
|
+
this.process.kill();
|
|
1239
|
+
} catch {}
|
|
1240
|
+
this.removeAllListeners();
|
|
1241
|
+
}
|
|
1242
|
+
toInfo() {
|
|
1243
|
+
return {
|
|
1244
|
+
id: this.id,
|
|
1245
|
+
title: this.lastTitle,
|
|
1246
|
+
command: this.command,
|
|
1247
|
+
args: this.args,
|
|
1248
|
+
platform: this.platform,
|
|
1249
|
+
isExited: this.isExited,
|
|
1250
|
+
exitCode: this.exitCode,
|
|
1251
|
+
createdAt: this.createdAt
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
var PtyManager = class {
|
|
1256
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1257
|
+
idCounter = 0;
|
|
1258
|
+
platform;
|
|
1259
|
+
constructor(defaultCwd) {
|
|
1260
|
+
this.defaultCwd = defaultCwd;
|
|
1261
|
+
this.platform = detectPtyPlatform();
|
|
1262
|
+
}
|
|
1263
|
+
create(opts) {
|
|
1264
|
+
const id = `pty-${++this.idCounter}`;
|
|
1265
|
+
const session = new PtySession(id, {
|
|
1266
|
+
cols: opts.cols,
|
|
1267
|
+
rows: opts.rows,
|
|
1268
|
+
command: opts.command,
|
|
1269
|
+
args: opts.args,
|
|
1270
|
+
cwd: this.defaultCwd,
|
|
1271
|
+
scrollback: opts.scrollback,
|
|
1272
|
+
maxBufferBytes: opts.maxBufferBytes,
|
|
1273
|
+
platform: this.platform
|
|
1274
|
+
});
|
|
1275
|
+
this.sessions.set(id, session);
|
|
1276
|
+
return session;
|
|
1277
|
+
}
|
|
1278
|
+
get(id) {
|
|
1279
|
+
return this.sessions.get(id);
|
|
1280
|
+
}
|
|
1281
|
+
list() {
|
|
1282
|
+
const result = [];
|
|
1283
|
+
for (const session of this.sessions.values()) result.push(session.toInfo());
|
|
1284
|
+
return result;
|
|
1285
|
+
}
|
|
1286
|
+
write(id, data) {
|
|
1287
|
+
this.sessions.get(id)?.write(data);
|
|
1288
|
+
}
|
|
1289
|
+
resize(id, cols, rows) {
|
|
1290
|
+
this.sessions.get(id)?.resize(cols, rows);
|
|
1291
|
+
}
|
|
1292
|
+
close(id) {
|
|
1293
|
+
const session = this.sessions.get(id);
|
|
1294
|
+
if (session) {
|
|
1295
|
+
session.close();
|
|
1296
|
+
this.sessions.delete(id);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
closeAll() {
|
|
1300
|
+
for (const session of this.sessions.values()) session.close();
|
|
1301
|
+
this.sessions.clear();
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
//#endregion
|
|
1306
|
+
//#region src/pty-websocket.ts
|
|
1307
|
+
function createPtyWebSocketHandler(ptyManager) {
|
|
1308
|
+
return (ws) => {
|
|
1309
|
+
const cleanups = /* @__PURE__ */ new Map();
|
|
1310
|
+
const send = (msg) => {
|
|
1311
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
|
|
1312
|
+
};
|
|
1313
|
+
const sendError = (code, message, opts) => {
|
|
1314
|
+
send({
|
|
1315
|
+
type: "error",
|
|
1316
|
+
code,
|
|
1317
|
+
message,
|
|
1318
|
+
sessionId: opts?.sessionId
|
|
1319
|
+
});
|
|
1320
|
+
};
|
|
1321
|
+
const attachToSession = (session, opts) => {
|
|
1322
|
+
const sessionId = session.id;
|
|
1323
|
+
cleanups.get(sessionId)?.();
|
|
1324
|
+
if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
|
|
1325
|
+
const onData = (data) => {
|
|
1326
|
+
send({
|
|
1327
|
+
type: "output",
|
|
1328
|
+
sessionId,
|
|
1329
|
+
data
|
|
1330
|
+
});
|
|
1331
|
+
};
|
|
1332
|
+
const onExit = (exitCode) => {
|
|
1333
|
+
send({
|
|
1334
|
+
type: "exit",
|
|
1335
|
+
sessionId,
|
|
1336
|
+
exitCode
|
|
1337
|
+
});
|
|
1338
|
+
};
|
|
1339
|
+
const onTitle = (title) => {
|
|
1340
|
+
send({
|
|
1341
|
+
type: "title",
|
|
1342
|
+
sessionId,
|
|
1343
|
+
title
|
|
1344
|
+
});
|
|
1345
|
+
};
|
|
1346
|
+
session.on("data", onData);
|
|
1347
|
+
session.on("exit", onExit);
|
|
1348
|
+
session.on("title", onTitle);
|
|
1349
|
+
cleanups.set(sessionId, () => {
|
|
1350
|
+
session.removeListener("data", onData);
|
|
1351
|
+
session.removeListener("exit", onExit);
|
|
1352
|
+
session.removeListener("title", onTitle);
|
|
1353
|
+
cleanups.delete(sessionId);
|
|
1354
|
+
});
|
|
1355
|
+
};
|
|
1356
|
+
ws.on("message", (raw) => {
|
|
1357
|
+
let parsed;
|
|
1358
|
+
try {
|
|
1359
|
+
parsed = JSON.parse(String(raw));
|
|
1360
|
+
} catch {
|
|
1361
|
+
sendError("INVALID_JSON", "Invalid JSON payload");
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
|
|
1365
|
+
if (!parsedMessage.success) {
|
|
1366
|
+
const firstIssue = parsedMessage.error.issues[0]?.message;
|
|
1367
|
+
sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
const msg = parsedMessage.data;
|
|
1371
|
+
switch (msg.type) {
|
|
1372
|
+
case "create": {
|
|
1373
|
+
const session = ptyManager.create({
|
|
1374
|
+
cols: msg.cols,
|
|
1375
|
+
rows: msg.rows,
|
|
1376
|
+
command: msg.command,
|
|
1377
|
+
args: msg.args
|
|
1378
|
+
});
|
|
1379
|
+
send({
|
|
1380
|
+
type: "created",
|
|
1381
|
+
requestId: msg.requestId,
|
|
1382
|
+
sessionId: session.id,
|
|
1383
|
+
platform: session.platform
|
|
1384
|
+
});
|
|
1385
|
+
attachToSession(session);
|
|
1386
|
+
break;
|
|
1387
|
+
}
|
|
1388
|
+
case "attach": {
|
|
1389
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1390
|
+
if (!session) {
|
|
1391
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1392
|
+
send({
|
|
1393
|
+
type: "exit",
|
|
1394
|
+
sessionId: msg.sessionId,
|
|
1395
|
+
exitCode: -1
|
|
1396
|
+
});
|
|
1397
|
+
break;
|
|
1398
|
+
}
|
|
1399
|
+
attachToSession(session, {
|
|
1400
|
+
cols: msg.cols,
|
|
1401
|
+
rows: msg.rows
|
|
1402
|
+
});
|
|
1403
|
+
const buffer = session.getBuffer();
|
|
1404
|
+
if (buffer) send({
|
|
1405
|
+
type: "buffer",
|
|
1406
|
+
sessionId: session.id,
|
|
1407
|
+
data: buffer
|
|
1408
|
+
});
|
|
1409
|
+
if (session.title) send({
|
|
1410
|
+
type: "title",
|
|
1411
|
+
sessionId: session.id,
|
|
1412
|
+
title: session.title
|
|
1413
|
+
});
|
|
1414
|
+
if (session.isExited) send({
|
|
1415
|
+
type: "exit",
|
|
1416
|
+
sessionId: session.id,
|
|
1417
|
+
exitCode: session.exitCode ?? -1
|
|
1418
|
+
});
|
|
1419
|
+
break;
|
|
1420
|
+
}
|
|
1421
|
+
case "list":
|
|
1422
|
+
send({
|
|
1423
|
+
type: "list",
|
|
1424
|
+
sessions: ptyManager.list().map((s) => ({
|
|
1425
|
+
id: s.id,
|
|
1426
|
+
title: s.title,
|
|
1427
|
+
command: s.command,
|
|
1428
|
+
args: s.args,
|
|
1429
|
+
platform: s.platform,
|
|
1430
|
+
isExited: s.isExited,
|
|
1431
|
+
exitCode: s.exitCode
|
|
1432
|
+
}))
|
|
1433
|
+
});
|
|
1434
|
+
break;
|
|
1435
|
+
case "input": {
|
|
1436
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1437
|
+
if (!session) {
|
|
1438
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1439
|
+
break;
|
|
1440
|
+
}
|
|
1441
|
+
session.write(msg.data);
|
|
1442
|
+
break;
|
|
1443
|
+
}
|
|
1444
|
+
case "resize": {
|
|
1445
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1446
|
+
if (!session) {
|
|
1447
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1448
|
+
break;
|
|
1449
|
+
}
|
|
1450
|
+
session.resize(msg.cols, msg.rows);
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
case "close": {
|
|
1454
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1455
|
+
if (!session) {
|
|
1456
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1457
|
+
break;
|
|
1458
|
+
}
|
|
1459
|
+
cleanups.get(msg.sessionId)?.();
|
|
1460
|
+
ptyManager.close(session.id);
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
ws.on("close", () => {
|
|
1466
|
+
for (const cleanup of cleanups.values()) cleanup();
|
|
1467
|
+
cleanups.clear();
|
|
1468
|
+
});
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
|
|
553
1472
|
//#endregion
|
|
554
1473
|
//#region src/server.ts
|
|
555
1474
|
/**
|
|
@@ -571,6 +1490,7 @@ function createServer(config) {
|
|
|
571
1490
|
const adapter = new OpenSpecAdapter(config.projectDir);
|
|
572
1491
|
const configManager = new ConfigManager(config.projectDir);
|
|
573
1492
|
const cliExecutor = new CliExecutor(configManager, config.projectDir);
|
|
1493
|
+
const kernel = config.kernel;
|
|
574
1494
|
const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
|
|
575
1495
|
const app = new Hono();
|
|
576
1496
|
const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
|
|
@@ -594,6 +1514,7 @@ function createServer(config) {
|
|
|
594
1514
|
adapter,
|
|
595
1515
|
configManager,
|
|
596
1516
|
cliExecutor,
|
|
1517
|
+
kernel,
|
|
597
1518
|
watcher,
|
|
598
1519
|
projectDir: config.projectDir
|
|
599
1520
|
})
|
|
@@ -603,6 +1524,7 @@ function createServer(config) {
|
|
|
603
1524
|
adapter,
|
|
604
1525
|
configManager,
|
|
605
1526
|
cliExecutor,
|
|
1527
|
+
kernel,
|
|
606
1528
|
watcher,
|
|
607
1529
|
projectDir: config.projectDir
|
|
608
1530
|
});
|
|
@@ -611,34 +1533,46 @@ function createServer(config) {
|
|
|
611
1533
|
adapter,
|
|
612
1534
|
configManager,
|
|
613
1535
|
cliExecutor,
|
|
1536
|
+
kernel,
|
|
614
1537
|
watcher,
|
|
615
1538
|
createContext,
|
|
616
1539
|
port: config.port ?? 3100
|
|
617
1540
|
};
|
|
618
1541
|
}
|
|
619
1542
|
/**
|
|
620
|
-
* Create WebSocket server for tRPC subscriptions
|
|
1543
|
+
* Create WebSocket server for tRPC subscriptions and PTY terminals
|
|
621
1544
|
*/
|
|
622
1545
|
async function createWebSocketServer(server, httpServer, config) {
|
|
623
|
-
await initWatcherPool(config.projectDir);
|
|
1546
|
+
if (!isWatcherPoolInitialized()) await initWatcherPool(config.projectDir);
|
|
624
1547
|
const wss = new WebSocketServer({ noServer: true });
|
|
625
1548
|
const handler = applyWSSHandler({
|
|
626
1549
|
wss,
|
|
627
1550
|
router: appRouter,
|
|
628
1551
|
createContext: server.createContext
|
|
629
1552
|
});
|
|
1553
|
+
const ptyManager = new PtyManager(config.projectDir);
|
|
1554
|
+
const ptyWss = new WebSocketServer({ noServer: true });
|
|
1555
|
+
const ptyHandler = createPtyWebSocketHandler(ptyManager);
|
|
1556
|
+
ptyWss.on("connection", ptyHandler);
|
|
630
1557
|
httpServer.on("upgrade", (...args) => {
|
|
631
1558
|
const [request, socket, head] = args;
|
|
632
|
-
if (request.url?.startsWith("/
|
|
1559
|
+
if (request.url?.startsWith("/ws/pty")) ptyWss.handleUpgrade(request, socket, head, (ws) => {
|
|
1560
|
+
ptyWss.emit("connection", ws, request);
|
|
1561
|
+
});
|
|
1562
|
+
else if (request.url?.startsWith("/trpc")) wss.handleUpgrade(request, socket, head, (ws) => {
|
|
633
1563
|
wss.emit("connection", ws, request);
|
|
634
1564
|
});
|
|
635
1565
|
});
|
|
636
1566
|
server.watcher?.start();
|
|
637
1567
|
return {
|
|
638
1568
|
wss,
|
|
1569
|
+
ptyWss,
|
|
1570
|
+
ptyManager,
|
|
639
1571
|
handler,
|
|
640
1572
|
close: () => {
|
|
641
1573
|
handler.broadcastReconnectNotification();
|
|
1574
|
+
ptyManager.closeAll();
|
|
1575
|
+
ptyWss.close();
|
|
642
1576
|
wss.close();
|
|
643
1577
|
server.watcher?.stop();
|
|
644
1578
|
}
|
|
@@ -655,9 +1589,13 @@ async function createWebSocketServer(server, httpServer, config) {
|
|
|
655
1589
|
async function startServer(config, setupApp) {
|
|
656
1590
|
const preferredPort = config.port ?? 3100;
|
|
657
1591
|
const port = await findAvailablePort(preferredPort);
|
|
1592
|
+
const cliExecutor = new CliExecutor(new ConfigManager(config.projectDir), config.projectDir);
|
|
1593
|
+
const kernel = new OpsxKernel(config.projectDir, cliExecutor);
|
|
1594
|
+
await initWatcherPool(config.projectDir);
|
|
658
1595
|
const server = createServer({
|
|
659
1596
|
...config,
|
|
660
|
-
port
|
|
1597
|
+
port,
|
|
1598
|
+
kernel
|
|
661
1599
|
});
|
|
662
1600
|
if (setupApp) setupApp(server.app);
|
|
663
1601
|
const httpServer = serve({
|
|
@@ -665,11 +1603,16 @@ async function startServer(config, setupApp) {
|
|
|
665
1603
|
port
|
|
666
1604
|
});
|
|
667
1605
|
const wsServer = await createWebSocketServer(server, httpServer, { projectDir: config.projectDir });
|
|
1606
|
+
const url = `http://localhost:${port}`;
|
|
1607
|
+
kernel.warmup().catch((err) => {
|
|
1608
|
+
console.error("Kernel warmup failed:", err);
|
|
1609
|
+
});
|
|
668
1610
|
return {
|
|
669
|
-
url
|
|
1611
|
+
url,
|
|
670
1612
|
port,
|
|
671
1613
|
preferredPort,
|
|
672
1614
|
close: async () => {
|
|
1615
|
+
kernel.dispose();
|
|
673
1616
|
wsServer.close();
|
|
674
1617
|
httpServer.close();
|
|
675
1618
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openspecui/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.mjs",
|
|
6
6
|
"exports": {
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
],
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@hono/node-server": "^1.14.1",
|
|
16
|
+
"@lydell/node-pty": "^1.1.0",
|
|
16
17
|
"@trpc/server": "^11.0.0",
|
|
17
18
|
"hono": "^4.7.3",
|
|
18
19
|
"ws": "^8.18.0",
|
|
20
|
+
"yaml": "^2.8.0",
|
|
19
21
|
"yargs": "^18.0.0",
|
|
20
22
|
"zod": "^3.24.1",
|
|
21
|
-
"@openspecui/core": "0.
|
|
23
|
+
"@openspecui/core": "1.0.2"
|
|
22
24
|
},
|
|
23
25
|
"devDependencies": {
|
|
24
26
|
"@types/node": "^22.10.2",
|
|
@@ -31,8 +33,8 @@
|
|
|
31
33
|
},
|
|
32
34
|
"scripts": {
|
|
33
35
|
"build": "tsdown src/index.ts --format esm --no-dts",
|
|
34
|
-
"typecheck": "tsc --noEmit
|
|
35
|
-
"dev": "tsx watch src/standalone.ts",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"dev": "tsx watch --include '../core/dist/**' src/standalone.ts",
|
|
36
38
|
"test": "vitest run",
|
|
37
39
|
"test:watch": "vitest"
|
|
38
40
|
}
|