@openspecui/server 0.9.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/dist/index.mjs +680 -0
- package/package.json +39 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { cors } from "hono/cors";
|
|
4
|
+
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
|
5
|
+
import { applyWSSHandler } from "@trpc/server/adapters/ws";
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
import { CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, ReactiveContext, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, initWatcherPool, sniffGlobalCli } from "@openspecui/core";
|
|
8
|
+
import { initTRPC } from "@trpc/server";
|
|
9
|
+
import { observable } from "@trpc/server/observable";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { createServer as createServer$1 } from "node:net";
|
|
12
|
+
|
|
13
|
+
//#region src/reactive-subscription.ts
|
|
14
|
+
/**
|
|
15
|
+
* 创建响应式订阅
|
|
16
|
+
*
|
|
17
|
+
* 自动追踪 task 中的文件依赖,当依赖变更时自动重新执行并推送新数据。
|
|
18
|
+
*
|
|
19
|
+
* @param task 要执行的异步任务,内部的文件读取会被自动追踪
|
|
20
|
+
* @returns tRPC observable
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* // 在 router 中使用
|
|
25
|
+
* subscribe: publicProcedure.subscription(({ ctx }) => {
|
|
26
|
+
* return createReactiveSubscription(() => ctx.adapter.listSpecsWithMeta())
|
|
27
|
+
* })
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
function createReactiveSubscription(task) {
|
|
31
|
+
return observable((emit) => {
|
|
32
|
+
const context = new ReactiveContext();
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
(async () => {
|
|
35
|
+
try {
|
|
36
|
+
for await (const data of context.stream(task, controller.signal)) emit.next(data);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (!controller.signal.aborted) emit.error(err);
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
return () => {
|
|
42
|
+
controller.abort();
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 创建带输入参数的响应式订阅
|
|
48
|
+
*
|
|
49
|
+
* @param task 接收输入参数的异步任务
|
|
50
|
+
* @returns 返回一个函数,接收输入参数并返回 tRPC observable
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* // 在 router 中使用
|
|
55
|
+
* subscribeOne: publicProcedure
|
|
56
|
+
* .input(z.object({ id: z.string() }))
|
|
57
|
+
* .subscription(({ ctx, input }) => {
|
|
58
|
+
* return createReactiveSubscriptionWithInput(
|
|
59
|
+
* (id: string) => ctx.adapter.readSpec(id)
|
|
60
|
+
* )(input.id)
|
|
61
|
+
* })
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
function createReactiveSubscriptionWithInput(task) {
|
|
65
|
+
return (input) => {
|
|
66
|
+
return createReactiveSubscription(() => task(input));
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
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
|
+
//#endregion
|
|
128
|
+
//#region src/router.ts
|
|
129
|
+
const t = initTRPC.context().create();
|
|
130
|
+
const router = t.router;
|
|
131
|
+
const publicProcedure = t.procedure;
|
|
132
|
+
/**
|
|
133
|
+
* Dashboard router - overview and status
|
|
134
|
+
*/
|
|
135
|
+
const dashboardRouter = router({
|
|
136
|
+
getData: publicProcedure.query(async ({ ctx }) => {
|
|
137
|
+
return ctx.adapter.getDashboardData();
|
|
138
|
+
}),
|
|
139
|
+
isInitialized: publicProcedure.query(async ({ ctx }) => {
|
|
140
|
+
return ctx.adapter.isInitialized();
|
|
141
|
+
}),
|
|
142
|
+
subscribe: publicProcedure.subscription(({ ctx }) => {
|
|
143
|
+
return createReactiveSubscription(() => ctx.adapter.getDashboardData());
|
|
144
|
+
}),
|
|
145
|
+
subscribeInitialized: publicProcedure.subscription(({ ctx }) => {
|
|
146
|
+
return createReactiveSubscription(() => ctx.adapter.isInitialized());
|
|
147
|
+
})
|
|
148
|
+
});
|
|
149
|
+
/**
|
|
150
|
+
* Spec router - spec CRUD operations
|
|
151
|
+
*/
|
|
152
|
+
const specRouter = router({
|
|
153
|
+
list: publicProcedure.query(async ({ ctx }) => {
|
|
154
|
+
return ctx.adapter.listSpecs();
|
|
155
|
+
}),
|
|
156
|
+
listWithMeta: publicProcedure.query(async ({ ctx }) => {
|
|
157
|
+
return ctx.adapter.listSpecsWithMeta();
|
|
158
|
+
}),
|
|
159
|
+
get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
|
160
|
+
return ctx.adapter.readSpec(input.id);
|
|
161
|
+
}),
|
|
162
|
+
getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
|
163
|
+
return ctx.adapter.readSpecRaw(input.id);
|
|
164
|
+
}),
|
|
165
|
+
save: publicProcedure.input(z.object({
|
|
166
|
+
id: z.string(),
|
|
167
|
+
content: z.string()
|
|
168
|
+
})).mutation(async ({ ctx, input }) => {
|
|
169
|
+
await ctx.adapter.writeSpec(input.id, input.content);
|
|
170
|
+
return { success: true };
|
|
171
|
+
}),
|
|
172
|
+
validate: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
|
173
|
+
return ctx.adapter.validateSpec(input.id);
|
|
174
|
+
}),
|
|
175
|
+
subscribe: publicProcedure.subscription(({ ctx }) => {
|
|
176
|
+
return createReactiveSubscription(() => ctx.adapter.listSpecsWithMeta());
|
|
177
|
+
}),
|
|
178
|
+
subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
|
|
179
|
+
return createReactiveSubscriptionWithInput((id) => ctx.adapter.readSpec(id))(input.id);
|
|
180
|
+
}),
|
|
181
|
+
subscribeRaw: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
|
|
182
|
+
return createReactiveSubscriptionWithInput((id) => ctx.adapter.readSpecRaw(id))(input.id);
|
|
183
|
+
})
|
|
184
|
+
});
|
|
185
|
+
/**
|
|
186
|
+
* Change router - change proposal operations
|
|
187
|
+
*/
|
|
188
|
+
const changeRouter = router({
|
|
189
|
+
list: publicProcedure.query(async ({ ctx }) => {
|
|
190
|
+
return ctx.adapter.listChanges();
|
|
191
|
+
}),
|
|
192
|
+
listWithMeta: publicProcedure.query(async ({ ctx }) => {
|
|
193
|
+
return ctx.adapter.listChangesWithMeta();
|
|
194
|
+
}),
|
|
195
|
+
listArchived: publicProcedure.query(async ({ ctx }) => {
|
|
196
|
+
return ctx.adapter.listArchivedChanges();
|
|
197
|
+
}),
|
|
198
|
+
get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
|
199
|
+
return ctx.adapter.readChange(input.id);
|
|
200
|
+
}),
|
|
201
|
+
getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
|
202
|
+
return ctx.adapter.readChangeRaw(input.id);
|
|
203
|
+
}),
|
|
204
|
+
save: publicProcedure.input(z.object({
|
|
205
|
+
id: z.string(),
|
|
206
|
+
proposal: z.string(),
|
|
207
|
+
tasks: z.string().optional()
|
|
208
|
+
})).mutation(async ({ ctx, input }) => {
|
|
209
|
+
await ctx.adapter.writeChange(input.id, input.proposal, input.tasks);
|
|
210
|
+
return { success: true };
|
|
211
|
+
}),
|
|
212
|
+
archive: publicProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
|
213
|
+
return ctx.adapter.archiveChange(input.id);
|
|
214
|
+
}),
|
|
215
|
+
validate: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
|
216
|
+
return ctx.adapter.validateChange(input.id);
|
|
217
|
+
}),
|
|
218
|
+
toggleTask: publicProcedure.input(z.object({
|
|
219
|
+
changeId: z.string(),
|
|
220
|
+
taskIndex: z.number().int().positive(),
|
|
221
|
+
completed: z.boolean()
|
|
222
|
+
})).mutation(async ({ ctx, input }) => {
|
|
223
|
+
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
|
+
return { success: true };
|
|
225
|
+
}),
|
|
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
|
+
subscribeFiles: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
|
|
233
|
+
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
|
+
})
|
|
238
|
+
});
|
|
239
|
+
/**
|
|
240
|
+
* Init router - project initialization
|
|
241
|
+
*/
|
|
242
|
+
const initRouter = router({ init: publicProcedure.mutation(async ({ ctx }) => {
|
|
243
|
+
await ctx.adapter.init();
|
|
244
|
+
return { success: true };
|
|
245
|
+
}) });
|
|
246
|
+
/**
|
|
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
|
+
* Archive router - archived changes
|
|
273
|
+
*/
|
|
274
|
+
const archiveRouter = router({
|
|
275
|
+
list: publicProcedure.query(async ({ ctx }) => {
|
|
276
|
+
return ctx.adapter.listArchivedChanges();
|
|
277
|
+
}),
|
|
278
|
+
listWithMeta: publicProcedure.query(async ({ ctx }) => {
|
|
279
|
+
return ctx.adapter.listArchivedChangesWithMeta();
|
|
280
|
+
}),
|
|
281
|
+
get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
|
282
|
+
return ctx.adapter.readArchivedChange(input.id);
|
|
283
|
+
}),
|
|
284
|
+
getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
|
285
|
+
return ctx.adapter.readArchivedChangeRaw(input.id);
|
|
286
|
+
}),
|
|
287
|
+
subscribe: publicProcedure.subscription(({ ctx }) => {
|
|
288
|
+
return createReactiveSubscription(() => ctx.adapter.listArchivedChangesWithMeta());
|
|
289
|
+
}),
|
|
290
|
+
subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
|
|
291
|
+
return createReactiveSubscriptionWithInput((id) => ctx.adapter.readArchivedChange(id))(input.id);
|
|
292
|
+
}),
|
|
293
|
+
subscribeFiles: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
|
|
294
|
+
return createReactiveSubscriptionWithInput((id) => ctx.adapter.readArchivedChangeFiles(id))(input.id);
|
|
295
|
+
})
|
|
296
|
+
});
|
|
297
|
+
z.object({
|
|
298
|
+
type: z.enum([
|
|
299
|
+
"spec",
|
|
300
|
+
"change",
|
|
301
|
+
"archive",
|
|
302
|
+
"project"
|
|
303
|
+
]),
|
|
304
|
+
action: z.enum([
|
|
305
|
+
"create",
|
|
306
|
+
"update",
|
|
307
|
+
"delete"
|
|
308
|
+
]),
|
|
309
|
+
id: z.string().optional(),
|
|
310
|
+
path: z.string(),
|
|
311
|
+
timestamp: z.number()
|
|
312
|
+
});
|
|
313
|
+
/**
|
|
314
|
+
* Realtime router - file change subscriptions
|
|
315
|
+
*/
|
|
316
|
+
const realtimeRouter = router({
|
|
317
|
+
onFileChange: publicProcedure.subscription(({ ctx }) => {
|
|
318
|
+
return observable((emit) => {
|
|
319
|
+
if (!ctx.watcher) {
|
|
320
|
+
emit.error(/* @__PURE__ */ new Error("File watcher not available"));
|
|
321
|
+
return () => {};
|
|
322
|
+
}
|
|
323
|
+
const handler = (event) => {
|
|
324
|
+
emit.next(event);
|
|
325
|
+
};
|
|
326
|
+
ctx.watcher.on("change", handler);
|
|
327
|
+
return () => {
|
|
328
|
+
ctx.watcher?.off("change", handler);
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
}),
|
|
332
|
+
onSpecChange: publicProcedure.input(z.object({ specId: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
|
|
333
|
+
return observable((emit) => {
|
|
334
|
+
if (!ctx.watcher) {
|
|
335
|
+
emit.error(/* @__PURE__ */ new Error("File watcher not available"));
|
|
336
|
+
return () => {};
|
|
337
|
+
}
|
|
338
|
+
const handler = (event) => {
|
|
339
|
+
if (event.type !== "spec") return;
|
|
340
|
+
if (input?.specId && event.id !== input.specId) return;
|
|
341
|
+
emit.next(event);
|
|
342
|
+
};
|
|
343
|
+
ctx.watcher.on("change", handler);
|
|
344
|
+
return () => {
|
|
345
|
+
ctx.watcher?.off("change", handler);
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
}),
|
|
349
|
+
onChangeChange: publicProcedure.input(z.object({ changeId: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
|
|
350
|
+
return observable((emit) => {
|
|
351
|
+
if (!ctx.watcher) {
|
|
352
|
+
emit.error(/* @__PURE__ */ new Error("File watcher not available"));
|
|
353
|
+
return () => {};
|
|
354
|
+
}
|
|
355
|
+
const handler = (event) => {
|
|
356
|
+
if (event.type !== "change" && event.type !== "archive") return;
|
|
357
|
+
if (input?.changeId && event.id !== input.changeId) return;
|
|
358
|
+
emit.next(event);
|
|
359
|
+
};
|
|
360
|
+
ctx.watcher.on("change", handler);
|
|
361
|
+
return () => {
|
|
362
|
+
ctx.watcher?.off("change", handler);
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
})
|
|
366
|
+
});
|
|
367
|
+
/**
|
|
368
|
+
* Config router - configuration management
|
|
369
|
+
*/
|
|
370
|
+
const configRouter = router({
|
|
371
|
+
get: publicProcedure.query(async ({ ctx }) => {
|
|
372
|
+
return ctx.configManager.readConfig();
|
|
373
|
+
}),
|
|
374
|
+
getEffectiveCliCommand: publicProcedure.query(async ({ ctx }) => {
|
|
375
|
+
return ctx.configManager.getCliCommandString();
|
|
376
|
+
}),
|
|
377
|
+
getDefaultCliCommand: publicProcedure.query(async () => {
|
|
378
|
+
return getDefaultCliCommandString();
|
|
379
|
+
}),
|
|
380
|
+
update: publicProcedure.input(z.object({
|
|
381
|
+
cli: z.object({ command: z.string() }).optional(),
|
|
382
|
+
ui: z.object({ theme: z.enum([
|
|
383
|
+
"light",
|
|
384
|
+
"dark",
|
|
385
|
+
"system"
|
|
386
|
+
]) }).optional()
|
|
387
|
+
})).mutation(async ({ ctx, input }) => {
|
|
388
|
+
await ctx.configManager.writeConfig(input);
|
|
389
|
+
return { success: true };
|
|
390
|
+
}),
|
|
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
|
+
subscribe: publicProcedure.subscription(({ ctx }) => {
|
|
396
|
+
return createReactiveSubscription(() => ctx.configManager.readConfig());
|
|
397
|
+
})
|
|
398
|
+
});
|
|
399
|
+
/**
|
|
400
|
+
* CLI router - execute external openspec CLI commands
|
|
401
|
+
*/
|
|
402
|
+
const cliRouter = router({
|
|
403
|
+
checkAvailability: publicProcedure.query(async ({ ctx }) => {
|
|
404
|
+
return ctx.cliExecutor.checkAvailability();
|
|
405
|
+
}),
|
|
406
|
+
sniffGlobalCli: publicProcedure.query(async () => {
|
|
407
|
+
return sniffGlobalCli();
|
|
408
|
+
}),
|
|
409
|
+
installGlobalCliStream: publicProcedure.subscription(({ ctx }) => {
|
|
410
|
+
return observable((emit) => {
|
|
411
|
+
const cancel = ctx.cliExecutor.executeCommandStream([
|
|
412
|
+
"npm",
|
|
413
|
+
"install",
|
|
414
|
+
"-g",
|
|
415
|
+
"@fission-ai/openspec"
|
|
416
|
+
], (event) => {
|
|
417
|
+
emit.next(event);
|
|
418
|
+
if (event.type === "exit") emit.complete();
|
|
419
|
+
});
|
|
420
|
+
return () => {
|
|
421
|
+
cancel();
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
}),
|
|
425
|
+
runCommandStream: publicProcedure.input(z.object({
|
|
426
|
+
command: z.string(),
|
|
427
|
+
args: z.array(z.string()).default([])
|
|
428
|
+
})).subscription(({ ctx, input }) => {
|
|
429
|
+
return createCliStreamObservable(async (onEvent) => ctx.cliExecutor.executeCommandStream([input.command, ...input.args], onEvent));
|
|
430
|
+
}),
|
|
431
|
+
getAvailableTools: publicProcedure.query(() => {
|
|
432
|
+
return getAvailableTools().map((tool) => ({
|
|
433
|
+
name: tool.name,
|
|
434
|
+
value: tool.value,
|
|
435
|
+
available: tool.available,
|
|
436
|
+
successLabel: tool.successLabel
|
|
437
|
+
}));
|
|
438
|
+
}),
|
|
439
|
+
getAllTools: publicProcedure.query(() => {
|
|
440
|
+
return getAllTools().map((tool) => ({
|
|
441
|
+
name: tool.name,
|
|
442
|
+
value: tool.value,
|
|
443
|
+
available: tool.available,
|
|
444
|
+
successLabel: tool.successLabel
|
|
445
|
+
}));
|
|
446
|
+
}),
|
|
447
|
+
getConfiguredTools: publicProcedure.query(async ({ ctx }) => {
|
|
448
|
+
return getConfiguredTools(ctx.projectDir);
|
|
449
|
+
}),
|
|
450
|
+
subscribeConfiguredTools: publicProcedure.subscription(({ ctx }) => {
|
|
451
|
+
return createReactiveSubscription(() => getConfiguredTools(ctx.projectDir));
|
|
452
|
+
}),
|
|
453
|
+
init: publicProcedure.input(z.object({ tools: z.union([
|
|
454
|
+
z.array(z.string()),
|
|
455
|
+
z.literal("all"),
|
|
456
|
+
z.literal("none")
|
|
457
|
+
]).optional() }).optional()).mutation(async ({ ctx, input }) => {
|
|
458
|
+
return ctx.cliExecutor.init(input?.tools ?? "all");
|
|
459
|
+
}),
|
|
460
|
+
archive: publicProcedure.input(z.object({
|
|
461
|
+
changeId: z.string(),
|
|
462
|
+
skipSpecs: z.boolean().optional(),
|
|
463
|
+
noValidate: z.boolean().optional()
|
|
464
|
+
})).mutation(async ({ ctx, input }) => {
|
|
465
|
+
return ctx.cliExecutor.archive(input.changeId, {
|
|
466
|
+
skipSpecs: input.skipSpecs,
|
|
467
|
+
noValidate: input.noValidate
|
|
468
|
+
});
|
|
469
|
+
}),
|
|
470
|
+
validate: publicProcedure.input(z.object({
|
|
471
|
+
type: z.enum(["spec", "change"]).optional(),
|
|
472
|
+
id: z.string().optional()
|
|
473
|
+
})).mutation(async ({ ctx, input }) => {
|
|
474
|
+
return ctx.cliExecutor.validate(input.type, input.id);
|
|
475
|
+
}),
|
|
476
|
+
validateStream: publicProcedure.input(z.object({
|
|
477
|
+
type: z.enum(["spec", "change"]).optional(),
|
|
478
|
+
id: z.string().optional()
|
|
479
|
+
})).subscription(({ ctx, input }) => {
|
|
480
|
+
return createCliStreamObservable((onEvent) => ctx.cliExecutor.validateStream(input.type, input.id, onEvent));
|
|
481
|
+
}),
|
|
482
|
+
execute: publicProcedure.input(z.object({ args: z.array(z.string()) })).mutation(async ({ ctx, input }) => {
|
|
483
|
+
return ctx.cliExecutor.execute(input.args);
|
|
484
|
+
}),
|
|
485
|
+
initStream: publicProcedure.input(z.object({ tools: z.union([
|
|
486
|
+
z.array(z.string()),
|
|
487
|
+
z.literal("all"),
|
|
488
|
+
z.literal("none")
|
|
489
|
+
]).optional() }).optional()).subscription(({ ctx, input }) => {
|
|
490
|
+
return createCliStreamObservable((onEvent) => ctx.cliExecutor.initStream(input?.tools ?? "all", onEvent));
|
|
491
|
+
}),
|
|
492
|
+
archiveStream: publicProcedure.input(z.object({
|
|
493
|
+
changeId: z.string(),
|
|
494
|
+
skipSpecs: z.boolean().optional(),
|
|
495
|
+
noValidate: z.boolean().optional()
|
|
496
|
+
})).subscription(({ ctx, input }) => {
|
|
497
|
+
return createCliStreamObservable((onEvent) => ctx.cliExecutor.archiveStream(input.changeId, {
|
|
498
|
+
skipSpecs: input.skipSpecs,
|
|
499
|
+
noValidate: input.noValidate
|
|
500
|
+
}, onEvent));
|
|
501
|
+
})
|
|
502
|
+
});
|
|
503
|
+
/**
|
|
504
|
+
* Main app router
|
|
505
|
+
*/
|
|
506
|
+
const appRouter = router({
|
|
507
|
+
dashboard: dashboardRouter,
|
|
508
|
+
spec: specRouter,
|
|
509
|
+
change: changeRouter,
|
|
510
|
+
archive: archiveRouter,
|
|
511
|
+
project: projectRouter,
|
|
512
|
+
init: initRouter,
|
|
513
|
+
realtime: realtimeRouter,
|
|
514
|
+
config: configRouter,
|
|
515
|
+
cli: cliRouter
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region src/port-utils.ts
|
|
520
|
+
/**
|
|
521
|
+
* Check if a port is available by trying to listen on it.
|
|
522
|
+
* Uses default binding (both IPv4 and IPv6) to detect conflicts.
|
|
523
|
+
*/
|
|
524
|
+
function isPortAvailable(port) {
|
|
525
|
+
return new Promise((resolve) => {
|
|
526
|
+
const server = createServer$1();
|
|
527
|
+
server.once("error", () => {
|
|
528
|
+
resolve(false);
|
|
529
|
+
});
|
|
530
|
+
server.once("listening", () => {
|
|
531
|
+
server.close(() => resolve(true));
|
|
532
|
+
});
|
|
533
|
+
server.listen(port);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Find an available port starting from the given port.
|
|
538
|
+
* Will try up to maxAttempts ports sequentially.
|
|
539
|
+
*
|
|
540
|
+
* @param startPort - The preferred port to start checking from
|
|
541
|
+
* @param maxAttempts - Maximum number of ports to try (default: 10)
|
|
542
|
+
* @returns The first available port found
|
|
543
|
+
* @throws Error if no available port is found in the range
|
|
544
|
+
*/
|
|
545
|
+
async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
546
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
547
|
+
const port = startPort + i;
|
|
548
|
+
if (await isPortAvailable(port)) return port;
|
|
549
|
+
}
|
|
550
|
+
throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
//#endregion
|
|
554
|
+
//#region src/server.ts
|
|
555
|
+
/**
|
|
556
|
+
* OpenSpecUI HTTP/WebSocket server.
|
|
557
|
+
*
|
|
558
|
+
* Provides tRPC endpoints for:
|
|
559
|
+
* - Dashboard data and project status
|
|
560
|
+
* - Spec CRUD operations
|
|
561
|
+
* - Change proposal management
|
|
562
|
+
* - AI-assisted operations (review, translate, suggest)
|
|
563
|
+
* - Realtime file change subscriptions via WebSocket
|
|
564
|
+
*
|
|
565
|
+
* @module server
|
|
566
|
+
*/
|
|
567
|
+
/**
|
|
568
|
+
* Create an OpenSpecUI HTTP server with optional WebSocket support
|
|
569
|
+
*/
|
|
570
|
+
function createServer(config) {
|
|
571
|
+
const adapter = new OpenSpecAdapter(config.projectDir);
|
|
572
|
+
const configManager = new ConfigManager(config.projectDir);
|
|
573
|
+
const cliExecutor = new CliExecutor(configManager, config.projectDir);
|
|
574
|
+
const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
|
|
575
|
+
const app = new Hono();
|
|
576
|
+
const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
|
|
577
|
+
app.use("*", cors({
|
|
578
|
+
origin: corsOrigins,
|
|
579
|
+
credentials: true
|
|
580
|
+
}));
|
|
581
|
+
app.get("/api/health", (c) => {
|
|
582
|
+
return c.json({
|
|
583
|
+
status: "ok",
|
|
584
|
+
projectDir: config.projectDir,
|
|
585
|
+
watcherEnabled: !!watcher
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
app.use("/trpc/*", async (c) => {
|
|
589
|
+
return await fetchRequestHandler({
|
|
590
|
+
endpoint: "/trpc",
|
|
591
|
+
req: c.req.raw,
|
|
592
|
+
router: appRouter,
|
|
593
|
+
createContext: () => ({
|
|
594
|
+
adapter,
|
|
595
|
+
configManager,
|
|
596
|
+
cliExecutor,
|
|
597
|
+
watcher,
|
|
598
|
+
projectDir: config.projectDir
|
|
599
|
+
})
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
const createContext = () => ({
|
|
603
|
+
adapter,
|
|
604
|
+
configManager,
|
|
605
|
+
cliExecutor,
|
|
606
|
+
watcher,
|
|
607
|
+
projectDir: config.projectDir
|
|
608
|
+
});
|
|
609
|
+
return {
|
|
610
|
+
app,
|
|
611
|
+
adapter,
|
|
612
|
+
configManager,
|
|
613
|
+
cliExecutor,
|
|
614
|
+
watcher,
|
|
615
|
+
createContext,
|
|
616
|
+
port: config.port ?? 3100
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Create WebSocket server for tRPC subscriptions
|
|
621
|
+
*/
|
|
622
|
+
async function createWebSocketServer(server, httpServer, config) {
|
|
623
|
+
await initWatcherPool(config.projectDir);
|
|
624
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
625
|
+
const handler = applyWSSHandler({
|
|
626
|
+
wss,
|
|
627
|
+
router: appRouter,
|
|
628
|
+
createContext: server.createContext
|
|
629
|
+
});
|
|
630
|
+
httpServer.on("upgrade", (...args) => {
|
|
631
|
+
const [request, socket, head] = args;
|
|
632
|
+
if (request.url?.startsWith("/trpc")) wss.handleUpgrade(request, socket, head, (ws) => {
|
|
633
|
+
wss.emit("connection", ws, request);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
server.watcher?.start();
|
|
637
|
+
return {
|
|
638
|
+
wss,
|
|
639
|
+
handler,
|
|
640
|
+
close: () => {
|
|
641
|
+
handler.broadcastReconnectNotification();
|
|
642
|
+
wss.close();
|
|
643
|
+
server.watcher?.stop();
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Start the OpenSpec UI server with WebSocket support.
|
|
649
|
+
* Automatically finds an available port if the preferred port is occupied.
|
|
650
|
+
*
|
|
651
|
+
* @param config - Server configuration
|
|
652
|
+
* @param setupApp - Optional callback to configure the Hono app before starting (e.g., add static file middleware)
|
|
653
|
+
* @returns Running server instance with actual port and close function
|
|
654
|
+
*/
|
|
655
|
+
async function startServer(config, setupApp) {
|
|
656
|
+
const preferredPort = config.port ?? 3100;
|
|
657
|
+
const port = await findAvailablePort(preferredPort);
|
|
658
|
+
const server = createServer({
|
|
659
|
+
...config,
|
|
660
|
+
port
|
|
661
|
+
});
|
|
662
|
+
if (setupApp) setupApp(server.app);
|
|
663
|
+
const httpServer = serve({
|
|
664
|
+
fetch: server.app.fetch,
|
|
665
|
+
port
|
|
666
|
+
});
|
|
667
|
+
const wsServer = await createWebSocketServer(server, httpServer, { projectDir: config.projectDir });
|
|
668
|
+
return {
|
|
669
|
+
url: `http://localhost:${port}`,
|
|
670
|
+
port,
|
|
671
|
+
preferredPort,
|
|
672
|
+
close: async () => {
|
|
673
|
+
wsServer.close();
|
|
674
|
+
httpServer.close();
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
//#endregion
|
|
680
|
+
export { createServer, createWebSocketServer, findAvailablePort, isPortAvailable, startServer };
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openspecui/server",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.mjs",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.mjs"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@hono/node-server": "^1.14.1",
|
|
16
|
+
"@trpc/server": "^11.0.0",
|
|
17
|
+
"hono": "^4.7.3",
|
|
18
|
+
"ws": "^8.18.0",
|
|
19
|
+
"yargs": "^18.0.0",
|
|
20
|
+
"zod": "^3.24.1",
|
|
21
|
+
"@openspecui/core": "0.9.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.10.2",
|
|
25
|
+
"@types/ws": "^8.5.13",
|
|
26
|
+
"@types/yargs": "^17.0.35",
|
|
27
|
+
"tsdown": "^0.16.6",
|
|
28
|
+
"tsx": "^4.19.2",
|
|
29
|
+
"typescript": "^5.7.2",
|
|
30
|
+
"vitest": "^2.1.8"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsdown src/index.ts --format esm --no-dts",
|
|
34
|
+
"typecheck": "tsc --noEmit -p tsconfig.check.json",
|
|
35
|
+
"dev": "tsx watch src/standalone.ts",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
38
|
+
}
|
|
39
|
+
}
|