@spexcode/spec-cli 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/bin/spex.mjs +76 -0
- package/dist/attach.d.ts +2 -0
- package/dist/attach.js +48 -0
- package/dist/claude-headless.d.ts +33 -0
- package/dist/claude-headless.js +307 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1859 -0
- package/dist/client.d.ts +111 -0
- package/dist/client.js +476 -0
- package/dist/cockpit.d.ts +7 -0
- package/dist/cockpit.js +26 -0
- package/dist/codex-headless.d.ts +1 -0
- package/dist/codex-headless.js +7 -0
- package/dist/codex-runtime-generations.d.ts +53 -0
- package/dist/codex-runtime-generations.js +686 -0
- package/dist/commit-surgery.d.ts +1 -0
- package/dist/commit-surgery.js +90 -0
- package/dist/contract-filter.d.ts +14 -0
- package/dist/contract-filter.js +219 -0
- package/dist/dashboard-assets.d.ts +6 -0
- package/dist/dashboard-assets.js +50 -0
- package/dist/delivery-queue.d.ts +23 -0
- package/dist/delivery-queue.js +179 -0
- package/dist/doctor.d.ts +9 -0
- package/dist/doctor.js +547 -0
- package/dist/eval-host.d.ts +1 -0
- package/dist/eval-host.js +19 -0
- package/dist/execution-trace.d.ts +26 -0
- package/dist/execution-trace.js +475 -0
- package/dist/file-write.d.ts +2 -0
- package/dist/file-write.js +26 -0
- package/dist/flat.d.ts +89 -0
- package/dist/flat.js +1076 -0
- package/dist/gateway-auth.d.ts +56 -0
- package/dist/gateway-auth.js +168 -0
- package/dist/gateway-hub.d.ts +28 -0
- package/dist/gateway-hub.js +361 -0
- package/dist/gateway.d.ts +37 -0
- package/dist/gateway.js +503 -0
- package/dist/graphCache.d.ts +27 -0
- package/dist/graphCache.js +716 -0
- package/dist/graphSnapshot.d.ts +14 -0
- package/dist/graphSnapshot.js +35 -0
- package/dist/graphStream.d.ts +85 -0
- package/dist/graphStream.js +1195 -0
- package/dist/guidance-catalog.d.ts +64 -0
- package/dist/guidance-catalog.js +166 -0
- package/dist/guide.d.ts +8 -0
- package/dist/guide.js +684 -0
- package/dist/harness-select.d.ts +18 -0
- package/dist/harness-select.js +62 -0
- package/dist/harness.d.ts +325 -0
- package/dist/harness.js +3026 -0
- package/dist/headless-controller.d.ts +9 -0
- package/dist/headless-controller.js +47 -0
- package/dist/help.d.ts +10 -0
- package/dist/help.js +574 -0
- package/dist/hook-prompts.d.ts +18 -0
- package/dist/hook-prompts.js +122 -0
- package/dist/hooks.d.ts +1 -0
- package/dist/hooks.js +27 -0
- package/dist/host-resources.d.ts +97 -0
- package/dist/host-resources.js +795 -0
- package/dist/host.d.ts +88 -0
- package/dist/host.js +663 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +899 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +284 -0
- package/dist/issues-cli.d.ts +6 -0
- package/dist/issues-cli.js +334 -0
- package/dist/issues.d.ts +69 -0
- package/dist/issues.js +189 -0
- package/dist/lint.d.ts +34 -0
- package/dist/lint.js +476 -0
- package/dist/listen.d.ts +7 -0
- package/dist/listen.js +24 -0
- package/dist/localIssues.d.ts +65 -0
- package/dist/localIssues.js +582 -0
- package/dist/login-page.d.ts +5 -0
- package/dist/login-page.js +86 -0
- package/dist/loop-in.d.ts +23 -0
- package/dist/loop-in.js +68 -0
- package/dist/machine-peer.d.ts +69 -0
- package/dist/machine-peer.js +603 -0
- package/dist/materialize.d.ts +18 -0
- package/dist/materialize.js +468 -0
- package/dist/mentions.d.ts +50 -0
- package/dist/mentions.js +117 -0
- package/dist/opencode-headless.d.ts +4 -0
- package/dist/opencode-headless.js +208 -0
- package/dist/opencode.d.ts +3 -0
- package/dist/opencode.js +142 -0
- package/dist/pi-harness.d.ts +3 -0
- package/dist/pi-harness.js +129 -0
- package/dist/pi-headless.d.ts +25 -0
- package/dist/pi-headless.js +196 -0
- package/dist/plugin-harness.d.ts +21 -0
- package/dist/plugin-harness.js +146 -0
- package/dist/pty-bridge.d.ts +10 -0
- package/dist/pty-bridge.js +404 -0
- package/dist/pty-helper.d.mts +1 -0
- package/dist/pty-helper.mjs +101 -0
- package/dist/pty-native-helper.d.mts +2 -0
- package/dist/pty-native-helper.mjs +23 -0
- package/dist/public-graph.d.ts +46 -0
- package/dist/public-graph.js +56 -0
- package/dist/ranker.d.ts +15 -0
- package/dist/ranker.js +188 -0
- package/dist/reaper.d.ts +6 -0
- package/dist/reaper.js +81 -0
- package/dist/reviews.d.ts +189 -0
- package/dist/reviews.js +350 -0
- package/dist/runtime-guard.d.ts +6 -0
- package/dist/runtime-guard.js +37 -0
- package/dist/runtime-ownership.d.ts +21 -0
- package/dist/runtime-ownership.js +84 -0
- package/dist/search.bench.d.mts +1 -0
- package/dist/search.bench.mjs +75 -0
- package/dist/search.d.ts +20 -0
- package/dist/search.js +61 -0
- package/dist/session-cursors.d.ts +14 -0
- package/dist/session-cursors.js +82 -0
- package/dist/session-declarations.d.ts +13 -0
- package/dist/session-declarations.js +126 -0
- package/dist/session-execution.d.ts +10 -0
- package/dist/session-execution.js +70 -0
- package/dist/session-files.d.ts +28 -0
- package/dist/session-files.js +130 -0
- package/dist/session-follow.d.ts +31 -0
- package/dist/session-follow.js +179 -0
- package/dist/session-reparent.d.ts +10 -0
- package/dist/session-reparent.js +33 -0
- package/dist/session-timeline.d.ts +51 -0
- package/dist/session-timeline.js +233 -0
- package/dist/session-web.d.ts +24 -0
- package/dist/session-web.js +141 -0
- package/dist/sessions.d.ts +412 -0
- package/dist/sessions.js +4239 -0
- package/dist/sh.d.ts +1 -0
- package/dist/sh.js +3 -0
- package/dist/shim-runtime.d.ts +1 -0
- package/dist/shim-runtime.js +161 -0
- package/dist/slash-commands.d.ts +9 -0
- package/dist/slash-commands.js +307 -0
- package/dist/source-files.d.ts +9 -0
- package/dist/source-files.js +113 -0
- package/dist/supervise.d.ts +1 -0
- package/dist/supervise.js +308 -0
- package/dist/tree.d.ts +25 -0
- package/dist/tree.js +98 -0
- package/dist/tsx-bin.d.ts +3 -0
- package/dist/tsx-bin.js +41 -0
- package/dist/uninstall.d.ts +3 -0
- package/dist/uninstall.js +177 -0
- package/dist/uploads.d.ts +36 -0
- package/dist/uploads.js +243 -0
- package/dist/worktree-sources.d.ts +1 -0
- package/dist/worktree-sources.js +47 -0
- package/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/hooks/dispatch.sh +105 -0
- package/hooks/harness.sh +272 -0
- package/package.json +47 -0
- package/templates/hooks/commit-msg +14 -0
- package/templates/hooks/post-checkout +25 -0
- package/templates/hooks/post-merge +34 -0
- package/templates/hooks/pre-commit +120 -0
- package/templates/hooks/prepare-commit-msg +35 -0
- package/templates/hooks/reference-transaction +105 -0
- package/templates/spec/project/.plugins/commands/extract/spec.md +66 -0
- package/templates/spec/project/.plugins/commands/regroup/spec.md +25 -0
- package/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/templates/spec/project/.plugins/commands/spec.md +30 -0
- package/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- package/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
- package/templates/spec/project/.plugins/core/idle/idle.sh +15 -0
- package/templates/spec/project/.plugins/core/idle/spec.md +13 -0
- package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +59 -0
- package/templates/spec/project/.plugins/core/mark-active/spec.md +22 -0
- package/templates/spec/project/.plugins/core/session-fail/fail.sh +12 -0
- package/templates/spec/project/.plugins/core/session-fail/spec.md +15 -0
- package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +43 -0
- package/templates/spec/project/.plugins/core/spec-first/spec.md +17 -0
- package/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +66 -0
- package/templates/spec/project/.plugins/core/spec-of-file/spec.md +15 -0
- package/templates/spec/project/.plugins/core/spec.md +22 -0
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +23 -0
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +149 -0
- package/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
- package/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
- package/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
- package/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
- package/templates/spec/project/.plugins/prompts/spec.md +24 -0
- package/templates/spec/project/.plugins/skills/distill/digest.mjs +136 -0
- package/templates/spec/project/.plugins/skills/distill/spec.md +67 -0
- package/templates/spec/project/.plugins/skills/spec.md +19 -0
- package/templates/spec/project/.plugins/spec.md +23 -0
- package/templates/spec/project/spec.md +20 -0
package/dist/host.js
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { mkdirSync, writeFileSync, readFileSync, renameSync, rmSync, readdirSync, openSync, closeSync, existsSync, realpathSync, statSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, join, basename, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { spexcodeHome, encodeProject } from '@spexcode/spec-core';
|
|
9
|
+
import { git } from '@spexcode/spec-core';
|
|
10
|
+
import { serveStatic, resolveDistDir } from './gateway.js';
|
|
11
|
+
import { startHubGateway } from './gateway-hub.js';
|
|
12
|
+
import { MachinePeerGateway } from './machine-peer.js';
|
|
13
|
+
import { DEFAULT_PROJECT_ICON, requireIdentityChoice } from '@spexcode/spec-core/identity';
|
|
14
|
+
import { resolveProjectIdentity, writeGatewayIcon, } from '@spexcode/spec-core';
|
|
15
|
+
import { cliEntrypointArgs } from './tsx-bin.js';
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
export const endpointRecordPath = (root) => join(spexcodeHome(), 'projects', encodeProject(root), 'backend.json');
|
|
18
|
+
// atomic publish: tmp + rename, so a reader never sees a torn record (the old write-in-place could be
|
|
19
|
+
// caught mid-write by the reconciler or a bare `spex`'s discovery probe).
|
|
20
|
+
export function publishEndpoint(rec) {
|
|
21
|
+
const file = endpointRecordPath(rec.root);
|
|
22
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
23
|
+
const tmp = join(dirname(file), `.backend.json.${process.pid}.tmp`);
|
|
24
|
+
writeFileSync(tmp, JSON.stringify(rec, null, 2) + '\n');
|
|
25
|
+
renameSync(tmp, file);
|
|
26
|
+
}
|
|
27
|
+
// remove the record ONLY if it is ours (matched by instanceId): a newer serve that already overwrote it,
|
|
28
|
+
// or another project's record, is never deleted by a retiring process.
|
|
29
|
+
export function dropOwnEndpoint(instanceId, root) {
|
|
30
|
+
const file = endpointRecordPath(root);
|
|
31
|
+
try {
|
|
32
|
+
if (JSON.parse(readFileSync(file, 'utf8'))?.instanceId === instanceId)
|
|
33
|
+
rmSync(file);
|
|
34
|
+
}
|
|
35
|
+
catch { /* not ours / already gone */ }
|
|
36
|
+
}
|
|
37
|
+
// a record is HOSTABLE only in the full identity shape; legacy {url,pid} records (pre-instance-identity)
|
|
38
|
+
// are ignored by the host — the direct CLI ladder still reads their url, and they are rewritten in the new
|
|
39
|
+
// shape the next time that serve restarts.
|
|
40
|
+
export function readEndpointRecord(file) {
|
|
41
|
+
try {
|
|
42
|
+
const r = JSON.parse(readFileSync(file, 'utf8'));
|
|
43
|
+
if (r?.version === 2 && typeof r.url === 'string' && typeof r.instanceId === 'string' && typeof r.root === 'string' &&
|
|
44
|
+
typeof r.identity?.title === 'string' && typeof r.identity?.icon === 'string')
|
|
45
|
+
return r;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export const catalogPath = () => join(spexcodeHome(), 'projects.json');
|
|
53
|
+
let catalogWarned = false;
|
|
54
|
+
export function readCatalog() {
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = readFileSync(catalogPath(), 'utf8');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
const list = Array.isArray(parsed?.projects) ? parsed.projects : [];
|
|
65
|
+
return list.filter((e) => e && typeof e.root === 'string');
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
// reads degrade loud-but-alive (the reconciler must keep serving live records); WRITES refuse below,
|
|
69
|
+
// so a malformed catalog is never silently clobbered.
|
|
70
|
+
if (!catalogWarned) {
|
|
71
|
+
catalogWarned = true;
|
|
72
|
+
console.error(`[host] malformed ${catalogPath()} (${e.message}) — known-project catalog ignored until fixed`);
|
|
73
|
+
}
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function writeCatalog(entries) {
|
|
78
|
+
if (existsSync(catalogPath())) {
|
|
79
|
+
try {
|
|
80
|
+
JSON.parse(readFileSync(catalogPath(), 'utf8'));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error(`refusing to overwrite malformed ${catalogPath()} — fix or remove it first`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
mkdirSync(dirname(catalogPath()), { recursive: true });
|
|
87
|
+
const tmp = join(dirname(catalogPath()), `.projects.json.${process.pid}.tmp`);
|
|
88
|
+
writeFileSync(tmp, JSON.stringify({ projects: entries }, null, 2) + '\n');
|
|
89
|
+
renameSync(tmp, catalogPath());
|
|
90
|
+
}
|
|
91
|
+
function existingDirectory(dir) {
|
|
92
|
+
let path;
|
|
93
|
+
try {
|
|
94
|
+
path = realpathSync(resolve(dir));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new Error(`${dir} is not an existing directory`);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
if (!statSync(path).isDirectory())
|
|
101
|
+
throw new Error('not a directory');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error(`${dir} is not an existing directory`);
|
|
105
|
+
}
|
|
106
|
+
return path;
|
|
107
|
+
}
|
|
108
|
+
function gitProjectRoot(dir) {
|
|
109
|
+
try {
|
|
110
|
+
return dirname(git(['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim());
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export function browseProjectDirectories(dir) {
|
|
117
|
+
const requested = (dir ?? '').trim() || homedir();
|
|
118
|
+
if (!existsSync(resolve(requested))) {
|
|
119
|
+
const path = resolve(requested);
|
|
120
|
+
return {
|
|
121
|
+
path, exists: false, parent: dirname(path) === path ? null : dirname(path), home: homedir(),
|
|
122
|
+
gitRoot: null, initialized: false, cataloged: false, entries: [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const path = existingDirectory(requested);
|
|
126
|
+
const gitRoot = gitProjectRoot(path);
|
|
127
|
+
const entries = readdirSync(path, { withFileTypes: true })
|
|
128
|
+
.filter((entry) => {
|
|
129
|
+
if (entry.isDirectory())
|
|
130
|
+
return true;
|
|
131
|
+
if (!entry.isSymbolicLink())
|
|
132
|
+
return false;
|
|
133
|
+
try {
|
|
134
|
+
return statSync(join(path, entry.name)).isDirectory();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
.map((entry) => {
|
|
141
|
+
const child = join(path, entry.name);
|
|
142
|
+
const isGit = existsSync(join(child, '.git'));
|
|
143
|
+
return {
|
|
144
|
+
name: entry.name,
|
|
145
|
+
path: child,
|
|
146
|
+
git: isGit,
|
|
147
|
+
initialized: isGit && existsSync(join(child, '.spec')),
|
|
148
|
+
};
|
|
149
|
+
})
|
|
150
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
151
|
+
return {
|
|
152
|
+
path, exists: true,
|
|
153
|
+
parent: dirname(path) === path ? null : dirname(path),
|
|
154
|
+
home: homedir(),
|
|
155
|
+
gitRoot,
|
|
156
|
+
initialized: !!gitRoot && existsSync(join(gitRoot, '.spec')),
|
|
157
|
+
cataloged: !!gitRoot && readCatalog().some((entry) => entry.root === gitRoot),
|
|
158
|
+
entries,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// Register a repo by normalizing any path inside it to its MAIN checkout (the identity every record and
|
|
162
|
+
// store key uses), then dedupe + persist it.
|
|
163
|
+
export function addKnownProject(dir) {
|
|
164
|
+
const path = existingDirectory(dir);
|
|
165
|
+
const root = gitProjectRoot(path);
|
|
166
|
+
if (!root)
|
|
167
|
+
throw new Error(`${dir} is not a git repository — SpexCode projects are git-backed (run \`git init\` there first)`);
|
|
168
|
+
catalogAdd(root);
|
|
169
|
+
return root;
|
|
170
|
+
}
|
|
171
|
+
function catalogAdd(root) {
|
|
172
|
+
const entries = readCatalog();
|
|
173
|
+
if (entries.some((e) => e.root === root))
|
|
174
|
+
return;
|
|
175
|
+
writeCatalog([...entries, { root, addedAt: new Date().toISOString() }]);
|
|
176
|
+
}
|
|
177
|
+
async function fetchInstance(url) {
|
|
178
|
+
const ctrl = new AbortController();
|
|
179
|
+
const t = setTimeout(() => ctrl.abort(), 900);
|
|
180
|
+
try {
|
|
181
|
+
const r = await fetch(`${url}/api/instance`, { signal: ctrl.signal });
|
|
182
|
+
if (!r.ok)
|
|
183
|
+
return null;
|
|
184
|
+
return await r.json();
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
clearTimeout(t);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export async function reconcileProjects() {
|
|
194
|
+
const projectsDir = join(spexcodeHome(), 'projects');
|
|
195
|
+
let dirs = [];
|
|
196
|
+
try {
|
|
197
|
+
dirs = readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
if (e.code !== 'ENOENT')
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
const live = new Map();
|
|
204
|
+
const claimed = new Set(); // roots with a slot-matched record, validated or not
|
|
205
|
+
await Promise.all(dirs.map(async (d) => {
|
|
206
|
+
const rec = readEndpointRecord(join(projectsDir, d, 'backend.json'));
|
|
207
|
+
if (!rec)
|
|
208
|
+
return;
|
|
209
|
+
if (encodeProject(rec.root) !== d)
|
|
210
|
+
return;
|
|
211
|
+
claimed.add(rec.root); // a dead/mismatched record still NAMES a project — listed offline
|
|
212
|
+
const inst = await fetchInstance(rec.url);
|
|
213
|
+
if (inst && inst.instanceId === rec.instanceId && inst.root === rec.root &&
|
|
214
|
+
typeof inst.identity?.title === 'string' && typeof inst.identity?.icon === 'string') {
|
|
215
|
+
live.set(rec.root, { rec, identity: inst.identity });
|
|
216
|
+
}
|
|
217
|
+
}));
|
|
218
|
+
const byId = new Map();
|
|
219
|
+
const push = (root) => {
|
|
220
|
+
if (!existsSync(root))
|
|
221
|
+
return;
|
|
222
|
+
const projectId = encodeProject(root);
|
|
223
|
+
if (byId.has(projectId))
|
|
224
|
+
return; // encodeProject is lossy; first root wins a (pathological) collision
|
|
225
|
+
const active = live.get(root) ?? null;
|
|
226
|
+
const source = readProjectConfig(root);
|
|
227
|
+
let identity;
|
|
228
|
+
try {
|
|
229
|
+
identity = active?.identity ?? resolveProjectIdentity(root, root);
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
console.error(`[host] cannot resolve identity for ${root}: ${e.message}`);
|
|
233
|
+
identity = { title: basename(root), icon: DEFAULT_PROJECT_ICON };
|
|
234
|
+
}
|
|
235
|
+
byId.set(projectId, {
|
|
236
|
+
projectId, root, identity, configRevision: source.revision,
|
|
237
|
+
online: !!active, url: active?.rec.url ?? null,
|
|
238
|
+
...(active ? { pid: active.rec.pid, startedAt: active.rec.startedAt } : {}),
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
for (const e of readCatalog())
|
|
242
|
+
push(e.root);
|
|
243
|
+
for (const root of claimed)
|
|
244
|
+
push(root);
|
|
245
|
+
return [...byId.values()].sort((a, b) => a.identity.title.localeCompare(b.identity.title) || a.root.localeCompare(b.root));
|
|
246
|
+
}
|
|
247
|
+
// single-flight + last-snapshot: every reader (GET, the stream loop, the per-request proxy resolution)
|
|
248
|
+
// shares one in-flight reconcile instead of stampeding probes.
|
|
249
|
+
let snapshot = [];
|
|
250
|
+
let inflight = null;
|
|
251
|
+
export function reconcileNow() {
|
|
252
|
+
return (inflight ??= reconcileProjects()
|
|
253
|
+
.then((list) => { snapshot = list; return list; })
|
|
254
|
+
.finally(() => { inflight = null; }));
|
|
255
|
+
}
|
|
256
|
+
// ── host operations (spawned `spex`, never forked logic) ─────────────────────────────────────────────
|
|
257
|
+
// init / doctor / serve run the EXISTING CLI implementations as child processes with cwd = the project
|
|
258
|
+
// root: the same git/harness/additive guarantees `spex init` gives at a terminal, the same doctor report,
|
|
259
|
+
// the same supervisor `spex serve` boots — no second implementation of any domain semantics. The spawned
|
|
260
|
+
// env is scrubbed of routing state (SPEXCODE_API_URL/PORT/session/instance ids) so a child never inherits
|
|
261
|
+
// the gateway's — or another project's — backend routing.
|
|
262
|
+
const cliArgs = cliEntrypointArgs(join(here, '..'), here);
|
|
263
|
+
function scrubbedEnv() {
|
|
264
|
+
const env = { ...process.env };
|
|
265
|
+
delete env.SPEXCODE_API_URL;
|
|
266
|
+
delete env.PORT;
|
|
267
|
+
delete env.SPEXCODE_SESSION_ID;
|
|
268
|
+
delete env.SPEXCODE_INSTANCE_ID;
|
|
269
|
+
return env;
|
|
270
|
+
}
|
|
271
|
+
export function runSpex(root, args, timeoutMs = 120_000) {
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
const child = spawn(process.execPath, [...cliArgs, ...args], { cwd: root, env: scrubbedEnv() });
|
|
274
|
+
let output = '';
|
|
275
|
+
child.stdout.on('data', (d) => { output += d; });
|
|
276
|
+
child.stderr.on('data', (d) => { output += d; });
|
|
277
|
+
const t = setTimeout(() => { try {
|
|
278
|
+
child.kill('SIGKILL');
|
|
279
|
+
}
|
|
280
|
+
catch { /* gone */ } }, timeoutMs);
|
|
281
|
+
child.on('close', (code) => { clearTimeout(t); resolve({ code, output }); });
|
|
282
|
+
child.on('error', (e) => { clearTimeout(t); resolve({ code: null, output: `${output}\nspawn failed: ${e.message}` }); });
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// One ordered add transaction: select an existing directory or explicitly create one with Git, run the
|
|
286
|
+
// real CLI initializer when requested, and only then claim catalog success.
|
|
287
|
+
export async function addKnownProjectWithSetup(dir, setup = {}) {
|
|
288
|
+
if (setup.createDir && !setup.initGit)
|
|
289
|
+
throw new Error('creating a project directory requires Git initialization');
|
|
290
|
+
let directoryCreated = false;
|
|
291
|
+
let path;
|
|
292
|
+
try {
|
|
293
|
+
path = existingDirectory(dir);
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
if (!setup.createDir)
|
|
297
|
+
throw e;
|
|
298
|
+
mkdirSync(resolve(dir), { recursive: true });
|
|
299
|
+
directoryCreated = true;
|
|
300
|
+
path = existingDirectory(dir);
|
|
301
|
+
}
|
|
302
|
+
let root = gitProjectRoot(path);
|
|
303
|
+
let gitInitialized = false;
|
|
304
|
+
if (!root) {
|
|
305
|
+
if (!setup.initGit)
|
|
306
|
+
throw new Error(`${dir} is not a git repository — enable Git initialization to add it`);
|
|
307
|
+
git(['init', '--quiet', '--', path]);
|
|
308
|
+
gitInitialized = true;
|
|
309
|
+
root = gitProjectRoot(path);
|
|
310
|
+
if (!root)
|
|
311
|
+
throw new Error(`git init completed but ${path} is still not a Git repository`);
|
|
312
|
+
}
|
|
313
|
+
let init;
|
|
314
|
+
if (setup.init) {
|
|
315
|
+
const harness = typeof setup.init.harness === 'string' ? setup.init.harness.trim() : '';
|
|
316
|
+
if (!harness)
|
|
317
|
+
throw new Error('SpexCode initialization requires at least one explicit harness target');
|
|
318
|
+
const preset = typeof setup.init.preset === 'string' ? setup.init.preset.trim() : '';
|
|
319
|
+
const result = await runSpex(root, ['init', '--harness', harness, ...(preset ? ['--preset', preset] : [])]);
|
|
320
|
+
init = result;
|
|
321
|
+
if (result.code !== 0)
|
|
322
|
+
return { ok: false, root, directoryCreated, gitInitialized, init };
|
|
323
|
+
}
|
|
324
|
+
catalogAdd(root);
|
|
325
|
+
return { ok: true, root, directoryCreated, gitInitialized, ...(init ? { init } : {}) };
|
|
326
|
+
}
|
|
327
|
+
let configWriteSeq = 0;
|
|
328
|
+
const configRevision = (raw) => createHash('sha256').update(raw === null ? 'missing' : `present\0${raw}`).digest('hex');
|
|
329
|
+
function readProjectConfig(root) {
|
|
330
|
+
try {
|
|
331
|
+
const content = readFileSync(join(root, 'spexcode.json'), 'utf8');
|
|
332
|
+
return { content, revision: configRevision(content) };
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
if (e.code === 'ENOENT')
|
|
336
|
+
return { content: '{}\n', revision: configRevision(null) };
|
|
337
|
+
throw e;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function writeProjectConfig(root, content, revision) {
|
|
341
|
+
const current = readProjectConfig(root);
|
|
342
|
+
if (revision !== current.revision) {
|
|
343
|
+
const e = new Error('spexcode.json changed on disk — reload before saving');
|
|
344
|
+
e.status = 409;
|
|
345
|
+
throw e;
|
|
346
|
+
}
|
|
347
|
+
let parsed;
|
|
348
|
+
try {
|
|
349
|
+
parsed = JSON.parse(content);
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
const err = new Error(`spexcode.json is not valid JSON: ${e.message}`);
|
|
353
|
+
err.status = 400;
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
356
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
357
|
+
const e = new Error('spexcode.json must contain one top-level JSON object');
|
|
358
|
+
e.status = 400;
|
|
359
|
+
throw e;
|
|
360
|
+
}
|
|
361
|
+
const normalized = content.endsWith('\n') ? content : `${content}\n`;
|
|
362
|
+
const file = join(root, 'spexcode.json');
|
|
363
|
+
const tmp = join(root, `.spexcode.json.${process.pid}.${++configWriteSeq}.tmp`);
|
|
364
|
+
try {
|
|
365
|
+
writeFileSync(tmp, normalized);
|
|
366
|
+
renameSync(tmp, file);
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
try {
|
|
370
|
+
rmSync(tmp);
|
|
371
|
+
}
|
|
372
|
+
catch { /* rename consumed it / write never created it */ }
|
|
373
|
+
}
|
|
374
|
+
return { content: normalized, revision: configRevision(normalized) };
|
|
375
|
+
}
|
|
376
|
+
function writeProjectIcon(root, icon, revision) {
|
|
377
|
+
const canonical = requireIdentityChoice(icon);
|
|
378
|
+
const current = readProjectConfig(root);
|
|
379
|
+
let config;
|
|
380
|
+
try {
|
|
381
|
+
config = JSON.parse(current.content);
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
const error = new Error(`spexcode.json is not valid JSON: ${e.message}`);
|
|
385
|
+
error.status = 400;
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
const dashboard = config.dashboard === undefined
|
|
389
|
+
? {}
|
|
390
|
+
: config.dashboard && typeof config.dashboard === 'object' && !Array.isArray(config.dashboard)
|
|
391
|
+
? config.dashboard
|
|
392
|
+
: null;
|
|
393
|
+
if (!dashboard) {
|
|
394
|
+
const error = new Error('spexcode.json dashboard must be an object before its icon can be changed');
|
|
395
|
+
error.status = 400;
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
const saved = writeProjectConfig(root, `${JSON.stringify({ ...config, dashboard: { ...dashboard, icon: canonical } }, null, 2)}\n`, revision);
|
|
399
|
+
return { ...saved, identity: resolveProjectIdentity(root, root) };
|
|
400
|
+
}
|
|
401
|
+
function freeTcpPort() {
|
|
402
|
+
return new Promise((res, rej) => {
|
|
403
|
+
const s = net.createServer();
|
|
404
|
+
s.once('error', rej);
|
|
405
|
+
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
// start an OFFLINE project's backend: spawn a detached `spex serve --port <free>` whose lifetime is
|
|
409
|
+
// independent of the gateway (it owns its record exactly like a hand-run serve), log to the project's
|
|
410
|
+
// runtime tier, and wait for its instance-validated record to reconcile online.
|
|
411
|
+
export async function startBackend(root, waitMs = 45_000) {
|
|
412
|
+
const port = await freeTcpPort();
|
|
413
|
+
const logDir = join(spexcodeHome(), 'projects', encodeProject(root));
|
|
414
|
+
mkdirSync(logDir, { recursive: true });
|
|
415
|
+
const logFile = join(logDir, 'serve.log');
|
|
416
|
+
const log = openSync(logFile, 'a');
|
|
417
|
+
const child = spawn(process.execPath, [...cliArgs, 'serve', '--port', String(port)], { cwd: root, env: scrubbedEnv(), detached: true, stdio: ['ignore', log, log] });
|
|
418
|
+
child.unref();
|
|
419
|
+
closeSync(log); // the child holds its own copy; keep no fd open in the gateway
|
|
420
|
+
const deadline = Date.now() + waitMs;
|
|
421
|
+
while (Date.now() < deadline) {
|
|
422
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
423
|
+
const entry = (await reconcileNow()).find((p) => p.root === root && p.online);
|
|
424
|
+
if (entry)
|
|
425
|
+
return entry;
|
|
426
|
+
}
|
|
427
|
+
throw new Error(`backend for ${root} did not come online within ${Math.round(waitMs / 1000)}s — see ${logFile}`);
|
|
428
|
+
}
|
|
429
|
+
const json = (res, status, body) => {
|
|
430
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
431
|
+
res.end(JSON.stringify(body));
|
|
432
|
+
};
|
|
433
|
+
function readBody(req) {
|
|
434
|
+
return new Promise((resolve) => {
|
|
435
|
+
let body = '';
|
|
436
|
+
req.on('data', (d) => { body += d; if (body.length > 65536)
|
|
437
|
+
req.destroy(); });
|
|
438
|
+
req.on('end', () => resolve(body));
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
export function startHostDashboard(opts) {
|
|
442
|
+
const distDir = opts.distDir ?? resolveDistDir();
|
|
443
|
+
const peers = new MachinePeerGateway();
|
|
444
|
+
peers.start();
|
|
445
|
+
const sseClients = new Set();
|
|
446
|
+
let lastBroadcast = '';
|
|
447
|
+
// reconcile → push the fresh list to every /projects/stream subscriber when it changed.
|
|
448
|
+
async function tick() {
|
|
449
|
+
const list = await reconcileNow();
|
|
450
|
+
const j = JSON.stringify(list);
|
|
451
|
+
if (j !== lastBroadcast) {
|
|
452
|
+
lastBroadcast = j;
|
|
453
|
+
for (const c of sseClients)
|
|
454
|
+
c.write(`data: ${j}\n\n`);
|
|
455
|
+
}
|
|
456
|
+
return list;
|
|
457
|
+
}
|
|
458
|
+
const extensions = {
|
|
459
|
+
// the hub's GET /projects rows, host-enriched: every cataloged/claimed project (not only live
|
|
460
|
+
// records), instance-validated online state, and the hub's own gating flag per row. `id` mirrors
|
|
461
|
+
// projectId — the hub's documented row key, kept so both shapes read the same list.
|
|
462
|
+
listProjects: async (store) => (await tick()).map((p) => ({ id: p.projectId, ...p, gated: !!store.projects[p.projectId] })),
|
|
463
|
+
adminRoute: async (req, res, path) => {
|
|
464
|
+
if (path === '/projects/stream') {
|
|
465
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
466
|
+
res.write(`data: ${JSON.stringify(await tick())}\n\n`);
|
|
467
|
+
sseClients.add(res);
|
|
468
|
+
req.on('close', () => sseClients.delete(res));
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
if (path === '/projects/browse' && req.method === 'GET') {
|
|
472
|
+
const requested = new URL(req.url ?? path, 'http://localhost').searchParams.get('path') ?? '';
|
|
473
|
+
try {
|
|
474
|
+
json(res, 200, browseProjectDirectories(requested));
|
|
475
|
+
}
|
|
476
|
+
catch (e) {
|
|
477
|
+
json(res, 400, { error: e.message });
|
|
478
|
+
}
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
if (path === '/projects' && req.method === 'POST') {
|
|
482
|
+
let body = {};
|
|
483
|
+
try {
|
|
484
|
+
body = JSON.parse(await readBody(req) || '{}');
|
|
485
|
+
}
|
|
486
|
+
catch { /* malformed body */ }
|
|
487
|
+
const root = String(body?.root ?? '').trim();
|
|
488
|
+
if (!root) {
|
|
489
|
+
json(res, 400, { error: 'body must be {"root": "/abs/path/to/repo"}' });
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
if (body.init !== undefined && (!body.init || typeof body.init !== 'object' || Array.isArray(body.init))) {
|
|
493
|
+
json(res, 400, { error: 'init must be {"harness":"<ids>","preset":"<optional>"}' });
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
const setup = await addKnownProjectWithSetup(root, {
|
|
498
|
+
createDir: body.createDir === true,
|
|
499
|
+
initGit: body.initGit === true,
|
|
500
|
+
...(body.init ? { init: { harness: body.init.harness, ...(body.init.preset !== undefined ? { preset: body.init.preset } : {}) } } : {}),
|
|
501
|
+
});
|
|
502
|
+
if (!setup.ok) {
|
|
503
|
+
json(res, 422, { error: 'spex init failed', ...setup });
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
const entry = (await tick()).find((p) => p.root === setup.root);
|
|
507
|
+
json(res, 200, { ...(entry ?? {
|
|
508
|
+
projectId: encodeProject(setup.root), root: setup.root,
|
|
509
|
+
identity: resolveProjectIdentity(setup.root, setup.root), configRevision: readProjectConfig(setup.root).revision,
|
|
510
|
+
online: false, url: null,
|
|
511
|
+
}), setup });
|
|
512
|
+
}
|
|
513
|
+
catch (e) {
|
|
514
|
+
json(res, 400, { error: e.message });
|
|
515
|
+
}
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
if (path === '/projects/icon' && req.method === 'PUT') {
|
|
519
|
+
let body;
|
|
520
|
+
try {
|
|
521
|
+
body = JSON.parse(await readBody(req) || '{}');
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
json(res, 400, { error: 'body must be {"icon":"<choice>","revision":"..."}' });
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
if (typeof body?.icon !== 'string' || typeof body?.revision !== 'string') {
|
|
528
|
+
json(res, 400, { error: 'body must be {"icon":"<choice>","revision":"..."}' });
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
json(res, 200, { ok: true, gateway: writeGatewayIcon(body.icon, body.revision) });
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
json(res, e.status ?? 400, { error: e.message });
|
|
536
|
+
}
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
const icon = path.match(/^\/projects\/([^/]+)\/icon$/);
|
|
540
|
+
if (icon && req.method === 'PUT') {
|
|
541
|
+
const projectId = decodeURIComponent(icon[1]);
|
|
542
|
+
const entry = (await reconcileNow()).find((p) => p.projectId === projectId);
|
|
543
|
+
if (!entry) {
|
|
544
|
+
json(res, 404, { error: `unknown project '${projectId}' — add it first (POST /projects)` });
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
let body;
|
|
548
|
+
try {
|
|
549
|
+
body = JSON.parse(await readBody(req) || '{}');
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
json(res, 400, { error: 'body must be {"icon":"<choice>","revision":"..."}' });
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
if (typeof body?.icon !== 'string' || typeof body?.revision !== 'string') {
|
|
556
|
+
json(res, 400, { error: 'body must be {"icon":"<choice>","revision":"..."}' });
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
json(res, 200, { ok: true, ...writeProjectIcon(entry.root, body.icon, body.revision) });
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
json(res, e.status ?? 400, { error: e.message });
|
|
564
|
+
}
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
const config = path.match(/^\/projects\/([^/]+)\/config$/);
|
|
568
|
+
if (config && (req.method === 'GET' || req.method === 'PUT')) {
|
|
569
|
+
const projectId = decodeURIComponent(config[1]);
|
|
570
|
+
const entry = (await reconcileNow()).find((p) => p.projectId === projectId);
|
|
571
|
+
if (!entry) {
|
|
572
|
+
json(res, 404, { error: `unknown project '${projectId}' — add it first (POST /projects)` });
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
if (req.method === 'GET') {
|
|
576
|
+
try {
|
|
577
|
+
json(res, 200, readProjectConfig(entry.root));
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
json(res, 500, { error: `cannot read spexcode.json: ${e.message}` });
|
|
581
|
+
}
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
let body;
|
|
585
|
+
try {
|
|
586
|
+
body = JSON.parse(await readBody(req) || '{}');
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
json(res, 400, { error: 'body must be {"content":"...","revision":"..."}' });
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
if (typeof body?.content !== 'string' || typeof body?.revision !== 'string') {
|
|
593
|
+
json(res, 400, { error: 'body must be {"content":"...","revision":"..."}' });
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
json(res, 200, { ok: true, ...writeProjectConfig(entry.root, body.content, body.revision) });
|
|
598
|
+
}
|
|
599
|
+
catch (e) {
|
|
600
|
+
json(res, e.status ?? 500, { error: e.message });
|
|
601
|
+
}
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
const op = path.match(/^\/projects\/([^/]+)\/(init|doctor|serve)$/);
|
|
605
|
+
if (op && req.method === 'POST') {
|
|
606
|
+
const projectId = decodeURIComponent(op[1]);
|
|
607
|
+
const entry = (await reconcileNow()).find((p) => p.projectId === projectId);
|
|
608
|
+
if (!entry) {
|
|
609
|
+
json(res, 404, { error: `unknown project '${projectId}' — add it first (POST /projects)` });
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
if (op[2] === 'serve') {
|
|
613
|
+
if (entry.online) {
|
|
614
|
+
json(res, 409, { error: `backend already online at ${entry.url}`, project: entry });
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
json(res, 200, { ok: true, project: await startBackend(entry.root) });
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
json(res, 502, { error: e.message });
|
|
622
|
+
}
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
let body = {};
|
|
626
|
+
try {
|
|
627
|
+
body = JSON.parse(await readBody(req) || '{}');
|
|
628
|
+
}
|
|
629
|
+
catch { /* malformed body → defaults */ }
|
|
630
|
+
const args = op[2] === 'init'
|
|
631
|
+
? ['init', ...(body?.harness ? ['--harness', String(body.harness)] : []), ...(body?.preset ? ['--preset', String(body.preset)] : [])]
|
|
632
|
+
: ['doctor'];
|
|
633
|
+
const r = await runSpex(entry.root, args);
|
|
634
|
+
json(res, 200, { ok: r.code === 0, code: r.code, output: r.output });
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
return false;
|
|
638
|
+
},
|
|
639
|
+
fallback: (req, res, path) => serveStatic(req, res, distDir, path),
|
|
640
|
+
};
|
|
641
|
+
const server = startHubGateway({ port: opts.port, host: opts.host ?? '127.0.0.1', tls: opts.tls ?? null, extensions });
|
|
642
|
+
// continuous reconciliation: the stream stays live without a client-side poll and the list the hub
|
|
643
|
+
// serves stays fresh. Heartbeat comments keep intermediaries from timing the stream out. Both timers
|
|
644
|
+
// unref'd — the SERVER holds the process open, not the loops.
|
|
645
|
+
const loop = setInterval(() => void tick().catch((e) => console.error(`[host] reconcile failed: ${e.message}`)), 2500);
|
|
646
|
+
loop.unref();
|
|
647
|
+
const ping = setInterval(() => { for (const c of sseClients)
|
|
648
|
+
c.write(': ping\n\n'); }, 10_000);
|
|
649
|
+
ping.unref();
|
|
650
|
+
return {
|
|
651
|
+
server,
|
|
652
|
+
close: async () => {
|
|
653
|
+
clearInterval(loop);
|
|
654
|
+
clearInterval(ping);
|
|
655
|
+
for (const c of sseClients)
|
|
656
|
+
c.destroy();
|
|
657
|
+
sseClients.clear();
|
|
658
|
+
await peers.close();
|
|
659
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
660
|
+
server.closeAllConnections?.();
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|