@openspecui/server 0.9.0 → 1.0.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 +1012 -63
- package/package.json +5 -3
package/dist/index.mjs
CHANGED
|
@@ -4,11 +4,17 @@ 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
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { dirname, join, matchesGlob, relative, resolve, sep } from "node:path";
|
|
8
10
|
import { initTRPC } from "@trpc/server";
|
|
9
11
|
import { observable } from "@trpc/server/observable";
|
|
10
12
|
import { z } from "zod";
|
|
13
|
+
import YAML, { 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
|
|
|
13
19
|
//#region src/reactive-subscription.ts
|
|
14
20
|
/**
|
|
@@ -124,28 +130,291 @@ function createCliStreamObservable(startStream) {
|
|
|
124
130
|
});
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/opsx-schema.ts
|
|
135
|
+
const SchemaYamlArtifactSchema = z.object({
|
|
136
|
+
id: z.string(),
|
|
137
|
+
generates: z.string(),
|
|
138
|
+
description: z.string().optional(),
|
|
139
|
+
template: z.string().optional(),
|
|
140
|
+
instruction: z.string().optional(),
|
|
141
|
+
requires: z.array(z.string()).optional()
|
|
142
|
+
});
|
|
143
|
+
const SchemaYamlSchema = z.object({
|
|
144
|
+
name: z.string(),
|
|
145
|
+
version: z.union([z.string(), z.number()]).optional(),
|
|
146
|
+
description: z.string().optional(),
|
|
147
|
+
artifacts: z.array(SchemaYamlArtifactSchema),
|
|
148
|
+
apply: z.object({
|
|
149
|
+
requires: z.array(z.string()).optional(),
|
|
150
|
+
tracks: z.string().optional(),
|
|
151
|
+
instruction: z.string().optional()
|
|
152
|
+
}).optional()
|
|
153
|
+
});
|
|
154
|
+
function parseSchemaYaml(content) {
|
|
155
|
+
const raw = parse(content);
|
|
156
|
+
const parsed = SchemaYamlSchema.safeParse(raw);
|
|
157
|
+
if (!parsed.success) throw new Error(`Invalid schema.yaml: ${parsed.error.message}`);
|
|
158
|
+
const { artifacts, apply, name, description, version } = parsed.data;
|
|
159
|
+
const detail = {
|
|
160
|
+
name,
|
|
161
|
+
description,
|
|
162
|
+
version,
|
|
163
|
+
artifacts: artifacts.map((artifact) => ({
|
|
164
|
+
id: artifact.id,
|
|
165
|
+
outputPath: artifact.generates,
|
|
166
|
+
description: artifact.description,
|
|
167
|
+
template: artifact.template,
|
|
168
|
+
instruction: artifact.instruction,
|
|
169
|
+
requires: artifact.requires ?? []
|
|
170
|
+
})),
|
|
171
|
+
applyRequires: apply?.requires ?? [],
|
|
172
|
+
applyTracks: apply?.tracks,
|
|
173
|
+
applyInstruction: apply?.instruction
|
|
174
|
+
};
|
|
175
|
+
const validated = SchemaDetailSchema.safeParse(detail);
|
|
176
|
+
if (!validated.success) throw new Error(`Invalid schema detail: ${validated.error.message}`);
|
|
177
|
+
return validated.data;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/reactive-kv.ts
|
|
182
|
+
/**
|
|
183
|
+
* In-memory reactive key-value store.
|
|
184
|
+
*
|
|
185
|
+
* - No disk persistence — devices own their own storage (e.g. IndexedDB).
|
|
186
|
+
* - Emits 'change' events when values are set or deleted.
|
|
187
|
+
* - Designed for cross-device sync scenarios where the server acts as a
|
|
188
|
+
* transient rendezvous point.
|
|
189
|
+
*/
|
|
190
|
+
var ReactiveKV = class {
|
|
191
|
+
store = /* @__PURE__ */ new Map();
|
|
192
|
+
emitter = new EventEmitter();
|
|
193
|
+
constructor() {
|
|
194
|
+
this.emitter.setMaxListeners(200);
|
|
195
|
+
}
|
|
196
|
+
get(key) {
|
|
197
|
+
return this.store.get(key);
|
|
198
|
+
}
|
|
199
|
+
set(key, value) {
|
|
200
|
+
this.store.set(key, value);
|
|
201
|
+
this.emitter.emit("change", key);
|
|
202
|
+
this.emitter.emit(`change:${key}`, value);
|
|
203
|
+
}
|
|
204
|
+
delete(key) {
|
|
205
|
+
const existed = this.store.delete(key);
|
|
206
|
+
if (existed) {
|
|
207
|
+
this.emitter.emit("change", key);
|
|
208
|
+
this.emitter.emit(`change:${key}`, void 0);
|
|
209
|
+
}
|
|
210
|
+
return existed;
|
|
211
|
+
}
|
|
212
|
+
has(key) {
|
|
213
|
+
return this.store.has(key);
|
|
214
|
+
}
|
|
215
|
+
keys() {
|
|
216
|
+
return [...this.store.keys()];
|
|
217
|
+
}
|
|
218
|
+
/** Subscribe to changes for a specific key. Returns unsubscribe function. */
|
|
219
|
+
onKey(key, listener) {
|
|
220
|
+
const event = `change:${key}`;
|
|
221
|
+
this.emitter.on(event, listener);
|
|
222
|
+
return () => {
|
|
223
|
+
this.emitter.off(event, listener);
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** Subscribe to all changes. Returns unsubscribe function. */
|
|
227
|
+
onChange(listener) {
|
|
228
|
+
this.emitter.on("change", listener);
|
|
229
|
+
return () => {
|
|
230
|
+
this.emitter.off("change", listener);
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
/** Singleton instance shared across the server lifetime */
|
|
235
|
+
const reactiveKV = new ReactiveKV();
|
|
236
|
+
|
|
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({
|
|
@@ -501,18 +736,374 @@ const cliRouter = router({
|
|
|
501
736
|
})
|
|
502
737
|
});
|
|
503
738
|
/**
|
|
739
|
+
* OPSX router - CLI-driven workflow data
|
|
740
|
+
*/
|
|
741
|
+
const opsxRouter = router({
|
|
742
|
+
status: publicProcedure.input(z.object({
|
|
743
|
+
change: z.string().optional(),
|
|
744
|
+
schema: z.string().optional()
|
|
745
|
+
})).query(async ({ ctx, input }) => {
|
|
746
|
+
return fetchOpsxStatus(ctx, input);
|
|
747
|
+
}),
|
|
748
|
+
subscribeStatus: publicProcedure.input(z.object({
|
|
749
|
+
change: z.string().optional(),
|
|
750
|
+
schema: z.string().optional()
|
|
751
|
+
})).subscription(({ ctx, input }) => {
|
|
752
|
+
return createReactiveSubscription(() => fetchOpsxStatus(ctx, input));
|
|
753
|
+
}),
|
|
754
|
+
statusList: publicProcedure.query(async ({ ctx }) => {
|
|
755
|
+
return fetchOpsxStatusList(ctx);
|
|
756
|
+
}),
|
|
757
|
+
subscribeStatusList: publicProcedure.subscription(({ ctx }) => {
|
|
758
|
+
return createReactiveSubscription(() => fetchOpsxStatusList(ctx));
|
|
759
|
+
}),
|
|
760
|
+
instructions: publicProcedure.input(z.object({
|
|
761
|
+
change: z.string().optional(),
|
|
762
|
+
artifact: z.string(),
|
|
763
|
+
schema: z.string().optional()
|
|
764
|
+
})).query(async ({ ctx, input }) => {
|
|
765
|
+
return fetchOpsxInstructions(ctx, input);
|
|
766
|
+
}),
|
|
767
|
+
subscribeInstructions: publicProcedure.input(z.object({
|
|
768
|
+
change: z.string().optional(),
|
|
769
|
+
artifact: z.string(),
|
|
770
|
+
schema: z.string().optional()
|
|
771
|
+
})).subscription(({ ctx, input }) => {
|
|
772
|
+
return createReactiveSubscription(() => fetchOpsxInstructions(ctx, input));
|
|
773
|
+
}),
|
|
774
|
+
applyInstructions: publicProcedure.input(z.object({
|
|
775
|
+
change: z.string().optional(),
|
|
776
|
+
schema: z.string().optional()
|
|
777
|
+
})).query(async ({ ctx, input }) => {
|
|
778
|
+
return fetchOpsxApplyInstructions(ctx, input);
|
|
779
|
+
}),
|
|
780
|
+
subscribeApplyInstructions: publicProcedure.input(z.object({
|
|
781
|
+
change: z.string().optional(),
|
|
782
|
+
schema: z.string().optional()
|
|
783
|
+
})).subscription(({ ctx, input }) => {
|
|
784
|
+
return createReactiveSubscription(() => fetchOpsxApplyInstructions(ctx, input));
|
|
785
|
+
}),
|
|
786
|
+
schemas: publicProcedure.query(async ({ ctx }) => {
|
|
787
|
+
return fetchOpsxSchemas(ctx);
|
|
788
|
+
}),
|
|
789
|
+
subscribeSchemas: publicProcedure.subscription(({ ctx }) => {
|
|
790
|
+
return createReactiveSubscription(() => fetchOpsxSchemas(ctx));
|
|
791
|
+
}),
|
|
792
|
+
templates: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
|
|
793
|
+
return fetchOpsxTemplates(ctx, input?.schema);
|
|
794
|
+
}),
|
|
795
|
+
subscribeTemplates: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
|
|
796
|
+
return createReactiveSubscription(() => fetchOpsxTemplates(ctx, input?.schema));
|
|
797
|
+
}),
|
|
798
|
+
templateContents: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
|
|
799
|
+
return fetchOpsxTemplateContents(ctx, input?.schema);
|
|
800
|
+
}),
|
|
801
|
+
subscribeTemplateContents: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
|
|
802
|
+
return createReactiveSubscription(() => fetchOpsxTemplateContents(ctx, input?.schema));
|
|
803
|
+
}),
|
|
804
|
+
schemaResolution: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
|
|
805
|
+
return fetchOpsxSchemaResolution(ctx, input.name);
|
|
806
|
+
}),
|
|
807
|
+
subscribeSchemaResolution: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
|
|
808
|
+
return createReactiveSubscription(() => fetchOpsxSchemaResolution(ctx, input.name));
|
|
809
|
+
}),
|
|
810
|
+
schemaDetail: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
|
|
811
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
812
|
+
const schemaPath = join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml");
|
|
813
|
+
const content = await reactiveReadFile(schemaPath);
|
|
814
|
+
if (!content) throw new Error(`schema.yaml not found at ${schemaPath}`);
|
|
815
|
+
return parseSchemaYaml(content);
|
|
816
|
+
}),
|
|
817
|
+
subscribeSchemaDetail: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
|
|
818
|
+
return createReactiveSubscription(async () => {
|
|
819
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
820
|
+
const schemaPath = join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml");
|
|
821
|
+
const content = await reactiveReadFile(schemaPath);
|
|
822
|
+
if (!content) throw new Error(`schema.yaml not found at ${schemaPath}`);
|
|
823
|
+
return parseSchemaYaml(content);
|
|
824
|
+
});
|
|
825
|
+
}),
|
|
826
|
+
schemaFiles: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
|
|
827
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
828
|
+
return readEntriesUnderRoot((await fetchOpsxSchemaResolution(ctx, input.name)).path);
|
|
829
|
+
}),
|
|
830
|
+
subscribeSchemaFiles: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
|
|
831
|
+
return createReactiveSubscription(async () => {
|
|
832
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
833
|
+
return readEntriesUnderRoot((await fetchOpsxSchemaResolution(ctx, input.name)).path);
|
|
834
|
+
});
|
|
835
|
+
}),
|
|
836
|
+
schemaYaml: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
|
|
837
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
838
|
+
return reactiveReadFile(join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml"));
|
|
839
|
+
}),
|
|
840
|
+
subscribeSchemaYaml: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
|
|
841
|
+
return createReactiveSubscription(async () => {
|
|
842
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
843
|
+
return reactiveReadFile(join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml"));
|
|
844
|
+
});
|
|
845
|
+
}),
|
|
846
|
+
writeSchemaYaml: publicProcedure.input(z.object({
|
|
847
|
+
name: z.string(),
|
|
848
|
+
content: z.string()
|
|
849
|
+
})).mutation(async ({ ctx, input }) => {
|
|
850
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
851
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.name);
|
|
852
|
+
ensureEditableSource(resolution.source, "schema.yaml");
|
|
853
|
+
const schemaPath = join(resolution.path, "schema.yaml");
|
|
854
|
+
await mkdir(dirname(schemaPath), { recursive: true });
|
|
855
|
+
await writeFile(schemaPath, input.content, "utf-8");
|
|
856
|
+
return { success: true };
|
|
857
|
+
}),
|
|
858
|
+
writeSchemaFile: publicProcedure.input(z.object({
|
|
859
|
+
schema: z.string(),
|
|
860
|
+
path: z.string(),
|
|
861
|
+
content: z.string()
|
|
862
|
+
})).mutation(async ({ ctx, input }) => {
|
|
863
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
864
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
|
|
865
|
+
ensureEditableSource(resolution.source, "schema file");
|
|
866
|
+
if (!input.path.trim()) throw new Error("path is required");
|
|
867
|
+
const fullPath = resolveEntryPath(resolution.path, input.path);
|
|
868
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
869
|
+
await writeFile(fullPath, input.content, "utf-8");
|
|
870
|
+
return { success: true };
|
|
871
|
+
}),
|
|
872
|
+
createSchemaFile: publicProcedure.input(z.object({
|
|
873
|
+
schema: z.string(),
|
|
874
|
+
path: z.string(),
|
|
875
|
+
content: z.string().optional()
|
|
876
|
+
})).mutation(async ({ ctx, input }) => {
|
|
877
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
878
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
|
|
879
|
+
ensureEditableSource(resolution.source, "schema file");
|
|
880
|
+
if (!input.path.trim()) throw new Error("path is required");
|
|
881
|
+
const fullPath = resolveEntryPath(resolution.path, input.path);
|
|
882
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
883
|
+
await writeFile(fullPath, input.content ?? "", "utf-8");
|
|
884
|
+
return { success: true };
|
|
885
|
+
}),
|
|
886
|
+
createSchemaDirectory: publicProcedure.input(z.object({
|
|
887
|
+
schema: z.string(),
|
|
888
|
+
path: z.string()
|
|
889
|
+
})).mutation(async ({ ctx, input }) => {
|
|
890
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
891
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
|
|
892
|
+
ensureEditableSource(resolution.source, "schema directory");
|
|
893
|
+
if (!input.path.trim()) throw new Error("path is required");
|
|
894
|
+
await mkdir(resolveEntryPath(resolution.path, input.path), { recursive: true });
|
|
895
|
+
return { success: true };
|
|
896
|
+
}),
|
|
897
|
+
deleteSchemaEntry: publicProcedure.input(z.object({
|
|
898
|
+
schema: z.string(),
|
|
899
|
+
path: z.string()
|
|
900
|
+
})).mutation(async ({ ctx, input }) => {
|
|
901
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
902
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
|
|
903
|
+
ensureEditableSource(resolution.source, "schema entry");
|
|
904
|
+
if (!input.path.trim()) throw new Error("path is required");
|
|
905
|
+
const fullPath = resolveEntryPath(resolution.path, input.path);
|
|
906
|
+
if (fullPath === resolve(resolution.path)) throw new Error("cannot delete schema root");
|
|
907
|
+
await rm(fullPath, {
|
|
908
|
+
recursive: true,
|
|
909
|
+
force: true
|
|
910
|
+
});
|
|
911
|
+
return { success: true };
|
|
912
|
+
}),
|
|
913
|
+
templateContent: publicProcedure.input(z.object({
|
|
914
|
+
schema: z.string(),
|
|
915
|
+
artifactId: z.string()
|
|
916
|
+
})).query(async ({ ctx, input }) => {
|
|
917
|
+
const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
|
|
918
|
+
if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
|
|
919
|
+
return {
|
|
920
|
+
content: await reactiveReadFile(info.path),
|
|
921
|
+
path: info.path,
|
|
922
|
+
source: info.source
|
|
923
|
+
};
|
|
924
|
+
}),
|
|
925
|
+
subscribeTemplateContent: publicProcedure.input(z.object({
|
|
926
|
+
schema: z.string(),
|
|
927
|
+
artifactId: z.string()
|
|
928
|
+
})).subscription(({ ctx, input }) => {
|
|
929
|
+
return createReactiveSubscription(async () => {
|
|
930
|
+
const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
|
|
931
|
+
if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
|
|
932
|
+
return {
|
|
933
|
+
content: await reactiveReadFile(info.path),
|
|
934
|
+
path: info.path,
|
|
935
|
+
source: info.source
|
|
936
|
+
};
|
|
937
|
+
});
|
|
938
|
+
}),
|
|
939
|
+
writeTemplateContent: publicProcedure.input(z.object({
|
|
940
|
+
schema: z.string(),
|
|
941
|
+
artifactId: z.string(),
|
|
942
|
+
content: z.string()
|
|
943
|
+
})).mutation(async ({ ctx, input }) => {
|
|
944
|
+
const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
|
|
945
|
+
if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
|
|
946
|
+
ensureEditableSource(info.source, "template");
|
|
947
|
+
await mkdir(dirname(info.path), { recursive: true });
|
|
948
|
+
await writeFile(info.path, input.content, "utf-8");
|
|
949
|
+
return { success: true };
|
|
950
|
+
}),
|
|
951
|
+
deleteSchema: publicProcedure.input(z.object({ name: z.string() })).mutation(async ({ ctx, input }) => {
|
|
952
|
+
await touchOpsxProjectDeps(ctx.projectDir);
|
|
953
|
+
const resolution = await fetchOpsxSchemaResolution(ctx, input.name);
|
|
954
|
+
ensureEditableSource(resolution.source, "schema");
|
|
955
|
+
await rm(resolution.path, {
|
|
956
|
+
recursive: true,
|
|
957
|
+
force: true
|
|
958
|
+
});
|
|
959
|
+
return { success: true };
|
|
960
|
+
}),
|
|
961
|
+
projectConfig: publicProcedure.query(async ({ ctx }) => {
|
|
962
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "config.yaml"));
|
|
963
|
+
}),
|
|
964
|
+
subscribeProjectConfig: publicProcedure.subscription(({ ctx }) => {
|
|
965
|
+
return createReactiveSubscription(async () => {
|
|
966
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "config.yaml"));
|
|
967
|
+
});
|
|
968
|
+
}),
|
|
969
|
+
writeProjectConfig: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
|
|
970
|
+
const openspecDir = join(ctx.projectDir, "openspec");
|
|
971
|
+
await mkdir(openspecDir, { recursive: true });
|
|
972
|
+
await writeFile(join(openspecDir, "config.yaml"), input.content, "utf-8");
|
|
973
|
+
return { success: true };
|
|
974
|
+
}),
|
|
975
|
+
updateProjectConfigUi: publicProcedure.input(z.object({
|
|
976
|
+
"font-size": z.number().min(8).max(32).optional(),
|
|
977
|
+
"font-families": z.array(z.string()).optional(),
|
|
978
|
+
"cursor-blink": z.boolean().optional(),
|
|
979
|
+
"cursor-style": z.enum([
|
|
980
|
+
"block",
|
|
981
|
+
"underline",
|
|
982
|
+
"bar"
|
|
983
|
+
]).optional(),
|
|
984
|
+
scrollback: z.number().min(0).max(1e5).optional()
|
|
985
|
+
})).mutation(async ({ ctx, input }) => {
|
|
986
|
+
const configPath = join(ctx.projectDir, "openspec", "config.yaml");
|
|
987
|
+
let doc;
|
|
988
|
+
try {
|
|
989
|
+
const content = await readFile(configPath, "utf-8");
|
|
990
|
+
doc = YAML.parseDocument(content);
|
|
991
|
+
} catch {
|
|
992
|
+
doc = new YAML.Document({});
|
|
993
|
+
}
|
|
994
|
+
if (!doc.has("ui")) doc.set("ui", doc.createNode({}));
|
|
995
|
+
const uiNode = doc.get("ui", true);
|
|
996
|
+
for (const [key, value] of Object.entries(input)) if (value !== void 0) uiNode.set(key, value);
|
|
997
|
+
await mkdir(join(ctx.projectDir, "openspec"), { recursive: true });
|
|
998
|
+
await writeFile(configPath, doc.toString(), "utf-8");
|
|
999
|
+
return { success: true };
|
|
1000
|
+
}),
|
|
1001
|
+
listChanges: publicProcedure.query(async ({ ctx }) => {
|
|
1002
|
+
return reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
|
|
1003
|
+
directoriesOnly: true,
|
|
1004
|
+
exclude: ["archive"],
|
|
1005
|
+
includeHidden: false
|
|
1006
|
+
});
|
|
1007
|
+
}),
|
|
1008
|
+
subscribeChanges: publicProcedure.subscription(({ ctx }) => {
|
|
1009
|
+
return createReactiveSubscription(async () => {
|
|
1010
|
+
return reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
|
|
1011
|
+
directoriesOnly: true,
|
|
1012
|
+
exclude: ["archive"],
|
|
1013
|
+
includeHidden: false
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
}),
|
|
1017
|
+
changeMetadata: publicProcedure.input(z.object({ changeId: z.string() })).query(async ({ ctx, input }) => {
|
|
1018
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, ".openspec.yaml"));
|
|
1019
|
+
}),
|
|
1020
|
+
subscribeChangeMetadata: publicProcedure.input(z.object({ changeId: z.string() })).subscription(({ ctx, input }) => {
|
|
1021
|
+
return createReactiveSubscription(async () => {
|
|
1022
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, ".openspec.yaml"));
|
|
1023
|
+
});
|
|
1024
|
+
}),
|
|
1025
|
+
readArtifactOutput: publicProcedure.input(z.object({
|
|
1026
|
+
changeId: z.string(),
|
|
1027
|
+
outputPath: z.string()
|
|
1028
|
+
})).query(async ({ ctx, input }) => {
|
|
1029
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath));
|
|
1030
|
+
}),
|
|
1031
|
+
subscribeArtifactOutput: publicProcedure.input(z.object({
|
|
1032
|
+
changeId: z.string(),
|
|
1033
|
+
outputPath: z.string()
|
|
1034
|
+
})).subscription(({ ctx, input }) => {
|
|
1035
|
+
return createReactiveSubscription(async () => {
|
|
1036
|
+
return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath));
|
|
1037
|
+
});
|
|
1038
|
+
}),
|
|
1039
|
+
readGlobArtifactFiles: publicProcedure.input(z.object({
|
|
1040
|
+
changeId: z.string(),
|
|
1041
|
+
outputPath: z.string()
|
|
1042
|
+
})).query(async ({ ctx, input }) => {
|
|
1043
|
+
return readGlobArtifactFiles(ctx.projectDir, input.changeId, input.outputPath);
|
|
1044
|
+
}),
|
|
1045
|
+
subscribeGlobArtifactFiles: publicProcedure.input(z.object({
|
|
1046
|
+
changeId: z.string(),
|
|
1047
|
+
outputPath: z.string()
|
|
1048
|
+
})).subscription(({ ctx, input }) => {
|
|
1049
|
+
return createReactiveSubscription(async () => {
|
|
1050
|
+
return readGlobArtifactFiles(ctx.projectDir, input.changeId, input.outputPath);
|
|
1051
|
+
});
|
|
1052
|
+
}),
|
|
1053
|
+
writeArtifactOutput: publicProcedure.input(z.object({
|
|
1054
|
+
changeId: z.string(),
|
|
1055
|
+
outputPath: z.string(),
|
|
1056
|
+
content: z.string()
|
|
1057
|
+
})).mutation(async ({ ctx, input }) => {
|
|
1058
|
+
await writeFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath), input.content, "utf-8");
|
|
1059
|
+
return { success: true };
|
|
1060
|
+
})
|
|
1061
|
+
});
|
|
1062
|
+
/**
|
|
1063
|
+
* KV router - in-memory reactive key-value store
|
|
1064
|
+
* No disk persistence — devices use IndexedDB for their own storage.
|
|
1065
|
+
*/
|
|
1066
|
+
const kvRouter = router({
|
|
1067
|
+
get: publicProcedure.input(z.object({ key: z.string() })).query(({ input }) => {
|
|
1068
|
+
return reactiveKV.get(input.key) ?? null;
|
|
1069
|
+
}),
|
|
1070
|
+
set: publicProcedure.input(z.object({
|
|
1071
|
+
key: z.string(),
|
|
1072
|
+
value: z.unknown()
|
|
1073
|
+
})).mutation(({ input }) => {
|
|
1074
|
+
reactiveKV.set(input.key, input.value);
|
|
1075
|
+
return { success: true };
|
|
1076
|
+
}),
|
|
1077
|
+
delete: publicProcedure.input(z.object({ key: z.string() })).mutation(({ input }) => {
|
|
1078
|
+
reactiveKV.delete(input.key);
|
|
1079
|
+
return { success: true };
|
|
1080
|
+
}),
|
|
1081
|
+
subscribe: publicProcedure.input(z.object({ key: z.string() })).subscription(({ input }) => {
|
|
1082
|
+
return observable((emit) => {
|
|
1083
|
+
const current = reactiveKV.get(input.key);
|
|
1084
|
+
emit.next(current ?? null);
|
|
1085
|
+
const unsub = reactiveKV.onKey(input.key, (value) => {
|
|
1086
|
+
emit.next(value ?? null);
|
|
1087
|
+
});
|
|
1088
|
+
return () => {
|
|
1089
|
+
unsub();
|
|
1090
|
+
};
|
|
1091
|
+
});
|
|
1092
|
+
})
|
|
1093
|
+
});
|
|
1094
|
+
/**
|
|
504
1095
|
* Main app router
|
|
505
1096
|
*/
|
|
506
1097
|
const appRouter = router({
|
|
507
|
-
dashboard: dashboardRouter,
|
|
508
1098
|
spec: specRouter,
|
|
509
1099
|
change: changeRouter,
|
|
510
1100
|
archive: archiveRouter,
|
|
511
|
-
project: projectRouter,
|
|
512
1101
|
init: initRouter,
|
|
513
1102
|
realtime: realtimeRouter,
|
|
514
1103
|
config: configRouter,
|
|
515
|
-
cli: cliRouter
|
|
1104
|
+
cli: cliRouter,
|
|
1105
|
+
opsx: opsxRouter,
|
|
1106
|
+
kv: kvRouter
|
|
516
1107
|
});
|
|
517
1108
|
|
|
518
1109
|
//#endregion
|
|
@@ -522,13 +1113,13 @@ const appRouter = router({
|
|
|
522
1113
|
* Uses default binding (both IPv4 and IPv6) to detect conflicts.
|
|
523
1114
|
*/
|
|
524
1115
|
function isPortAvailable(port) {
|
|
525
|
-
return new Promise((resolve) => {
|
|
1116
|
+
return new Promise((resolve$1) => {
|
|
526
1117
|
const server = createServer$1();
|
|
527
1118
|
server.once("error", () => {
|
|
528
|
-
resolve(false);
|
|
1119
|
+
resolve$1(false);
|
|
529
1120
|
});
|
|
530
1121
|
server.once("listening", () => {
|
|
531
|
-
server.close(() => resolve(true));
|
|
1122
|
+
server.close(() => resolve$1(true));
|
|
532
1123
|
});
|
|
533
1124
|
server.listen(port);
|
|
534
1125
|
});
|
|
@@ -550,6 +1141,340 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
|
550
1141
|
throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
|
|
551
1142
|
}
|
|
552
1143
|
|
|
1144
|
+
//#endregion
|
|
1145
|
+
//#region src/pty-manager.ts
|
|
1146
|
+
const DEFAULT_SCROLLBACK = 1e3;
|
|
1147
|
+
const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
1148
|
+
function detectPtyPlatform() {
|
|
1149
|
+
if (process.platform === "win32") return "windows";
|
|
1150
|
+
if (process.platform === "darwin") return "macos";
|
|
1151
|
+
return "common";
|
|
1152
|
+
}
|
|
1153
|
+
var PtySession = class extends EventEmitter$1 {
|
|
1154
|
+
id;
|
|
1155
|
+
command;
|
|
1156
|
+
args;
|
|
1157
|
+
platform;
|
|
1158
|
+
createdAt;
|
|
1159
|
+
process;
|
|
1160
|
+
titleInterval = null;
|
|
1161
|
+
lastTitle = "";
|
|
1162
|
+
buffer = [];
|
|
1163
|
+
bufferByteLength = 0;
|
|
1164
|
+
maxBufferLines;
|
|
1165
|
+
maxBufferBytes;
|
|
1166
|
+
isExited = false;
|
|
1167
|
+
exitCode = null;
|
|
1168
|
+
constructor(id, opts) {
|
|
1169
|
+
super();
|
|
1170
|
+
this.id = id;
|
|
1171
|
+
this.createdAt = Date.now();
|
|
1172
|
+
const shell = opts.command ?? process.env.SHELL ?? "/bin/sh";
|
|
1173
|
+
const args = opts.command ? opts.args ?? [] : [];
|
|
1174
|
+
this.command = shell;
|
|
1175
|
+
this.args = args;
|
|
1176
|
+
this.platform = opts.platform;
|
|
1177
|
+
this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
|
|
1178
|
+
this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
|
|
1179
|
+
this.process = pty.spawn(shell, args, {
|
|
1180
|
+
name: "xterm-256color",
|
|
1181
|
+
cols: opts.cols ?? 80,
|
|
1182
|
+
rows: opts.rows ?? 24,
|
|
1183
|
+
cwd: opts.cwd,
|
|
1184
|
+
env: {
|
|
1185
|
+
...process.env,
|
|
1186
|
+
TERM: "xterm-256color"
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
this.process.onData((data) => {
|
|
1190
|
+
this.appendBuffer(data);
|
|
1191
|
+
this.emit("data", data);
|
|
1192
|
+
});
|
|
1193
|
+
this.process.onExit(({ exitCode }) => {
|
|
1194
|
+
if (this.titleInterval) {
|
|
1195
|
+
clearInterval(this.titleInterval);
|
|
1196
|
+
this.titleInterval = null;
|
|
1197
|
+
}
|
|
1198
|
+
this.isExited = true;
|
|
1199
|
+
this.exitCode = exitCode;
|
|
1200
|
+
this.emit("exit", exitCode);
|
|
1201
|
+
});
|
|
1202
|
+
this.titleInterval = setInterval(() => {
|
|
1203
|
+
try {
|
|
1204
|
+
const title = this.process.process;
|
|
1205
|
+
if (title && title !== this.lastTitle) {
|
|
1206
|
+
this.lastTitle = title;
|
|
1207
|
+
this.emit("title", title);
|
|
1208
|
+
}
|
|
1209
|
+
} catch {}
|
|
1210
|
+
}, 1e3);
|
|
1211
|
+
}
|
|
1212
|
+
get title() {
|
|
1213
|
+
return this.lastTitle;
|
|
1214
|
+
}
|
|
1215
|
+
appendBuffer(data) {
|
|
1216
|
+
let chunk = data;
|
|
1217
|
+
if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
|
|
1218
|
+
this.buffer.push(chunk);
|
|
1219
|
+
this.bufferByteLength += chunk.length;
|
|
1220
|
+
while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
|
|
1221
|
+
const removed = this.buffer.shift();
|
|
1222
|
+
this.bufferByteLength -= removed.length;
|
|
1223
|
+
}
|
|
1224
|
+
while (this.buffer.length > this.maxBufferLines) {
|
|
1225
|
+
const removed = this.buffer.shift();
|
|
1226
|
+
this.bufferByteLength -= removed.length;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
getBuffer() {
|
|
1230
|
+
return this.buffer.join("");
|
|
1231
|
+
}
|
|
1232
|
+
write(data) {
|
|
1233
|
+
if (!this.isExited) this.process.write(data);
|
|
1234
|
+
}
|
|
1235
|
+
resize(cols, rows) {
|
|
1236
|
+
if (!this.isExited) this.process.resize(cols, rows);
|
|
1237
|
+
}
|
|
1238
|
+
close() {
|
|
1239
|
+
if (this.titleInterval) {
|
|
1240
|
+
clearInterval(this.titleInterval);
|
|
1241
|
+
this.titleInterval = null;
|
|
1242
|
+
}
|
|
1243
|
+
try {
|
|
1244
|
+
this.process.kill();
|
|
1245
|
+
} catch {}
|
|
1246
|
+
this.removeAllListeners();
|
|
1247
|
+
}
|
|
1248
|
+
toInfo() {
|
|
1249
|
+
return {
|
|
1250
|
+
id: this.id,
|
|
1251
|
+
title: this.lastTitle,
|
|
1252
|
+
command: this.command,
|
|
1253
|
+
args: this.args,
|
|
1254
|
+
platform: this.platform,
|
|
1255
|
+
isExited: this.isExited,
|
|
1256
|
+
exitCode: this.exitCode,
|
|
1257
|
+
createdAt: this.createdAt
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
var PtyManager = class {
|
|
1262
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1263
|
+
idCounter = 0;
|
|
1264
|
+
platform;
|
|
1265
|
+
constructor(defaultCwd) {
|
|
1266
|
+
this.defaultCwd = defaultCwd;
|
|
1267
|
+
this.platform = detectPtyPlatform();
|
|
1268
|
+
}
|
|
1269
|
+
create(opts) {
|
|
1270
|
+
const id = `pty-${++this.idCounter}`;
|
|
1271
|
+
const session = new PtySession(id, {
|
|
1272
|
+
cols: opts.cols,
|
|
1273
|
+
rows: opts.rows,
|
|
1274
|
+
command: opts.command,
|
|
1275
|
+
args: opts.args,
|
|
1276
|
+
cwd: this.defaultCwd,
|
|
1277
|
+
scrollback: opts.scrollback,
|
|
1278
|
+
maxBufferBytes: opts.maxBufferBytes,
|
|
1279
|
+
platform: this.platform
|
|
1280
|
+
});
|
|
1281
|
+
this.sessions.set(id, session);
|
|
1282
|
+
return session;
|
|
1283
|
+
}
|
|
1284
|
+
get(id) {
|
|
1285
|
+
return this.sessions.get(id);
|
|
1286
|
+
}
|
|
1287
|
+
list() {
|
|
1288
|
+
const result = [];
|
|
1289
|
+
for (const session of this.sessions.values()) result.push(session.toInfo());
|
|
1290
|
+
return result;
|
|
1291
|
+
}
|
|
1292
|
+
write(id, data) {
|
|
1293
|
+
this.sessions.get(id)?.write(data);
|
|
1294
|
+
}
|
|
1295
|
+
resize(id, cols, rows) {
|
|
1296
|
+
this.sessions.get(id)?.resize(cols, rows);
|
|
1297
|
+
}
|
|
1298
|
+
close(id) {
|
|
1299
|
+
const session = this.sessions.get(id);
|
|
1300
|
+
if (session) {
|
|
1301
|
+
session.close();
|
|
1302
|
+
this.sessions.delete(id);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
closeAll() {
|
|
1306
|
+
for (const session of this.sessions.values()) session.close();
|
|
1307
|
+
this.sessions.clear();
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
//#endregion
|
|
1312
|
+
//#region src/pty-websocket.ts
|
|
1313
|
+
function createPtyWebSocketHandler(ptyManager) {
|
|
1314
|
+
return (ws) => {
|
|
1315
|
+
const cleanups = /* @__PURE__ */ new Map();
|
|
1316
|
+
const send = (msg) => {
|
|
1317
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
|
|
1318
|
+
};
|
|
1319
|
+
const sendError = (code, message, opts) => {
|
|
1320
|
+
send({
|
|
1321
|
+
type: "error",
|
|
1322
|
+
code,
|
|
1323
|
+
message,
|
|
1324
|
+
sessionId: opts?.sessionId
|
|
1325
|
+
});
|
|
1326
|
+
};
|
|
1327
|
+
const attachToSession = (session, opts) => {
|
|
1328
|
+
const sessionId = session.id;
|
|
1329
|
+
cleanups.get(sessionId)?.();
|
|
1330
|
+
if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
|
|
1331
|
+
const onData = (data) => {
|
|
1332
|
+
send({
|
|
1333
|
+
type: "output",
|
|
1334
|
+
sessionId,
|
|
1335
|
+
data
|
|
1336
|
+
});
|
|
1337
|
+
};
|
|
1338
|
+
const onExit = (exitCode) => {
|
|
1339
|
+
send({
|
|
1340
|
+
type: "exit",
|
|
1341
|
+
sessionId,
|
|
1342
|
+
exitCode
|
|
1343
|
+
});
|
|
1344
|
+
};
|
|
1345
|
+
const onTitle = (title) => {
|
|
1346
|
+
send({
|
|
1347
|
+
type: "title",
|
|
1348
|
+
sessionId,
|
|
1349
|
+
title
|
|
1350
|
+
});
|
|
1351
|
+
};
|
|
1352
|
+
session.on("data", onData);
|
|
1353
|
+
session.on("exit", onExit);
|
|
1354
|
+
session.on("title", onTitle);
|
|
1355
|
+
cleanups.set(sessionId, () => {
|
|
1356
|
+
session.removeListener("data", onData);
|
|
1357
|
+
session.removeListener("exit", onExit);
|
|
1358
|
+
session.removeListener("title", onTitle);
|
|
1359
|
+
cleanups.delete(sessionId);
|
|
1360
|
+
});
|
|
1361
|
+
};
|
|
1362
|
+
ws.on("message", (raw) => {
|
|
1363
|
+
let parsed;
|
|
1364
|
+
try {
|
|
1365
|
+
parsed = JSON.parse(String(raw));
|
|
1366
|
+
} catch {
|
|
1367
|
+
sendError("INVALID_JSON", "Invalid JSON payload");
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
|
|
1371
|
+
if (!parsedMessage.success) {
|
|
1372
|
+
const firstIssue = parsedMessage.error.issues[0]?.message;
|
|
1373
|
+
sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const msg = parsedMessage.data;
|
|
1377
|
+
switch (msg.type) {
|
|
1378
|
+
case "create": {
|
|
1379
|
+
const session = ptyManager.create({
|
|
1380
|
+
cols: msg.cols,
|
|
1381
|
+
rows: msg.rows,
|
|
1382
|
+
command: msg.command,
|
|
1383
|
+
args: msg.args
|
|
1384
|
+
});
|
|
1385
|
+
send({
|
|
1386
|
+
type: "created",
|
|
1387
|
+
requestId: msg.requestId,
|
|
1388
|
+
sessionId: session.id,
|
|
1389
|
+
platform: session.platform
|
|
1390
|
+
});
|
|
1391
|
+
attachToSession(session);
|
|
1392
|
+
break;
|
|
1393
|
+
}
|
|
1394
|
+
case "attach": {
|
|
1395
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1396
|
+
if (!session) {
|
|
1397
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1398
|
+
send({
|
|
1399
|
+
type: "exit",
|
|
1400
|
+
sessionId: msg.sessionId,
|
|
1401
|
+
exitCode: -1
|
|
1402
|
+
});
|
|
1403
|
+
break;
|
|
1404
|
+
}
|
|
1405
|
+
attachToSession(session, {
|
|
1406
|
+
cols: msg.cols,
|
|
1407
|
+
rows: msg.rows
|
|
1408
|
+
});
|
|
1409
|
+
const buffer = session.getBuffer();
|
|
1410
|
+
if (buffer) send({
|
|
1411
|
+
type: "buffer",
|
|
1412
|
+
sessionId: session.id,
|
|
1413
|
+
data: buffer
|
|
1414
|
+
});
|
|
1415
|
+
if (session.title) send({
|
|
1416
|
+
type: "title",
|
|
1417
|
+
sessionId: session.id,
|
|
1418
|
+
title: session.title
|
|
1419
|
+
});
|
|
1420
|
+
if (session.isExited) send({
|
|
1421
|
+
type: "exit",
|
|
1422
|
+
sessionId: session.id,
|
|
1423
|
+
exitCode: session.exitCode ?? -1
|
|
1424
|
+
});
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
case "list":
|
|
1428
|
+
send({
|
|
1429
|
+
type: "list",
|
|
1430
|
+
sessions: ptyManager.list().map((s) => ({
|
|
1431
|
+
id: s.id,
|
|
1432
|
+
title: s.title,
|
|
1433
|
+
command: s.command,
|
|
1434
|
+
args: s.args,
|
|
1435
|
+
platform: s.platform,
|
|
1436
|
+
isExited: s.isExited,
|
|
1437
|
+
exitCode: s.exitCode
|
|
1438
|
+
}))
|
|
1439
|
+
});
|
|
1440
|
+
break;
|
|
1441
|
+
case "input": {
|
|
1442
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1443
|
+
if (!session) {
|
|
1444
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1445
|
+
break;
|
|
1446
|
+
}
|
|
1447
|
+
session.write(msg.data);
|
|
1448
|
+
break;
|
|
1449
|
+
}
|
|
1450
|
+
case "resize": {
|
|
1451
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1452
|
+
if (!session) {
|
|
1453
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1454
|
+
break;
|
|
1455
|
+
}
|
|
1456
|
+
session.resize(msg.cols, msg.rows);
|
|
1457
|
+
break;
|
|
1458
|
+
}
|
|
1459
|
+
case "close": {
|
|
1460
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1461
|
+
if (!session) {
|
|
1462
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1463
|
+
break;
|
|
1464
|
+
}
|
|
1465
|
+
cleanups.get(msg.sessionId)?.();
|
|
1466
|
+
ptyManager.close(session.id);
|
|
1467
|
+
break;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
ws.on("close", () => {
|
|
1472
|
+
for (const cleanup of cleanups.values()) cleanup();
|
|
1473
|
+
cleanups.clear();
|
|
1474
|
+
});
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
553
1478
|
//#endregion
|
|
554
1479
|
//#region src/server.ts
|
|
555
1480
|
/**
|
|
@@ -571,6 +1496,7 @@ function createServer(config) {
|
|
|
571
1496
|
const adapter = new OpenSpecAdapter(config.projectDir);
|
|
572
1497
|
const configManager = new ConfigManager(config.projectDir);
|
|
573
1498
|
const cliExecutor = new CliExecutor(configManager, config.projectDir);
|
|
1499
|
+
const kernel = config.kernel;
|
|
574
1500
|
const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
|
|
575
1501
|
const app = new Hono();
|
|
576
1502
|
const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
|
|
@@ -594,6 +1520,7 @@ function createServer(config) {
|
|
|
594
1520
|
adapter,
|
|
595
1521
|
configManager,
|
|
596
1522
|
cliExecutor,
|
|
1523
|
+
kernel,
|
|
597
1524
|
watcher,
|
|
598
1525
|
projectDir: config.projectDir
|
|
599
1526
|
})
|
|
@@ -603,6 +1530,7 @@ function createServer(config) {
|
|
|
603
1530
|
adapter,
|
|
604
1531
|
configManager,
|
|
605
1532
|
cliExecutor,
|
|
1533
|
+
kernel,
|
|
606
1534
|
watcher,
|
|
607
1535
|
projectDir: config.projectDir
|
|
608
1536
|
});
|
|
@@ -611,34 +1539,46 @@ function createServer(config) {
|
|
|
611
1539
|
adapter,
|
|
612
1540
|
configManager,
|
|
613
1541
|
cliExecutor,
|
|
1542
|
+
kernel,
|
|
614
1543
|
watcher,
|
|
615
1544
|
createContext,
|
|
616
1545
|
port: config.port ?? 3100
|
|
617
1546
|
};
|
|
618
1547
|
}
|
|
619
1548
|
/**
|
|
620
|
-
* Create WebSocket server for tRPC subscriptions
|
|
1549
|
+
* Create WebSocket server for tRPC subscriptions and PTY terminals
|
|
621
1550
|
*/
|
|
622
1551
|
async function createWebSocketServer(server, httpServer, config) {
|
|
623
|
-
await initWatcherPool(config.projectDir);
|
|
1552
|
+
if (!isWatcherPoolInitialized()) await initWatcherPool(config.projectDir);
|
|
624
1553
|
const wss = new WebSocketServer({ noServer: true });
|
|
625
1554
|
const handler = applyWSSHandler({
|
|
626
1555
|
wss,
|
|
627
1556
|
router: appRouter,
|
|
628
1557
|
createContext: server.createContext
|
|
629
1558
|
});
|
|
1559
|
+
const ptyManager = new PtyManager(config.projectDir);
|
|
1560
|
+
const ptyWss = new WebSocketServer({ noServer: true });
|
|
1561
|
+
const ptyHandler = createPtyWebSocketHandler(ptyManager);
|
|
1562
|
+
ptyWss.on("connection", ptyHandler);
|
|
630
1563
|
httpServer.on("upgrade", (...args) => {
|
|
631
1564
|
const [request, socket, head] = args;
|
|
632
|
-
if (request.url?.startsWith("/
|
|
1565
|
+
if (request.url?.startsWith("/ws/pty")) ptyWss.handleUpgrade(request, socket, head, (ws) => {
|
|
1566
|
+
ptyWss.emit("connection", ws, request);
|
|
1567
|
+
});
|
|
1568
|
+
else if (request.url?.startsWith("/trpc")) wss.handleUpgrade(request, socket, head, (ws) => {
|
|
633
1569
|
wss.emit("connection", ws, request);
|
|
634
1570
|
});
|
|
635
1571
|
});
|
|
636
1572
|
server.watcher?.start();
|
|
637
1573
|
return {
|
|
638
1574
|
wss,
|
|
1575
|
+
ptyWss,
|
|
1576
|
+
ptyManager,
|
|
639
1577
|
handler,
|
|
640
1578
|
close: () => {
|
|
641
1579
|
handler.broadcastReconnectNotification();
|
|
1580
|
+
ptyManager.closeAll();
|
|
1581
|
+
ptyWss.close();
|
|
642
1582
|
wss.close();
|
|
643
1583
|
server.watcher?.stop();
|
|
644
1584
|
}
|
|
@@ -655,9 +1595,13 @@ async function createWebSocketServer(server, httpServer, config) {
|
|
|
655
1595
|
async function startServer(config, setupApp) {
|
|
656
1596
|
const preferredPort = config.port ?? 3100;
|
|
657
1597
|
const port = await findAvailablePort(preferredPort);
|
|
1598
|
+
const cliExecutor = new CliExecutor(new ConfigManager(config.projectDir), config.projectDir);
|
|
1599
|
+
const kernel = new OpsxKernel(config.projectDir, cliExecutor);
|
|
1600
|
+
await initWatcherPool(config.projectDir);
|
|
658
1601
|
const server = createServer({
|
|
659
1602
|
...config,
|
|
660
|
-
port
|
|
1603
|
+
port,
|
|
1604
|
+
kernel
|
|
661
1605
|
});
|
|
662
1606
|
if (setupApp) setupApp(server.app);
|
|
663
1607
|
const httpServer = serve({
|
|
@@ -665,11 +1609,16 @@ async function startServer(config, setupApp) {
|
|
|
665
1609
|
port
|
|
666
1610
|
});
|
|
667
1611
|
const wsServer = await createWebSocketServer(server, httpServer, { projectDir: config.projectDir });
|
|
1612
|
+
const url = `http://localhost:${port}`;
|
|
1613
|
+
kernel.warmup().catch((err) => {
|
|
1614
|
+
console.error("Kernel warmup failed:", err);
|
|
1615
|
+
});
|
|
668
1616
|
return {
|
|
669
|
-
url
|
|
1617
|
+
url,
|
|
670
1618
|
port,
|
|
671
1619
|
preferredPort,
|
|
672
1620
|
close: async () => {
|
|
1621
|
+
kernel.dispose();
|
|
673
1622
|
wsServer.close();
|
|
674
1623
|
httpServer.close();
|
|
675
1624
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openspecui/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
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.0"
|
|
22
24
|
},
|
|
23
25
|
"devDependencies": {
|
|
24
26
|
"@types/node": "^22.10.2",
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
},
|
|
32
34
|
"scripts": {
|
|
33
35
|
"build": "tsdown src/index.ts --format esm --no-dts",
|
|
34
|
-
"typecheck": "tsc --noEmit
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
35
37
|
"dev": "tsx watch src/standalone.ts",
|
|
36
38
|
"test": "vitest run",
|
|
37
39
|
"test:watch": "vitest"
|