@corenel/sidecar 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -40
- package/dist/cli.js +405 -41
- package/package.json +41 -41
package/README.md
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
# @corenel/sidecar
|
|
2
|
-
|
|
3
|
-
A small local capability server for [Prompd](https://prompd.app). Run it next to the
|
|
4
|
-
web app and the browser can reach your machine — most usefully, your **local LLMs**
|
|
5
|
-
(Ollama, LM Studio, vLLM) **without CORS**, plus your filesystem and (opt-in) shell.
|
|
6
|
-
|
|
7
|
-
It runs only while the command is open, binds to loopback by default, and pairs with
|
|
8
|
-
a token the app reads once.
|
|
9
|
-
|
|
10
|
-
## Use
|
|
11
|
-
|
|
12
|
-
In Prompd, open **Connect a sidecar** and follow the steps, or run it directly:
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
npx @corenel/sidecar --allow-origin https://prompd.app
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
It prints a pairing token; paste that into the app. The `--allow-origin` flag must
|
|
19
|
-
match the site you're connecting from (the app fills it in for you).
|
|
20
|
-
|
|
21
|
-
## Options
|
|
22
|
-
|
|
23
|
-
| Flag | Default | What |
|
|
24
|
-
|------|---------|------|
|
|
25
|
-
| `--allow-origin <url>` | _(none)_ | Browser origin allowed to connect. Required for a web page. Repeatable. |
|
|
26
|
-
| `--port <n>` | `4858` | Port to listen on. |
|
|
27
|
-
| `--root <dir>` | cwd | Root the file tools are confined to. |
|
|
28
|
-
| `--allow-shell` | off | Enable the `shell_exec` tool (RCE on your machine — opt-in). |
|
|
29
|
-
| `--host <addr>` | `127.0.0.1` | Bind address. Non-loopback is reachable off-box; keep the token secret. |
|
|
30
|
-
| `--tls-cert <f>` `--tls-key <f>` | _(none)_ | Serve `wss://` (needed for a remote sidecar from an https page). |
|
|
31
|
-
|
|
32
|
-
## Security
|
|
33
|
-
|
|
34
|
-
- Loopback by default; a browser connection requires both the pairing token **and** an
|
|
35
|
-
allowed Origin.
|
|
36
|
-
- The HTTP proxy (for local LLMs) only reaches **loopback** hosts — it can't be used as
|
|
37
|
-
an SSRF pivot to internal services.
|
|
38
|
-
- `shell_exec` is off unless you pass `--allow-shell`.
|
|
39
|
-
|
|
40
|
-
Licensed under Elastic-2.0.
|
|
1
|
+
# @corenel/sidecar
|
|
2
|
+
|
|
3
|
+
A small local capability server for [Prompd](https://prompd.app). Run it next to the
|
|
4
|
+
web app and the browser can reach your machine — most usefully, your **local LLMs**
|
|
5
|
+
(Ollama, LM Studio, vLLM) **without CORS**, plus your filesystem and (opt-in) shell.
|
|
6
|
+
|
|
7
|
+
It runs only while the command is open, binds to loopback by default, and pairs with
|
|
8
|
+
a token the app reads once.
|
|
9
|
+
|
|
10
|
+
## Use
|
|
11
|
+
|
|
12
|
+
In Prompd, open **Connect a sidecar** and follow the steps, or run it directly:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx @corenel/sidecar --allow-origin https://prompd.app
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It prints a pairing token; paste that into the app. The `--allow-origin` flag must
|
|
19
|
+
match the site you're connecting from (the app fills it in for you).
|
|
20
|
+
|
|
21
|
+
## Options
|
|
22
|
+
|
|
23
|
+
| Flag | Default | What |
|
|
24
|
+
|------|---------|------|
|
|
25
|
+
| `--allow-origin <url>` | _(none)_ | Browser origin allowed to connect. Required for a web page. Repeatable. |
|
|
26
|
+
| `--port <n>` | `4858` | Port to listen on. |
|
|
27
|
+
| `--root <dir>` | cwd | Root the file tools are confined to. |
|
|
28
|
+
| `--allow-shell` | off | Enable the `shell_exec` tool (RCE on your machine — opt-in). |
|
|
29
|
+
| `--host <addr>` | `127.0.0.1` | Bind address. Non-loopback is reachable off-box; keep the token secret. |
|
|
30
|
+
| `--tls-cert <f>` `--tls-key <f>` | _(none)_ | Serve `wss://` (needed for a remote sidecar from an https page). |
|
|
31
|
+
|
|
32
|
+
## Security
|
|
33
|
+
|
|
34
|
+
- Loopback by default; a browser connection requires both the pairing token **and** an
|
|
35
|
+
allowed Origin.
|
|
36
|
+
- The HTTP proxy (for local LLMs) only reaches **loopback** hosts — it can't be used as
|
|
37
|
+
an SSRF pivot to internal services.
|
|
38
|
+
- `shell_exec` is off unless you pass `--allow-shell`.
|
|
39
|
+
|
|
40
|
+
Licensed under Elastic-2.0.
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);
|
|
2
3
|
|
|
3
4
|
// src/cli.ts
|
|
4
5
|
import { resolve as resolve2 } from "node:path";
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
6
|
+
import { readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
6
8
|
|
|
7
9
|
// src/server.ts
|
|
8
10
|
import { WebSocketServer } from "ws";
|
|
@@ -76,16 +78,19 @@ var SidecarServer = class {
|
|
|
76
78
|
}
|
|
77
79
|
onHello(m) {
|
|
78
80
|
if (m.protocol !== SIDECAR_PROTOCOL) {
|
|
81
|
+
this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
|
|
79
82
|
this.ch.send({ t: "denied", reason: `protocol ${m.protocol} unsupported` });
|
|
80
83
|
return;
|
|
81
84
|
}
|
|
82
85
|
if (!safeEqual(m.token ?? "", this.opts.token)) {
|
|
86
|
+
this.opts.log?.("denied: invalid token");
|
|
83
87
|
this.ch.send({ t: "denied", reason: "invalid token" });
|
|
84
88
|
return;
|
|
85
89
|
}
|
|
86
90
|
this.authed = true;
|
|
91
|
+
this.opts.log?.(`paired \u2014 ${this.tools.size} ${this.tools.size === 1 ? "tool" : "tools"} advertised`);
|
|
87
92
|
const tools = [...this.tools.values()].map(({ run: _run, ...decl }) => decl);
|
|
88
|
-
this.ch.send({ t: "welcome", tools, features: ["http-proxy"] });
|
|
93
|
+
this.ch.send({ t: "welcome", tools, features: ["http-proxy"], ...this.opts.info ? { info: this.opts.info } : {} });
|
|
89
94
|
}
|
|
90
95
|
/** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
|
|
91
96
|
* sidecar must never be usable as an SSRF pivot to internal/metadata hosts. The
|
|
@@ -122,13 +127,16 @@ var SidecarServer = class {
|
|
|
122
127
|
async onInvoke(m) {
|
|
123
128
|
const tool = this.tools.get(m.name);
|
|
124
129
|
if (!tool) {
|
|
130
|
+
this.opts.log?.(`invoke ${m.name}: unknown tool`);
|
|
125
131
|
this.ch.send({ t: "invoke-result", id: m.id, ok: false, error: `unknown tool ${m.name}` });
|
|
126
132
|
return;
|
|
127
133
|
}
|
|
134
|
+
this.opts.log?.(`invoke ${m.name}`);
|
|
128
135
|
try {
|
|
129
136
|
const result = await tool.run(m.args, this.makeContext());
|
|
130
137
|
this.ch.send({ t: "invoke-result", id: m.id, ok: true, result });
|
|
131
138
|
} catch (e) {
|
|
139
|
+
this.opts.log?.(`invoke ${m.name} failed: ${errMessage(e)}`);
|
|
132
140
|
this.ch.send({ t: "invoke-result", id: m.id, ok: false, error: errMessage(e) });
|
|
133
141
|
}
|
|
134
142
|
}
|
|
@@ -228,16 +236,42 @@ function wsChannel(ws) {
|
|
|
228
236
|
};
|
|
229
237
|
}
|
|
230
238
|
|
|
239
|
+
// src/lanOrigin.ts
|
|
240
|
+
function isPrivateLanOrigin(origin) {
|
|
241
|
+
let host;
|
|
242
|
+
try {
|
|
243
|
+
host = new URL(origin).hostname;
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const h = host.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
248
|
+
if (h === "localhost" || h.endsWith(".localhost")) return true;
|
|
249
|
+
if (h.includes(":")) return h === "::1" || /^f[cd]/.test(h) || /^fe[89ab]/.test(h);
|
|
250
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
|
|
251
|
+
if (!m) return false;
|
|
252
|
+
const a = Number(m[1]);
|
|
253
|
+
const b = Number(m[2]);
|
|
254
|
+
return a === 127 || a === 10 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254;
|
|
255
|
+
}
|
|
256
|
+
|
|
231
257
|
// src/server.ts
|
|
232
258
|
function startSidecar(opts) {
|
|
233
259
|
const host = opts.host ?? "127.0.0.1";
|
|
234
260
|
const allowed = new Set(opts.allowedOrigins ?? []);
|
|
235
|
-
const verifyClient = (
|
|
236
|
-
|
|
237
|
-
|
|
261
|
+
const verifyClient = (info) => {
|
|
262
|
+
const origin = info.origin;
|
|
263
|
+
const ip = info.req.socket?.remoteAddress || "?";
|
|
264
|
+
const lan = !!origin && !!opts.allowLan && isPrivateLanOrigin(origin);
|
|
265
|
+
const ok = !origin || allowed.has(origin) || lan;
|
|
266
|
+
opts.log?.(ok ? `upgrade allowed \u2014 origin ${origin || "(native client)"}${lan ? " (LAN)" : ""} (${ip})` : `upgrade REFUSED \u2014 origin ${origin} not in allowlist [${[...allowed].join(", ") || "(none)"}]${opts.allowLan ? "" : " (pass --allow-lan to accept any LAN origin)"} (${ip})`);
|
|
267
|
+
return ok;
|
|
238
268
|
};
|
|
239
|
-
const onConnection = (ws,
|
|
240
|
-
|
|
269
|
+
const onConnection = (ws, req) => {
|
|
270
|
+
const origin = req.headers.origin || "(native client)";
|
|
271
|
+
const ip = req.socket.remoteAddress || "?";
|
|
272
|
+
opts.log?.(`client connected \u2014 ${origin} (${ip})`);
|
|
273
|
+
ws.on("close", () => opts.log?.(`client disconnected \u2014 ${origin}`));
|
|
274
|
+
new SidecarServer({ channel: wsChannel(ws), token: opts.token, tools: opts.tools, info: opts.info, log: opts.log });
|
|
241
275
|
};
|
|
242
276
|
if (opts.tls) {
|
|
243
277
|
const httpsServer = createHttpsServer({ cert: opts.tls.cert, key: opts.tls.key });
|
|
@@ -284,42 +318,47 @@ function withinRoot(root, abs) {
|
|
|
284
318
|
const rel = relative(root, abs);
|
|
285
319
|
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
|
|
286
320
|
}
|
|
287
|
-
function
|
|
288
|
-
const
|
|
289
|
-
|
|
321
|
+
function activeRoot(roots, rootArg) {
|
|
322
|
+
const r = typeof rootArg === "string" ? rootArg : "";
|
|
323
|
+
return roots.includes(r) ? r : roots[0];
|
|
324
|
+
}
|
|
325
|
+
function confine(roots, rootArg, p) {
|
|
326
|
+
const abs = resolve(activeRoot(roots, rootArg), p);
|
|
327
|
+
if (!roots.some((root) => withinRoot(root, abs))) throw new Error(`path escapes the sidecar roots: ${p}`);
|
|
290
328
|
return abs;
|
|
291
329
|
}
|
|
292
|
-
async function assertRealWithinRoot(
|
|
293
|
-
const
|
|
330
|
+
async function assertRealWithinRoot(roots, abs) {
|
|
331
|
+
const realRoots = await Promise.all(roots.map((r) => fs.realpath(r)));
|
|
294
332
|
let probe = abs;
|
|
295
333
|
for (; ; ) {
|
|
296
334
|
try {
|
|
297
335
|
const real = await fs.realpath(probe);
|
|
298
|
-
if (!withinRoot(
|
|
336
|
+
if (!realRoots.some((rr) => withinRoot(rr, real))) throw new Error(`path escapes the sidecar roots via symlink: ${abs}`);
|
|
299
337
|
return;
|
|
300
338
|
} catch (e) {
|
|
301
339
|
if (e instanceof Error && e.message.includes("via symlink")) throw e;
|
|
302
340
|
const parent = dirname(probe);
|
|
303
|
-
if (parent === probe) throw new Error(`cannot resolve path under the sidecar
|
|
341
|
+
if (parent === probe) throw new Error(`cannot resolve path under the sidecar roots: ${abs}`);
|
|
304
342
|
probe = parent;
|
|
305
343
|
}
|
|
306
344
|
}
|
|
307
345
|
}
|
|
346
|
+
var ROOT_PARAM = { type: "string", description: "which pre-approved root to resolve the path against (its absolute path; defaults to the primary root)" };
|
|
308
347
|
var MAX_READ_BYTES = 16 * 1024 * 1024;
|
|
309
|
-
function nodeFsTools(
|
|
348
|
+
function nodeFsTools(roots) {
|
|
310
349
|
return [
|
|
311
350
|
{
|
|
312
351
|
name: "fs_read_file",
|
|
313
|
-
description: "Read a UTF-8 text file from the sidecar machine, relative to
|
|
352
|
+
description: "Read a UTF-8 text file from the sidecar machine, relative to a pre-approved root.",
|
|
314
353
|
parameters: {
|
|
315
354
|
type: "object",
|
|
316
|
-
properties: { path: { type: "string", description: "path relative to the
|
|
355
|
+
properties: { path: { type: "string", description: "path relative to the chosen root" }, root: ROOT_PARAM },
|
|
317
356
|
required: ["path"]
|
|
318
357
|
},
|
|
319
358
|
namespace: "fs",
|
|
320
359
|
async run(args) {
|
|
321
|
-
const abs = confine(root, String(args.path ?? ""));
|
|
322
|
-
await assertRealWithinRoot(
|
|
360
|
+
const abs = confine(roots, args.root, String(args.path ?? ""));
|
|
361
|
+
await assertRealWithinRoot(roots, abs);
|
|
323
362
|
const st = await fs.stat(abs);
|
|
324
363
|
if (st.size > MAX_READ_BYTES) {
|
|
325
364
|
throw new Error(`file too large to read (${st.size} bytes; max ${MAX_READ_BYTES})`);
|
|
@@ -329,36 +368,56 @@ function nodeFsTools(root) {
|
|
|
329
368
|
},
|
|
330
369
|
{
|
|
331
370
|
name: "fs_write_file",
|
|
332
|
-
description: "Write a UTF-8 text file on the sidecar machine, relative to
|
|
371
|
+
description: "Write a UTF-8 text file on the sidecar machine, relative to a pre-approved root.",
|
|
333
372
|
parameters: {
|
|
334
373
|
type: "object",
|
|
335
374
|
properties: {
|
|
336
|
-
path: { type: "string", description: "path relative to the
|
|
337
|
-
content: { type: "string" }
|
|
375
|
+
path: { type: "string", description: "path relative to the chosen root" },
|
|
376
|
+
content: { type: "string" },
|
|
377
|
+
root: ROOT_PARAM
|
|
338
378
|
},
|
|
339
379
|
required: ["path", "content"]
|
|
340
380
|
},
|
|
341
381
|
namespace: "fs",
|
|
342
382
|
mutates: true,
|
|
343
383
|
async run(args) {
|
|
344
|
-
const abs = confine(root, String(args.path ?? ""));
|
|
384
|
+
const abs = confine(roots, args.root, String(args.path ?? ""));
|
|
345
385
|
await fs.mkdir(dirname(abs), { recursive: true });
|
|
346
|
-
await assertRealWithinRoot(
|
|
386
|
+
await assertRealWithinRoot(roots, abs);
|
|
347
387
|
await fs.writeFile(abs, String(args.content ?? ""), "utf8");
|
|
348
388
|
return `wrote ${args.path}`;
|
|
349
389
|
}
|
|
350
390
|
},
|
|
391
|
+
{
|
|
392
|
+
name: "fs_read_bytes",
|
|
393
|
+
description: "Read a file from the sidecar machine as base64 (for previewing media/binary the text read would mangle), relative to a pre-approved root.",
|
|
394
|
+
parameters: {
|
|
395
|
+
type: "object",
|
|
396
|
+
properties: { path: { type: "string", description: "path relative to the chosen root" }, root: ROOT_PARAM },
|
|
397
|
+
required: ["path"]
|
|
398
|
+
},
|
|
399
|
+
namespace: "fs",
|
|
400
|
+
async run(args) {
|
|
401
|
+
const abs = confine(roots, args.root, String(args.path ?? ""));
|
|
402
|
+
await assertRealWithinRoot(roots, abs);
|
|
403
|
+
const st = await fs.stat(abs);
|
|
404
|
+
if (st.size > MAX_READ_BYTES) {
|
|
405
|
+
throw new Error(`file too large to read (${st.size} bytes; max ${MAX_READ_BYTES})`);
|
|
406
|
+
}
|
|
407
|
+
return (await fs.readFile(abs)).toString("base64");
|
|
408
|
+
}
|
|
409
|
+
},
|
|
351
410
|
{
|
|
352
411
|
name: "fs_list_dir",
|
|
353
|
-
description: "List the entries of a directory on the sidecar machine, relative to
|
|
412
|
+
description: "List the entries of a directory on the sidecar machine, relative to a pre-approved root.",
|
|
354
413
|
parameters: {
|
|
355
414
|
type: "object",
|
|
356
|
-
properties: { path: { type: "string", description: 'directory relative to the
|
|
415
|
+
properties: { path: { type: "string", description: 'directory relative to the chosen root (default ".")' }, root: ROOT_PARAM }
|
|
357
416
|
},
|
|
358
417
|
namespace: "fs",
|
|
359
418
|
async run(args) {
|
|
360
|
-
const abs = confine(root, String(args.path ?? "."));
|
|
361
|
-
await assertRealWithinRoot(
|
|
419
|
+
const abs = confine(roots, args.root, String(args.path ?? "."));
|
|
420
|
+
await assertRealWithinRoot(roots, abs);
|
|
362
421
|
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
363
422
|
return entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name).join("\n");
|
|
364
423
|
}
|
|
@@ -368,15 +427,16 @@ function nodeFsTools(root) {
|
|
|
368
427
|
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
369
428
|
var MAX_TIMEOUT_MS = 3e5;
|
|
370
429
|
var MAX_OUTPUT = 1e5;
|
|
371
|
-
function nodeShellTool(
|
|
430
|
+
function nodeShellTool(roots) {
|
|
372
431
|
return {
|
|
373
432
|
name: "shell_exec",
|
|
374
|
-
description: "Run a shell command on the sidecar machine (commands a browser cannot spawn). Runs in
|
|
433
|
+
description: "Run a shell command on the sidecar machine (commands a browser cannot spawn). Runs in a pre-approved root by default; returns the exit status plus combined stdout/stderr.",
|
|
375
434
|
parameters: {
|
|
376
435
|
type: "object",
|
|
377
436
|
properties: {
|
|
378
437
|
command: { type: "string", description: "the shell command line to run" },
|
|
379
|
-
cwd: { type: "string", description: 'working directory relative to the
|
|
438
|
+
cwd: { type: "string", description: 'working directory relative to the chosen root (default ".")' },
|
|
439
|
+
root: ROOT_PARAM,
|
|
380
440
|
timeout_ms: { type: "number", description: `max run time in ms (default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS})` }
|
|
381
441
|
},
|
|
382
442
|
required: ["command"]
|
|
@@ -387,8 +447,8 @@ function nodeShellTool(root) {
|
|
|
387
447
|
async run(args) {
|
|
388
448
|
const command = String(args.command ?? "").trim();
|
|
389
449
|
if (!command) return "Error: no command given";
|
|
390
|
-
const cwd = confine(root, String(args.cwd ?? "."));
|
|
391
|
-
await assertRealWithinRoot(
|
|
450
|
+
const cwd = confine(roots, args.root, String(args.cwd ?? "."));
|
|
451
|
+
await assertRealWithinRoot(roots, cwd);
|
|
392
452
|
const timeout = Math.min(Math.max(Number(args.timeout_ms) || DEFAULT_TIMEOUT_MS, 1), MAX_TIMEOUT_MS);
|
|
393
453
|
return runShell(command, cwd, timeout);
|
|
394
454
|
}
|
|
@@ -502,26 +562,294 @@ async function loadOrCreateSelfSigned(extraHosts = []) {
|
|
|
502
562
|
return { cert: pems.cert, key: pems.private, sans };
|
|
503
563
|
}
|
|
504
564
|
|
|
565
|
+
// src/tunnel/registry.ts
|
|
566
|
+
var providers = /* @__PURE__ */ new Map();
|
|
567
|
+
function registerTunnel(p) {
|
|
568
|
+
providers.set(p.id, p);
|
|
569
|
+
}
|
|
570
|
+
function getTunnel(id) {
|
|
571
|
+
const direct = providers.get(id);
|
|
572
|
+
if (direct) return direct;
|
|
573
|
+
for (const p of providers.values()) if (p.aliases?.includes(id)) return p;
|
|
574
|
+
return void 0;
|
|
575
|
+
}
|
|
576
|
+
function tunnelIds() {
|
|
577
|
+
return [...providers.keys()];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// src/tunnel/zrok.ts
|
|
581
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
582
|
+
function extractShareUrl(s) {
|
|
583
|
+
const full = s.match(/https?:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[^\s"'\\]*)?/);
|
|
584
|
+
if (full) return full[0];
|
|
585
|
+
const ep = s.match(/endpoints:(?:\\[nrt]|\s)*([A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}(?:\/[^\s"'\\]*)?)/);
|
|
586
|
+
return ep ? `https://${ep[1]}` : null;
|
|
587
|
+
}
|
|
588
|
+
var START_TIMEOUT_MS = 25e3;
|
|
589
|
+
function shareArgs(port, name) {
|
|
590
|
+
const a = ["share", "public", "--headless", "--backend-mode", "proxy"];
|
|
591
|
+
if (name) a.push("-n", `public:${name}`);
|
|
592
|
+
a.push(`http://localhost:${port}`);
|
|
593
|
+
return a;
|
|
594
|
+
}
|
|
595
|
+
var tail = (s, n = 6) => s.trim().split("\n").slice(-n).join("\n");
|
|
596
|
+
function zrokBins() {
|
|
597
|
+
const env = process.env.ZROK_BIN?.trim();
|
|
598
|
+
return env ? [env] : ["zrok", "zrok2"];
|
|
599
|
+
}
|
|
600
|
+
function stopChild(child) {
|
|
601
|
+
return new Promise((res) => {
|
|
602
|
+
if (child.exitCode !== null || child.signalCode) {
|
|
603
|
+
res();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const hard = setTimeout(() => {
|
|
607
|
+
try {
|
|
608
|
+
child.kill("SIGKILL");
|
|
609
|
+
} catch {
|
|
610
|
+
}
|
|
611
|
+
res();
|
|
612
|
+
}, 2e3);
|
|
613
|
+
child.once("exit", () => {
|
|
614
|
+
clearTimeout(hard);
|
|
615
|
+
res();
|
|
616
|
+
});
|
|
617
|
+
try {
|
|
618
|
+
child.kill("SIGTERM");
|
|
619
|
+
} catch {
|
|
620
|
+
clearTimeout(hard);
|
|
621
|
+
res();
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
function reserveName(bin, name) {
|
|
626
|
+
return new Promise((res, rej) => {
|
|
627
|
+
const child = spawn2(bin, ["create", "name", name], { stdio: ["ignore", "ignore", "ignore"] });
|
|
628
|
+
let settled = false;
|
|
629
|
+
const done = (fn) => {
|
|
630
|
+
if (settled) return;
|
|
631
|
+
settled = true;
|
|
632
|
+
clearTimeout(t);
|
|
633
|
+
fn();
|
|
634
|
+
};
|
|
635
|
+
child.on("error", (e) => done(() => e?.code === "ENOENT" ? rej(e) : res()));
|
|
636
|
+
child.on("exit", () => done(() => res()));
|
|
637
|
+
const t = setTimeout(() => done(() => {
|
|
638
|
+
try {
|
|
639
|
+
child.kill();
|
|
640
|
+
} catch {
|
|
641
|
+
}
|
|
642
|
+
res();
|
|
643
|
+
}), 15e3);
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
async function startWith(bin, port, name) {
|
|
647
|
+
if (name) await reserveName(bin, name);
|
|
648
|
+
return new Promise((resolveHandle, reject) => {
|
|
649
|
+
const child = spawn2(bin, shareArgs(port, name), { stdio: ["ignore", "pipe", "pipe"] });
|
|
650
|
+
let out = "";
|
|
651
|
+
let settled = false;
|
|
652
|
+
const finish = (fn) => {
|
|
653
|
+
if (settled) return;
|
|
654
|
+
settled = true;
|
|
655
|
+
clearTimeout(timer);
|
|
656
|
+
fn();
|
|
657
|
+
};
|
|
658
|
+
const onData = (b) => {
|
|
659
|
+
out += b.toString();
|
|
660
|
+
const url = extractShareUrl(out);
|
|
661
|
+
if (url) finish(() => resolveHandle({ url, stop: () => stopChild(child) }));
|
|
662
|
+
};
|
|
663
|
+
child.stdout?.on("data", onData);
|
|
664
|
+
child.stderr?.on("data", onData);
|
|
665
|
+
child.on("error", (e) => finish(() => reject(e)));
|
|
666
|
+
child.on("exit", (code) => finish(() => reject(new Error(
|
|
667
|
+
`${bin} exited (code ${code}) before a share URL appeared \u2014 is the environment enabled? Run \`${bin} enable <token>\` once.
|
|
668
|
+
${tail(out)}`
|
|
669
|
+
))));
|
|
670
|
+
const timer = setTimeout(() => finish(() => {
|
|
671
|
+
void stopChild(child);
|
|
672
|
+
reject(new Error(`${bin} produced no share URL within ${START_TIMEOUT_MS / 1e3}s.
|
|
673
|
+
${tail(out)}`));
|
|
674
|
+
}), START_TIMEOUT_MS);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
var zrokProvider = {
|
|
678
|
+
id: "zrok",
|
|
679
|
+
description: "zrok public share (needs the zrok CLI [`zrok`/`zrok2`] installed + `enable <token>` run once \u2014 https://zrok.io)",
|
|
680
|
+
start({ port, name }) {
|
|
681
|
+
const bins = zrokBins();
|
|
682
|
+
const tryAt = (i) => {
|
|
683
|
+
if (i >= bins.length) {
|
|
684
|
+
return Promise.reject(new Error(
|
|
685
|
+
`zrok not found (tried: ${bins.join(", ")}). Install the zrok CLI (https://docs.zrok.io) and run its \`enable <token>\` once, or set ZROK_BIN to the binary name/path.`
|
|
686
|
+
));
|
|
687
|
+
}
|
|
688
|
+
return startWith(bins[i], port, name).catch((e) => {
|
|
689
|
+
if (e?.code === "ENOENT") return tryAt(i + 1);
|
|
690
|
+
throw e;
|
|
691
|
+
});
|
|
692
|
+
};
|
|
693
|
+
return tryAt(0);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// src/tunnel/cloudflared.ts
|
|
698
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
699
|
+
function extractTunnelUrl(s) {
|
|
700
|
+
const m = s.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
701
|
+
return m ? m[0] : null;
|
|
702
|
+
}
|
|
703
|
+
var START_TIMEOUT_MS2 = 25e3;
|
|
704
|
+
function tunnelArgs(port, host = "localhost") {
|
|
705
|
+
return ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", `http://${host}:${port}`];
|
|
706
|
+
}
|
|
707
|
+
var tail2 = (s, n = 6) => s.trim().split("\n").slice(-n).join("\n");
|
|
708
|
+
function cloudflaredBins() {
|
|
709
|
+
const env = process.env.CLOUDFLARED_BIN?.trim();
|
|
710
|
+
return env ? [env] : ["cloudflared"];
|
|
711
|
+
}
|
|
712
|
+
function stopChild2(child) {
|
|
713
|
+
return new Promise((res) => {
|
|
714
|
+
if (child.exitCode !== null || child.signalCode) {
|
|
715
|
+
res();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const hard = setTimeout(() => {
|
|
719
|
+
try {
|
|
720
|
+
child.kill("SIGKILL");
|
|
721
|
+
} catch {
|
|
722
|
+
}
|
|
723
|
+
res();
|
|
724
|
+
}, 2e3);
|
|
725
|
+
child.once("exit", () => {
|
|
726
|
+
clearTimeout(hard);
|
|
727
|
+
res();
|
|
728
|
+
});
|
|
729
|
+
try {
|
|
730
|
+
child.kill("SIGTERM");
|
|
731
|
+
} catch {
|
|
732
|
+
clearTimeout(hard);
|
|
733
|
+
res();
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
function startWith2(bin, port, host, log) {
|
|
738
|
+
return new Promise((resolveHandle, reject) => {
|
|
739
|
+
const child = spawn3(bin, tunnelArgs(port, host), { stdio: ["ignore", "pipe", "pipe"] });
|
|
740
|
+
let out = "";
|
|
741
|
+
let settled = false;
|
|
742
|
+
let tailBuf = "";
|
|
743
|
+
const finish = (fn) => {
|
|
744
|
+
if (settled) return;
|
|
745
|
+
settled = true;
|
|
746
|
+
clearTimeout(timer);
|
|
747
|
+
fn();
|
|
748
|
+
};
|
|
749
|
+
const onData = (b) => {
|
|
750
|
+
const text = b.toString();
|
|
751
|
+
if (!settled) {
|
|
752
|
+
out += text;
|
|
753
|
+
const url = extractTunnelUrl(out);
|
|
754
|
+
if (url) finish(() => resolveHandle({ url, stop: () => stopChild2(child) }));
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
tailBuf += text;
|
|
758
|
+
let nl;
|
|
759
|
+
while ((nl = tailBuf.indexOf("\n")) >= 0) {
|
|
760
|
+
const line = tailBuf.slice(0, nl).replace(/\r$/, "").trim();
|
|
761
|
+
tailBuf = tailBuf.slice(nl + 1);
|
|
762
|
+
if (line) log?.(`cloudflared: ${line}`);
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
child.stdout?.on("data", onData);
|
|
766
|
+
child.stderr?.on("data", onData);
|
|
767
|
+
child.on("error", (e) => finish(() => reject(e)));
|
|
768
|
+
child.on("exit", (code) => {
|
|
769
|
+
if (settled) {
|
|
770
|
+
log?.(`cloudflared exited (code ${code}) \u2014 the tunnel is down`);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
finish(() => reject(new Error(`${bin} exited (code ${code}) before a tunnel URL appeared.
|
|
774
|
+
${tail2(out)}`)));
|
|
775
|
+
});
|
|
776
|
+
const timer = setTimeout(() => finish(() => {
|
|
777
|
+
void stopChild2(child);
|
|
778
|
+
reject(new Error(`${bin} produced no tunnel URL within ${START_TIMEOUT_MS2 / 1e3}s.
|
|
779
|
+
${tail2(out)}`));
|
|
780
|
+
}), START_TIMEOUT_MS2);
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
var cloudflaredProvider = {
|
|
784
|
+
id: "cloudflared",
|
|
785
|
+
aliases: ["cloudflare"],
|
|
786
|
+
description: "Cloudflare quick tunnel (needs the `cloudflared` CLI installed \u2014 https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ \u2014 no account, no interstitial; URL is ephemeral)",
|
|
787
|
+
start({ port, host, log }) {
|
|
788
|
+
const bins = cloudflaredBins();
|
|
789
|
+
const tryAt = (i) => {
|
|
790
|
+
if (i >= bins.length) {
|
|
791
|
+
return Promise.reject(new Error(
|
|
792
|
+
`cloudflared not found (tried: ${bins.join(", ")}). Install the cloudflared CLI (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/), or set CLOUDFLARED_BIN to the binary name/path.`
|
|
793
|
+
));
|
|
794
|
+
}
|
|
795
|
+
return startWith2(bins[i], port, host || "localhost", log).catch((e) => {
|
|
796
|
+
if (e?.code === "ENOENT") return tryAt(i + 1);
|
|
797
|
+
throw e;
|
|
798
|
+
});
|
|
799
|
+
};
|
|
800
|
+
return tryAt(0);
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// src/tunnel/index.ts
|
|
805
|
+
registerTunnel(cloudflaredProvider);
|
|
806
|
+
registerTunnel(zrokProvider);
|
|
807
|
+
|
|
505
808
|
// src/cli.ts
|
|
506
809
|
function parseArgs(argv) {
|
|
507
|
-
const out = { port: 4858, host: "127.0.0.1",
|
|
810
|
+
const out = { port: 4858, host: "127.0.0.1", roots: [], allowOrigins: [], allowShell: false, allowLan: false, selfSigned: false, open: false, appUrl: "https://prompd.app" };
|
|
508
811
|
for (let i = 0; i < argv.length; i++) {
|
|
509
812
|
const a = argv[i];
|
|
510
813
|
if (a === "--port") out.port = Number(argv[++i]);
|
|
511
814
|
else if (a === "--host") out.host = argv[++i];
|
|
512
|
-
else if (a === "--root") out.
|
|
815
|
+
else if (a === "--root") out.roots.push(resolve2(argv[++i]));
|
|
513
816
|
else if (a === "--allow-origin") out.allowOrigins.push(argv[++i]);
|
|
514
817
|
else if (a === "--allow-shell") out.allowShell = true;
|
|
818
|
+
else if (a === "--allow-lan") out.allowLan = true;
|
|
515
819
|
else if (a === "--tls-cert") out.tlsCert = resolve2(argv[++i]);
|
|
516
820
|
else if (a === "--tls-key") out.tlsKey = resolve2(argv[++i]);
|
|
517
821
|
else if (a === "--self-signed") out.selfSigned = true;
|
|
822
|
+
else if (a === "--tunnel") out.tunnel = argv[++i];
|
|
823
|
+
else if (a === "--tunnel-name") out.tunnelName = argv[++i];
|
|
824
|
+
else if (a === "--open") out.open = true;
|
|
825
|
+
else if (a === "--app-url") out.appUrl = argv[++i];
|
|
518
826
|
}
|
|
519
827
|
return out;
|
|
520
828
|
}
|
|
521
829
|
var isIp2 = (s) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(s);
|
|
830
|
+
function openBrowser(url) {
|
|
831
|
+
const p = process.platform;
|
|
832
|
+
const cmd = p === "darwin" ? "open" : p === "win32" ? "cmd" : "xdg-open";
|
|
833
|
+
const args = p === "win32" ? ["/c", "start", "", url] : [url];
|
|
834
|
+
try {
|
|
835
|
+
spawn4(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
836
|
+
} catch {
|
|
837
|
+
}
|
|
838
|
+
}
|
|
522
839
|
var DEFAULT_ALLOWED_ORIGINS = ["https://prompd.app", "https://www.prompd.app", "https://prmd.ai", "https://www.prmd.ai"];
|
|
523
840
|
async function main() {
|
|
524
841
|
const args = parseArgs(process.argv.slice(2));
|
|
842
|
+
if (args.roots.length === 0) args.roots = [process.cwd()];
|
|
843
|
+
args.roots = [...new Set(args.roots)];
|
|
844
|
+
for (const r of args.roots) {
|
|
845
|
+
let ok = false;
|
|
846
|
+
try {
|
|
847
|
+
ok = statSync(r).isDirectory();
|
|
848
|
+
} catch {
|
|
849
|
+
ok = false;
|
|
850
|
+
}
|
|
851
|
+
if (!ok) throw new Error(`--root is not a directory: ${r}`);
|
|
852
|
+
}
|
|
525
853
|
const token = await loadOrCreateToken();
|
|
526
854
|
if (args.tlsCert && !args.tlsKey || !args.tlsCert && args.tlsKey) {
|
|
527
855
|
throw new Error("TLS needs both --tls-cert and --tls-key");
|
|
@@ -534,15 +862,41 @@ async function main() {
|
|
|
534
862
|
tls = { cert: ss.cert, key: ss.key };
|
|
535
863
|
selfSigned = true;
|
|
536
864
|
}
|
|
865
|
+
const tunnelProvider = args.tunnel ? getTunnel(args.tunnel) : void 0;
|
|
866
|
+
if (args.tunnel && !tunnelProvider) {
|
|
867
|
+
throw new Error(`unknown --tunnel "${args.tunnel}". Available: ${tunnelIds().join(", ") || "(none)"}`);
|
|
868
|
+
}
|
|
869
|
+
if (tunnelProvider) {
|
|
870
|
+
args.host = "127.0.0.1";
|
|
871
|
+
tls = void 0;
|
|
872
|
+
selfSigned = false;
|
|
873
|
+
}
|
|
537
874
|
const allowedOrigins = [.../* @__PURE__ */ new Set([...DEFAULT_ALLOWED_ORIGINS, ...args.allowOrigins])];
|
|
875
|
+
const slog = (line) => {
|
|
876
|
+
process.stdout.write(` [${(/* @__PURE__ */ new Date()).toTimeString().slice(0, 8)}] ${line}
|
|
877
|
+
`);
|
|
878
|
+
};
|
|
538
879
|
const handle = await startSidecar({
|
|
539
880
|
port: args.port,
|
|
540
881
|
host: args.host,
|
|
541
882
|
token,
|
|
542
|
-
tools: [...nodeFsTools(args.
|
|
883
|
+
tools: [...nodeFsTools(args.roots), ...args.allowShell ? [nodeShellTool(args.roots)] : []],
|
|
543
884
|
allowedOrigins,
|
|
544
|
-
tls
|
|
885
|
+
tls,
|
|
886
|
+
// Advertised in the handshake so the host can show the available working
|
|
887
|
+
// directories and let the user pick one (confined to the union of roots).
|
|
888
|
+
info: { root: args.roots[0], roots: args.roots, platform: process.platform },
|
|
889
|
+
log: slog,
|
|
890
|
+
allowLan: args.allowLan
|
|
545
891
|
});
|
|
892
|
+
let tunnel = null;
|
|
893
|
+
let tunnelWss = null;
|
|
894
|
+
if (tunnelProvider) {
|
|
895
|
+
process.stdout.write(`starting ${tunnelProvider.id} tunnel${args.tunnelName ? ` (reserved name "${args.tunnelName}")` : ""}\u2026
|
|
896
|
+
`);
|
|
897
|
+
tunnel = await tunnelProvider.start({ port: handle.port, host: handle.host, name: args.tunnelName, log: slog });
|
|
898
|
+
tunnelWss = tunnel.url.replace(/^http/, "ws");
|
|
899
|
+
}
|
|
546
900
|
const scheme = handle.secure ? "wss" : "ws";
|
|
547
901
|
const exposed = handle.host !== "127.0.0.1" && handle.host !== "localhost";
|
|
548
902
|
const connectHosts = handle.host === "0.0.0.0" ? lanIPv4s().length ? lanIPv4s() : ["127.0.0.1"] : [handle.host];
|
|
@@ -550,19 +904,29 @@ async function main() {
|
|
|
550
904
|
process.stdout.write(
|
|
551
905
|
[
|
|
552
906
|
`corenel sidecar listening on ${scheme}://${handle.host}:${handle.port}`,
|
|
553
|
-
` root: ${args.
|
|
907
|
+
args.roots.length === 1 ? ` root: ${args.roots[0]}` : ` roots: ${args.roots.join("\n ")} (${args.roots.length} approved; pick the active one in the app)`,
|
|
554
908
|
` tools: fs_read/write/list${args.allowShell ? " + shell_exec" : " (shell off; pass --allow-shell to enable)"}`,
|
|
555
909
|
` token: ${token} (also at ${tokenPath()})`,
|
|
556
|
-
` connect at: ${connectUrls.join(" ")}`,
|
|
557
|
-
` origins: ${allowedOrigins.join(", ")}${args.allowOrigins.length ? "" : " (canonical defaults; pass --allow-origin to add dev/self-hosted)"}`,
|
|
910
|
+
tunnelWss ? ` connect at: ${tunnelWss} (public ${args.tunnel} tunnel${args.tunnelName ? ", stable" : ""} \u2014 pair from any device with this URL + the token)` : ` connect at: ${connectUrls.join(" ")}`,
|
|
911
|
+
` origins: ${allowedOrigins.join(", ")}${args.allowLan ? " + any private-LAN origin (--allow-lan)" : args.allowOrigins.length ? "" : " (canonical defaults; pass --allow-origin to add dev/self-hosted)"}`,
|
|
558
912
|
selfSigned ? ` cert: self-signed (in ~/.corenel) \u2014 trust it once: open ${connectUrls.map((u) => u.replace(/^wss/, "https")).join(" or ")} in this browser and accept the warning, then pair.` : null,
|
|
559
913
|
exposed ? ` WARNING: bound to ${handle.host} (not loopback) \u2014 reachable off-box. The pairing token + Origin allowlist are the only protection; keep the token secret.` : null,
|
|
560
914
|
!handle.secure && exposed ? ` NOTE: a browser on an https page cannot connect to ws:// \u2014 serve wss:// with --self-signed (or --tls-cert/--tls-key) for LAN pairing.` : null,
|
|
561
915
|
""
|
|
562
916
|
].filter((l) => l !== null).join("\n")
|
|
563
917
|
);
|
|
918
|
+
if (args.open) {
|
|
919
|
+
const loopback = `${handle.secure ? "wss" : "ws"}://127.0.0.1:${handle.port}`;
|
|
920
|
+
const target = `${args.appUrl.replace(/\/$/, "")}/studio#sidecar=${encodeURIComponent(loopback)}&token=${encodeURIComponent(token)}`;
|
|
921
|
+
process.stdout.write(` opening ${args.appUrl} (auto-pairing)\u2026
|
|
922
|
+
`);
|
|
923
|
+
openBrowser(target);
|
|
924
|
+
}
|
|
564
925
|
const shutdown = () => {
|
|
565
|
-
|
|
926
|
+
const closeServer = () => handle.close().then(() => process.exit(0));
|
|
927
|
+
if (tunnel) void tunnel.stop().catch(() => {
|
|
928
|
+
}).then(closeServer);
|
|
929
|
+
else void closeServer();
|
|
566
930
|
};
|
|
567
931
|
process.on("SIGINT", shutdown);
|
|
568
932
|
process.on("SIGTERM", shutdown);
|
package/package.json
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@corenel/sidecar",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"license": "Elastic-2.0",
|
|
6
|
-
"description": "Prompd sidecar — a local capability server. Run it next to Prompd (prompd.app) so the browser app can reach your machine: local LLMs (Ollama/LM Studio/vLLM) without CORS, your filesystem, and (opt-in) shell.",
|
|
7
|
-
"keywords": ["prompd", "corenel", "sidecar", "ollama", "local-llm", "cors"],
|
|
8
|
-
"bin": {
|
|
9
|
-
"corenel-sidecar": "dist/cli.js"
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"dist",
|
|
13
|
-
"README.md"
|
|
14
|
-
],
|
|
15
|
-
"engines": {
|
|
16
|
-
"node": ">=18"
|
|
17
|
-
},
|
|
18
|
-
"publishConfig": {
|
|
19
|
-
"access": "public"
|
|
20
|
-
},
|
|
21
|
-
"scripts": {
|
|
22
|
-
"build": "node build.mjs",
|
|
23
|
-
"prepublishOnly": "node build.mjs",
|
|
24
|
-
"start": "tsx src/cli.ts",
|
|
25
|
-
"typecheck": "tsc --noEmit",
|
|
26
|
-
"verify": "tsx src/verify.ts"
|
|
27
|
-
},
|
|
28
|
-
"dependencies": {
|
|
29
|
-
"selfsigned": "^2.4.1",
|
|
30
|
-
"ws": "^8.18.0"
|
|
31
|
-
},
|
|
32
|
-
"devDependencies": {
|
|
33
|
-
"@corenel/protocol": "workspace:*",
|
|
34
|
-
"@corenel/harness": "workspace:*",
|
|
35
|
-
"@types/node": "^24.13.1",
|
|
36
|
-
"@types/ws": "^8.5.13",
|
|
37
|
-
"esbuild": "^0.21.5",
|
|
38
|
-
"tsx": "^4.19.2",
|
|
39
|
-
"typescript": "^5.9.3"
|
|
40
|
-
}
|
|
41
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@corenel/sidecar",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "Elastic-2.0",
|
|
6
|
+
"description": "Prompd sidecar — a local capability server. Run it next to Prompd (prompd.app) so the browser app can reach your machine: local LLMs (Ollama/LM Studio/vLLM) without CORS, your filesystem, and (opt-in) shell.",
|
|
7
|
+
"keywords": ["prompd", "corenel", "sidecar", "ollama", "local-llm", "cors"],
|
|
8
|
+
"bin": {
|
|
9
|
+
"corenel-sidecar": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "node build.mjs",
|
|
23
|
+
"prepublishOnly": "node build.mjs",
|
|
24
|
+
"start": "tsx src/cli.ts",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"verify": "tsx src/verify.ts"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"selfsigned": "^2.4.1",
|
|
30
|
+
"ws": "^8.18.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@corenel/protocol": "workspace:*",
|
|
34
|
+
"@corenel/harness": "workspace:*",
|
|
35
|
+
"@types/node": "^24.13.1",
|
|
36
|
+
"@types/ws": "^8.5.13",
|
|
37
|
+
"esbuild": "^0.21.5",
|
|
38
|
+
"tsx": "^4.19.2",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
}
|
|
41
|
+
}
|