@netnodeag/kraftwerk 0.64.0 → 0.66.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/README.md +126 -138
- package/dist/acp.js +3 -1
- package/dist/cli/daemon.d.ts +14 -0
- package/dist/cli/daemon.js +60 -0
- package/dist/cli/devices.d.ts +14 -0
- package/dist/cli/devices.js +53 -0
- package/dist/cli/doctor-legacy.js +9 -1
- package/dist/cli/doctor.js +17 -30
- package/dist/cli/init.js +1 -6
- package/dist/cli/kraftwerk.js +8 -3
- package/dist/cli/ui.d.ts +9 -0
- package/dist/cli/ui.js +47 -23
- package/dist/cli/workspaces.js +5 -3
- package/dist/client/index.d.ts +17 -0
- package/dist/client/index.js +10 -2
- package/dist/client/live.d.ts +1 -2
- package/dist/client/live.js +4 -2
- package/dist/client/routes.d.ts +8 -0
- package/dist/client/routes.js +8 -0
- package/dist/config.d.ts +18 -55
- package/dist/config.js +39 -92
- package/dist/core/chat/context.js +1 -1
- package/dist/core/chat/pi.js +2 -1
- package/dist/core/chat/sessions.d.ts +1 -1
- package/dist/core/chat/sessions.js +1 -1
- package/dist/core/chat/state.d.ts +2 -0
- package/dist/core/chat/state.js +5 -0
- package/dist/core/daemon.d.ts +27 -0
- package/dist/core/daemon.js +61 -0
- package/dist/core/devices.d.ts +35 -0
- package/dist/core/devices.js +156 -0
- package/dist/core/env.d.ts +16 -0
- package/dist/core/env.js +45 -0
- package/dist/core/git.d.ts +2 -0
- package/dist/core/git.js +20 -4
- package/dist/core/instances.d.ts +32 -12
- package/dist/core/instances.js +136 -64
- package/dist/core/routines.d.ts +6 -0
- package/dist/core/routines.js +11 -0
- package/dist/core/runner.js +7 -3
- package/dist/core/vibeables.d.ts +2 -0
- package/dist/core/vibeables.js +17 -5
- package/dist/core/workspace.d.ts +13 -0
- package/dist/core/workspace.js +25 -0
- package/dist/dotenv.d.ts +2 -2
- package/dist/dotenv.js +2 -2
- package/dist/server/api/devices.d.ts +20 -0
- package/dist/server/api/devices.js +55 -0
- package/dist/server/api/hub.d.ts +25 -0
- package/dist/server/api/hub.js +32 -0
- package/dist/server/api/index.d.ts +42 -2
- package/dist/server/api/index.js +5 -1
- package/dist/server/api/router.d.ts +21 -6
- package/dist/server/api/router.js +37 -9
- package/dist/server/api/socket.d.ts +15 -1
- package/dist/server/api/socket.js +59 -8
- package/dist/server/api/workspace.d.ts +2 -0
- package/dist/server/api/workspace.js +4 -2
- package/dist/server/bind.d.ts +16 -0
- package/dist/server/bind.js +25 -0
- package/dist/server/cloud.d.ts +5 -1
- package/dist/server/cloud.js +89 -56
- package/dist/server/hub-context.d.ts +7 -0
- package/dist/server/hub-context.js +18 -0
- package/dist/server/live.d.ts +2 -0
- package/dist/server/live.js +9 -0
- package/dist/server/preview.js +2 -1
- package/dist/server/server.d.ts +58 -1
- package/dist/server/server.js +267 -103
- package/dist/server/target.d.ts +20 -0
- package/dist/server/target.js +18 -0
- package/dist/server/trust.d.ts +41 -0
- package/dist/server/trust.js +42 -0
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +7 -46
- package/web/dist/assets/{dist-Cgcwvf4j.js → dist-1bNkaWWA.js} +1 -1
- package/web/dist/assets/dist-BU1SOB5S.js +1 -0
- package/web/dist/assets/{dist-CboP0-T1.js → dist-Bk3f-J5U.js} +1 -1
- package/web/dist/assets/{dist-2kswkIH2.js → dist-Bx99Puvv.js} +1 -1
- package/web/dist/assets/{dist-D2PvEzo7.js → dist-C0M2aITo.js} +1 -1
- package/web/dist/assets/{dist-D_BcTzz-.js → dist-C0z19Zge.js} +1 -1
- package/web/dist/assets/{dist-36MjITWa.js → dist-COEgBKG6.js} +1 -1
- package/web/dist/assets/{dist-Jdx6DRrm.js → dist-CYRrdlae.js} +1 -1
- package/web/dist/assets/{dist-DDkd_zrP.js → dist-CbEFnSCD.js} +1 -1
- package/web/dist/assets/{dist-BsuA2pgO.js → dist-DCYVKU-H.js} +1 -1
- package/web/dist/assets/{dist-B8gPjMPP.js → dist-XyTHWtoN.js} +1 -1
- package/web/dist/assets/{editor-ByRUOfrX.js → editor-DPcGvSPy.js} +3 -3
- package/web/dist/assets/index-BAYs-2WC.css +2 -0
- package/web/dist/assets/index-DKLJdwYE.js +229 -0
- package/web/dist/index.html +2 -2
- package/dist/cli/tunnel.d.ts +0 -10
- package/dist/cli/tunnel.js +0 -280
- package/dist/server/access.d.ts +0 -32
- package/dist/server/access.js +0 -121
- package/web/dist/assets/dist-CVqmJrYd.js +0 -1
- package/web/dist/assets/index-BR7HvDzD.js +0 -229
- package/web/dist/assets/index-Bra74Nxe.css +0 -2
package/dist/core/vibeables.js
CHANGED
|
@@ -6,9 +6,10 @@ import path from "node:path";
|
|
|
6
6
|
import { parse } from "yaml";
|
|
7
7
|
import { resolveWorkspace, vibeablesRootFor } from "../config.js";
|
|
8
8
|
import { workspaceRoot } from "./context.js";
|
|
9
|
-
import { perWorkspace } from "./workspace.js";
|
|
9
|
+
import { perWorkspace, workspaceOpen } from "./workspace.js";
|
|
10
10
|
import { moveToTrash } from "./trash.js";
|
|
11
11
|
import { newestMtime } from "./mtime.js";
|
|
12
|
+
import { workspaceEnv } from "./env.js";
|
|
12
13
|
export const VIBEABLE_CONFIG_FILE = "vibeable.yml";
|
|
13
14
|
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
14
15
|
export function safeVibeableSlug(slug) {
|
|
@@ -353,7 +354,7 @@ async function startDevNow(slug) {
|
|
|
353
354
|
detached: process.platform !== "win32",
|
|
354
355
|
stdio: ["ignore", "pipe", "pipe"],
|
|
355
356
|
env: {
|
|
356
|
-
...
|
|
357
|
+
...workspaceEnv(),
|
|
357
358
|
PORT: String(port),
|
|
358
359
|
BASE_PATH: basePath,
|
|
359
360
|
PUBLIC_URL: basePath,
|
|
@@ -371,19 +372,22 @@ async function startDevNow(slug) {
|
|
|
371
372
|
pushLog(d, Buffer.from(`spawn failed: ${err.message}\n`));
|
|
372
373
|
d.exitCode = null;
|
|
373
374
|
d.ready = false;
|
|
374
|
-
|
|
375
|
+
if (workspaceOpen())
|
|
376
|
+
broadcast(slug, { type: "dev", dev: devView(slug) });
|
|
375
377
|
});
|
|
376
378
|
child.on("exit", (code) => {
|
|
377
379
|
d.exitCode = code;
|
|
378
380
|
d.ready = false;
|
|
379
381
|
pushLog(d, Buffer.from(`[exited with ${code ?? "signal"}]\n`));
|
|
380
|
-
|
|
382
|
+
// Closing the workspace ends its dev servers: their exit then has nobody to tell.
|
|
383
|
+
if (workspaceOpen())
|
|
384
|
+
broadcast(slug, { type: "dev", dev: devView(slug) });
|
|
381
385
|
});
|
|
382
386
|
broadcast(slug, { type: "dev", dev: devView(slug) });
|
|
383
387
|
// Readiness runs in the background; the pane follows the dev events.
|
|
384
388
|
void (async () => {
|
|
385
389
|
const until = Date.now() + READY_TIMEOUT;
|
|
386
|
-
while (Date.now() < until && d.exitCode === undefined && devs().get(slug) === d) {
|
|
390
|
+
while (Date.now() < until && d.exitCode === undefined && workspaceOpen() && devs().get(slug) === d) {
|
|
387
391
|
if (await probe(port)) {
|
|
388
392
|
d.ready = true;
|
|
389
393
|
broadcast(slug, { type: "dev", dev: devView(slug) });
|
|
@@ -432,3 +436,11 @@ export function disposeAllDevs() {
|
|
|
432
436
|
if (d.exitCode === undefined)
|
|
433
437
|
killGroup(d, "SIGTERM");
|
|
434
438
|
}
|
|
439
|
+
/** The current workspace's dev servers and file watchers end (the workspace closes). */
|
|
440
|
+
export function disposeWorkspaceDevs() {
|
|
441
|
+
for (const d of devs().values())
|
|
442
|
+
if (d.exitCode === undefined)
|
|
443
|
+
killGroup(d, "SIGTERM");
|
|
444
|
+
for (const slug of [...channels().keys()])
|
|
445
|
+
dropChannel(slug);
|
|
446
|
+
}
|
package/dist/core/workspace.d.ts
CHANGED
|
@@ -18,19 +18,32 @@
|
|
|
18
18
|
export declare class Workspace {
|
|
19
19
|
readonly root: string;
|
|
20
20
|
readonly outputDir: string;
|
|
21
|
+
/** Its address on this machine (kraftwerk.yml `slug`, else the folder name), when known. */
|
|
22
|
+
readonly slug?: string;
|
|
23
|
+
/** Its `.env` (parsed, not applied — see env.ts); {} when none was given. */
|
|
24
|
+
readonly env: Record<string, string>;
|
|
21
25
|
private readonly slots;
|
|
22
26
|
/** `root` defaults to the parent of the output dir (wrong for nested output dirs — pass it when known). */
|
|
23
27
|
constructor(opts: {
|
|
24
28
|
outputDir: string;
|
|
25
29
|
root?: string;
|
|
30
|
+
slug?: string;
|
|
31
|
+
env?: Record<string, string>;
|
|
26
32
|
});
|
|
27
33
|
/** Run `fn` (and everything it starts) in this workspace. */
|
|
28
34
|
run<T>(fn: () => T): T;
|
|
35
|
+
/** Closed (no longer served): its state is gone and may not come back — see close(). */
|
|
36
|
+
get closed(): boolean;
|
|
37
|
+
private isClosed;
|
|
29
38
|
slot<T>(key: object, init: () => T): T;
|
|
39
|
+
/** No longer served: sweeps over all workspaces (shutdown, the idle reaper) skip it. Its slots are dropped. */
|
|
40
|
+
close(): void;
|
|
30
41
|
peek<T>(key: object): T | undefined;
|
|
31
42
|
}
|
|
32
43
|
/** The workspace a single-workspace process serves; used outside any `ws.run`. */
|
|
33
44
|
export declare function setDefaultWorkspace(ws: Workspace): Workspace;
|
|
45
|
+
/** Whether the caller's workspace is still served (a background loop checks before its next round); true outside any. */
|
|
46
|
+
export declare function workspaceOpen(): boolean;
|
|
34
47
|
export declare function currentWorkspace(): Workspace;
|
|
35
48
|
export interface PerWorkspace<T> {
|
|
36
49
|
/** The current workspace's value, created on first use. */
|
package/dist/core/workspace.js
CHANGED
|
@@ -20,22 +20,42 @@ import path from "node:path";
|
|
|
20
20
|
export class Workspace {
|
|
21
21
|
root;
|
|
22
22
|
outputDir;
|
|
23
|
+
/** Its address on this machine (kraftwerk.yml `slug`, else the folder name), when known. */
|
|
24
|
+
slug;
|
|
25
|
+
/** Its `.env` (parsed, not applied — see env.ts); {} when none was given. */
|
|
26
|
+
env;
|
|
23
27
|
slots = new Map();
|
|
24
28
|
/** `root` defaults to the parent of the output dir (wrong for nested output dirs — pass it when known). */
|
|
25
29
|
constructor(opts) {
|
|
26
30
|
this.outputDir = path.resolve(opts.outputDir);
|
|
27
31
|
this.root = opts.root ? path.resolve(opts.root) : path.dirname(this.outputDir);
|
|
32
|
+
this.slug = opts.slug;
|
|
33
|
+
this.env = opts.env ?? {};
|
|
28
34
|
known.add(this);
|
|
29
35
|
}
|
|
30
36
|
/** Run `fn` (and everything it starts) in this workspace. */
|
|
31
37
|
run(fn) {
|
|
32
38
|
return storage.run(this, fn);
|
|
33
39
|
}
|
|
40
|
+
/** Closed (no longer served): its state is gone and may not come back — see close(). */
|
|
41
|
+
get closed() {
|
|
42
|
+
return this.isClosed;
|
|
43
|
+
}
|
|
44
|
+
isClosed = false;
|
|
34
45
|
slot(key, init) {
|
|
46
|
+
// Something still running for a closed workspace (a fetch in flight, a heartbeat) must not bring its state back.
|
|
47
|
+
if (this.isClosed)
|
|
48
|
+
throw new Error("workspace closed");
|
|
35
49
|
if (!this.slots.has(key))
|
|
36
50
|
this.slots.set(key, init());
|
|
37
51
|
return this.slots.get(key);
|
|
38
52
|
}
|
|
53
|
+
/** No longer served: sweeps over all workspaces (shutdown, the idle reaper) skip it. Its slots are dropped. */
|
|
54
|
+
close() {
|
|
55
|
+
this.isClosed = true;
|
|
56
|
+
known.delete(this);
|
|
57
|
+
this.slots.clear();
|
|
58
|
+
}
|
|
39
59
|
peek(key) {
|
|
40
60
|
return this.slots.get(key);
|
|
41
61
|
}
|
|
@@ -48,6 +68,11 @@ export function setDefaultWorkspace(ws) {
|
|
|
48
68
|
fallback = ws;
|
|
49
69
|
return ws;
|
|
50
70
|
}
|
|
71
|
+
/** Whether the caller's workspace is still served (a background loop checks before its next round); true outside any. */
|
|
72
|
+
export function workspaceOpen() {
|
|
73
|
+
const ws = storage.getStore() ?? fallback;
|
|
74
|
+
return !ws?.closed;
|
|
75
|
+
}
|
|
51
76
|
export function currentWorkspace() {
|
|
52
77
|
const ws = storage.getStore() ?? fallback;
|
|
53
78
|
if (!ws)
|
package/dist/dotenv.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* The project's `.env`: KEY=VALUE lines next to kraftwerk.yml, loaded into
|
|
3
3
|
* this process at CLI start (a commander preAction hook in kraftwerk.ts),
|
|
4
4
|
* so every kraftwerk process sees them and everything it spawns inherits
|
|
5
|
-
* them — the inspector, the chat agents and routines, workflow runs,
|
|
6
|
-
*
|
|
5
|
+
* them — the inspector, the chat agents and routines, workflow runs,
|
|
6
|
+
* `requires:` checks.
|
|
7
7
|
*
|
|
8
8
|
* A variable already set in the shell wins over the file, the dotenv
|
|
9
9
|
* convention, with one exception that makes restarts work: a value this
|
package/dist/dotenv.js
CHANGED
|
@@ -4,8 +4,8 @@ import path from "node:path";
|
|
|
4
4
|
* The project's `.env`: KEY=VALUE lines next to kraftwerk.yml, loaded into
|
|
5
5
|
* this process at CLI start (a commander preAction hook in kraftwerk.ts),
|
|
6
6
|
* so every kraftwerk process sees them and everything it spawns inherits
|
|
7
|
-
* them — the inspector, the chat agents and routines, workflow runs,
|
|
8
|
-
*
|
|
7
|
+
* them — the inspector, the chat agents and routines, workflow runs,
|
|
8
|
+
* `requires:` checks.
|
|
9
9
|
*
|
|
10
10
|
* A variable already set in the shell wins over the file, the dotenv
|
|
11
11
|
* convention, with one exception that makes restarts work: a value this
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
/** Paired devices: other machines that use this one's kraftwerk (see core/devices.ts). */
|
|
3
|
+
export declare const deviceRoutes: (import("./router.js").Route<"devices.code", {
|
|
4
|
+
code: string;
|
|
5
|
+
expiresAt: string;
|
|
6
|
+
urls: string[];
|
|
7
|
+
}, "/api/devices/code", undefined> | import("./router.js").Route<"devices.list", {
|
|
8
|
+
devices: import("../../core/devices.js").Device[];
|
|
9
|
+
}, "/api/devices", undefined> | import("./router.js").Route<"devices.pair", import("./router.js").Reply<{
|
|
10
|
+
token: string;
|
|
11
|
+
device: import("../../core/devices.js").Device;
|
|
12
|
+
}>, "/api/pair", z.ZodObject<{
|
|
13
|
+
code: z.ZodString;
|
|
14
|
+
name: z.ZodDefault<z.ZodString>;
|
|
15
|
+
}, z.core.$strip>> | import("./router.js").Route<"devices.revoke", {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
}, "/api/devices/:id", undefined> | import("./router.js").Route<"devices.self", {
|
|
18
|
+
trust: "device" | "local" | "none";
|
|
19
|
+
device?: import("../../core/devices.js").Device | undefined;
|
|
20
|
+
}, "/api/devices/self", undefined>)[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { createPairCode, listDevices, redeemPairCode, revokeDevice } from "../../core/devices.js";
|
|
3
|
+
import { networkUrls } from "../bind.js";
|
|
4
|
+
import { DEVICE_COOKIE } from "../trust.js";
|
|
5
|
+
import { ApiError, fail, reply, route } from "./router.js";
|
|
6
|
+
/** Failed pairing attempts per peer, to make guessing a code pointless: a code is 8 characters from 31 and lives 10 minutes. */
|
|
7
|
+
const failures = new Map();
|
|
8
|
+
const MAX_FAILURES = 10;
|
|
9
|
+
const FAILURE_WINDOW_MS = 10 * 60_000;
|
|
10
|
+
function guard(peer) {
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
const recent = (failures.get(peer) ?? []).filter((t) => now - t < FAILURE_WINDOW_MS);
|
|
13
|
+
failures.set(peer, recent);
|
|
14
|
+
if (recent.length >= MAX_FAILURES)
|
|
15
|
+
fail(429, "too many wrong pairing codes — wait a few minutes");
|
|
16
|
+
}
|
|
17
|
+
/** Paired devices: other machines that use this one's kraftwerk (see core/devices.ts). */
|
|
18
|
+
export const deviceRoutes = [
|
|
19
|
+
// Public: an unpaired device trades a code for its token. A browser also gets the token as an
|
|
20
|
+
// HttpOnly cookie, so previews, downloads and the socket work without script access to it.
|
|
21
|
+
route({
|
|
22
|
+
name: "devices.pair",
|
|
23
|
+
method: "POST",
|
|
24
|
+
path: "/api/pair",
|
|
25
|
+
summary: "trade a pairing code {code, name} for a device token",
|
|
26
|
+
access: "public",
|
|
27
|
+
body: z.object({ code: z.string().min(1), name: z.string().default("") }),
|
|
28
|
+
}, async (c) => {
|
|
29
|
+
const peer = c.req?.socket.remoteAddress ?? "socket";
|
|
30
|
+
guard(peer);
|
|
31
|
+
const { code, name } = await c.body();
|
|
32
|
+
try {
|
|
33
|
+
const paired = await redeemPairCode(code, name);
|
|
34
|
+
const secure = c.req?.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
|
|
35
|
+
return reply(200, paired, {
|
|
36
|
+
"set-cookie": `${DEVICE_COOKIE}=${encodeURIComponent(paired.token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=34560000${secure}`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
failures.get(peer)?.push(Date.now()) ?? failures.set(peer, [Date.now()]);
|
|
41
|
+
throw new ApiError(400, err.message);
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
route({ name: "devices.self", method: "GET", path: "/api/devices/self", summary: "how this caller is trusted: this machine or a paired device" }, async (c) => ({
|
|
45
|
+
trust: c.trust.kind,
|
|
46
|
+
...(c.trust.kind === "device" ? { device: c.trust.device } : {}),
|
|
47
|
+
})),
|
|
48
|
+
route({ name: "devices.list", method: "GET", path: "/api/devices", summary: "every paired device", access: "local" }, async () => ({ devices: await listDevices() })),
|
|
49
|
+
// The code is shown once; the addresses are where a device on the network opens kraftwerk (none while bound to this machine only).
|
|
50
|
+
route({ name: "devices.code", method: "POST", path: "/api/devices/code", summary: "a one-time pairing code, valid 10 minutes", access: "local" }, async (c) => ({
|
|
51
|
+
...(await createPairCode()),
|
|
52
|
+
urls: networkUrls(c.req?.socket.localPort ?? 0),
|
|
53
|
+
})),
|
|
54
|
+
route({ name: "devices.revoke", method: "DELETE", path: "/api/devices/:id", summary: "unpair a device: its token stops working at once", access: "local" }, async (c) => (await revokeDevice(c.params.id)) ? { ok: true } : fail(404, "no such device")),
|
|
55
|
+
];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
/** What a hub serves (server.ts Hub): the machine's open workspaces, and opening or closing one. Need no workspace. */
|
|
3
|
+
export declare const hubRoutes: (import("./router.js").Route<"hub.close", {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
}, "/api/hub/close", z.ZodObject<{
|
|
6
|
+
root: z.ZodString;
|
|
7
|
+
}, z.core.$strip>> | import("./router.js").Route<"hub.list", {
|
|
8
|
+
daemon: boolean;
|
|
9
|
+
port: number;
|
|
10
|
+
workspaces: {
|
|
11
|
+
slug: string;
|
|
12
|
+
name: string;
|
|
13
|
+
url: string;
|
|
14
|
+
aliasPort?: number | undefined;
|
|
15
|
+
root?: string | undefined;
|
|
16
|
+
rootLabel?: string | undefined;
|
|
17
|
+
}[];
|
|
18
|
+
}, "/api/hub", undefined> | import("./router.js").Route<"hub.open", {
|
|
19
|
+
slug: string;
|
|
20
|
+
name: string;
|
|
21
|
+
url: string;
|
|
22
|
+
aliasPort?: number | undefined;
|
|
23
|
+
}, "/api/hub/open", z.ZodObject<{
|
|
24
|
+
root: z.ZodString;
|
|
25
|
+
}, z.core.$strip>>)[];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { tildify } from "../../core/instances.js";
|
|
3
|
+
import { currentHub } from "../hub-context.js";
|
|
4
|
+
import { fail, route } from "./router.js";
|
|
5
|
+
const Root = z.object({ root: z.string().min(1, "root is required") });
|
|
6
|
+
/** What a hub serves (server.ts Hub): the machine's open workspaces, and opening or closing one. Need no workspace. */
|
|
7
|
+
export const hubRoutes = [
|
|
8
|
+
// The folder is this machine's business; a paired device sees names and addresses.
|
|
9
|
+
route({ name: "hub.list", method: "GET", path: "/api/hub", summary: "the workspaces this kraftwerk serves: slug, name, address" }, async (c) => {
|
|
10
|
+
const hub = currentHub();
|
|
11
|
+
return {
|
|
12
|
+
daemon: hub.daemon,
|
|
13
|
+
port: hub.port,
|
|
14
|
+
workspaces: hub.list().map((o) => ({
|
|
15
|
+
slug: o.slug,
|
|
16
|
+
name: o.name,
|
|
17
|
+
url: o.url,
|
|
18
|
+
...(o.aliasPort ? { aliasPort: o.aliasPort } : {}),
|
|
19
|
+
...(c.trust.kind === "local" ? { root: o.root, rootLabel: tildify(o.root) } : {}),
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
}),
|
|
23
|
+
route({ name: "hub.open", method: "POST", path: "/api/hub/open", summary: "serve a workspace {root} next to the others", access: "local", body: Root, errors: 400 }, async (c) => {
|
|
24
|
+
const { root } = await c.body();
|
|
25
|
+
const o = await currentHub().open(root);
|
|
26
|
+
return { slug: o.slug, name: o.name, url: o.url, ...(o.aliasPort ? { aliasPort: o.aliasPort } : {}) };
|
|
27
|
+
}),
|
|
28
|
+
route({ name: "hub.close", method: "POST", path: "/api/hub/close", summary: "stop serving a workspace {root}; the others keep running", access: "local", body: Root }, async (c) => {
|
|
29
|
+
const { root } = await c.body();
|
|
30
|
+
return (await currentHub().close(root)) ? { ok: true } : fail(404, "not open here");
|
|
31
|
+
}),
|
|
32
|
+
];
|
|
@@ -284,7 +284,24 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
284
284
|
ok: true;
|
|
285
285
|
}, "/api/chats/:id/task-stop", z.ZodObject<{
|
|
286
286
|
taskId: z.ZodDefault<z.ZodString>;
|
|
287
|
-
}, z.core.$strip>> | Route<"
|
|
287
|
+
}, z.core.$strip>> | Route<"devices.code", {
|
|
288
|
+
code: string;
|
|
289
|
+
expiresAt: string;
|
|
290
|
+
urls: string[];
|
|
291
|
+
}, "/api/devices/code", undefined> | Route<"devices.list", {
|
|
292
|
+
devices: import("../../core/devices.js").Device[];
|
|
293
|
+
}, "/api/devices", undefined> | Route<"devices.pair", import("./router.js").Reply<{
|
|
294
|
+
token: string;
|
|
295
|
+
device: import("../../core/devices.js").Device;
|
|
296
|
+
}>, "/api/pair", z.ZodObject<{
|
|
297
|
+
code: z.ZodString;
|
|
298
|
+
name: z.ZodDefault<z.ZodString>;
|
|
299
|
+
}, z.core.$strip>> | Route<"devices.revoke", {
|
|
300
|
+
ok: boolean;
|
|
301
|
+
}, "/api/devices/:id", undefined> | Route<"devices.self", {
|
|
302
|
+
trust: "device" | "local" | "none";
|
|
303
|
+
device?: import("../../core/devices.js").Device | undefined;
|
|
304
|
+
}, "/api/devices/self", undefined> | Route<"files.delete", {
|
|
288
305
|
ok: boolean;
|
|
289
306
|
}, "/api/files", undefined> | Route<"files.list", import("../../core/files.js").FilesListing, "/api/files/list", undefined> | Route<"files.mkdir", import("./router.js").Reply<import("../../core/files.js").FileEntry>, "/api/files/folder", z.ZodObject<{
|
|
290
307
|
scope: z.ZodOptional<z.ZodString>;
|
|
@@ -320,7 +337,29 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
320
337
|
}>, "/api/git/pull", undefined> | Route<"git.push", import("./router.js").Reply<{
|
|
321
338
|
ok: boolean;
|
|
322
339
|
error?: string;
|
|
323
|
-
}>, "/api/git/push", undefined> | Route<"git.status", import("../../core/git.js").GitStatus, "/api/git", undefined> | Route<"
|
|
340
|
+
}>, "/api/git/push", undefined> | Route<"git.status", import("../../core/git.js").GitStatus, "/api/git", undefined> | Route<"hub.close", {
|
|
341
|
+
ok: boolean;
|
|
342
|
+
}, "/api/hub/close", z.ZodObject<{
|
|
343
|
+
root: z.ZodString;
|
|
344
|
+
}, z.core.$strip>> | Route<"hub.list", {
|
|
345
|
+
daemon: boolean;
|
|
346
|
+
port: number;
|
|
347
|
+
workspaces: {
|
|
348
|
+
slug: string;
|
|
349
|
+
name: string;
|
|
350
|
+
url: string;
|
|
351
|
+
aliasPort?: number | undefined;
|
|
352
|
+
root?: string | undefined;
|
|
353
|
+
rootLabel?: string | undefined;
|
|
354
|
+
}[];
|
|
355
|
+
}, "/api/hub", undefined> | Route<"hub.open", {
|
|
356
|
+
slug: string;
|
|
357
|
+
name: string;
|
|
358
|
+
url: string;
|
|
359
|
+
aliasPort?: number | undefined;
|
|
360
|
+
}, "/api/hub/open", z.ZodObject<{
|
|
361
|
+
root: z.ZodString;
|
|
362
|
+
}, z.core.$strip>> | Route<"knowledge.concept", import("../../okf.js").ConceptDetail, "/api/knowledge/:bundle/concept", undefined> | Route<"knowledge.create", import("../../okf.js").BundleInfo, "/api/knowledge", z.ZodObject<{
|
|
324
363
|
name: z.ZodDefault<z.ZodString>;
|
|
325
364
|
}, z.core.$strip>> | Route<"knowledge.delete", {
|
|
326
365
|
ok: boolean;
|
|
@@ -331,6 +370,7 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
331
370
|
id: z.ZodDefault<z.ZodString>;
|
|
332
371
|
}, z.core.$strip>> | Route<"meta.get", {
|
|
333
372
|
workspaceName: string;
|
|
373
|
+
workspaceSlug: string;
|
|
334
374
|
workspaceIcon: string;
|
|
335
375
|
workspaceColor: string;
|
|
336
376
|
workspaceNamed: boolean;
|
package/dist/server/api/index.js
CHANGED
|
@@ -3,6 +3,8 @@ import { agentRoutes } from "./agents.js";
|
|
|
3
3
|
import { attentionRoutes } from "./attention.js";
|
|
4
4
|
import { channelRoutes } from "./channels.js";
|
|
5
5
|
import { chatRoutes } from "./chats.js";
|
|
6
|
+
import { deviceRoutes } from "./devices.js";
|
|
7
|
+
import { hubRoutes } from "./hub.js";
|
|
6
8
|
import { fileRoutes } from "./files.js";
|
|
7
9
|
import { gitRoutes } from "./git.js";
|
|
8
10
|
import { knowledgeRoutes } from "./knowledge.js";
|
|
@@ -32,8 +34,10 @@ const domainRoutes = [
|
|
|
32
34
|
...fileRoutes,
|
|
33
35
|
...gitRoutes,
|
|
34
36
|
...vibeableRoutes,
|
|
37
|
+
...deviceRoutes,
|
|
38
|
+
...hubRoutes,
|
|
35
39
|
];
|
|
36
|
-
const protocolRoute = route({ name: "protocol.get", method: "GET", path: "/api/protocol", summary: "the protocol version and every route" }, async () => ({
|
|
40
|
+
const protocolRoute = route({ name: "protocol.get", method: "GET", path: "/api/protocol", summary: "the protocol version and every route", access: "public" }, async () => ({
|
|
37
41
|
version: PROTOCOL_VERSION,
|
|
38
42
|
routes: describe(),
|
|
39
43
|
}));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type http from "node:http";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import type { Trust } from "../trust.js";
|
|
3
4
|
/**
|
|
4
5
|
* The API's routing: every endpoint is one named route — an RPC-style name
|
|
5
6
|
* (`projects.get`), an HTTP method + path pattern, and a handler that
|
|
@@ -24,14 +25,17 @@ export interface Ctx<B = Record<string, unknown>> {
|
|
|
24
25
|
query: URLSearchParams;
|
|
25
26
|
/** The JSON body (parsed by the route's schema when it has one); an empty body is `{}`, malformed JSON a 400. Read once. */
|
|
26
27
|
body<T = B>(): Promise<T>;
|
|
28
|
+
/** Who is asking (see trust.ts). */
|
|
29
|
+
trust: Trust;
|
|
27
30
|
}
|
|
28
|
-
/** A result with a status other than 200 (201 created, 409 with a body, …). */
|
|
31
|
+
/** A result with a status other than 200 (201 created, 409 with a body, …), and headers for HTTP (a cookie); the socket ignores them. */
|
|
29
32
|
export declare class Reply<B> {
|
|
30
33
|
readonly status: number;
|
|
31
34
|
readonly body: B;
|
|
32
|
-
|
|
35
|
+
readonly headers: Record<string, string>;
|
|
36
|
+
constructor(status: number, body: B, headers?: Record<string, string>);
|
|
33
37
|
}
|
|
34
|
-
export declare const reply: <B>(status: number, body: B) => Reply<B>;
|
|
38
|
+
export declare const reply: <B>(status: number, body: B, headers?: Record<string, string>) => Reply<B>;
|
|
35
39
|
/** A refusal with a status; the body is `{error}` unless given. */
|
|
36
40
|
export declare class ApiError extends Error {
|
|
37
41
|
readonly status: number;
|
|
@@ -53,6 +57,12 @@ export interface RouteDef<N extends string, P extends string = string, S extends
|
|
|
53
57
|
upload?: boolean;
|
|
54
58
|
/** The JSON body's schema: validated before the handler, typed for clients. */
|
|
55
59
|
body?: S;
|
|
60
|
+
/**
|
|
61
|
+
* Who may call it. Default: anyone trusted (this machine, a paired
|
|
62
|
+
* device). `public`: also unpaired callers (pairing, the protocol).
|
|
63
|
+
* `local`: this machine only (managing devices).
|
|
64
|
+
*/
|
|
65
|
+
access?: "public" | "local";
|
|
56
66
|
}
|
|
57
67
|
/** What `c.body()` returns: the schema's output, else a loose object. */
|
|
58
68
|
export type BodyOf<S> = S extends z.ZodType ? z.output<S> : Record<string, unknown>;
|
|
@@ -70,17 +80,22 @@ export type PathOf<R> = R extends Route<string, unknown, infer P, z.ZodType | un
|
|
|
70
80
|
export declare const statusByMessage: (missing: RegExp) => (message: string) => number;
|
|
71
81
|
export interface Router {
|
|
72
82
|
routes: readonly Route[];
|
|
73
|
-
handle(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void>;
|
|
83
|
+
handle(req: http.IncomingMessage, res: http.ServerResponse, url: URL, trust: Trust): Promise<void>;
|
|
74
84
|
/**
|
|
75
85
|
* Run a route by name without HTTP (the socket): path parameters by name,
|
|
76
86
|
* `query` and `body` beside them. Raw and upload routes are refused.
|
|
77
87
|
*/
|
|
78
|
-
invoke(name: string, input: Record<string, unknown
|
|
88
|
+
invoke(name: string, input: Record<string, unknown>, trust: Trust): Promise<{
|
|
79
89
|
status: number;
|
|
80
90
|
data: unknown;
|
|
81
91
|
}>;
|
|
92
|
+
/** Why `trust` may not call route `name` ({status, error}), or undefined when it may. */
|
|
93
|
+
allowed(name: string, trust: Trust): {
|
|
94
|
+
status: number;
|
|
95
|
+
error: string;
|
|
96
|
+
} | undefined;
|
|
82
97
|
}
|
|
83
|
-
export declare function json(res: http.ServerResponse, body: unknown, status?: number): void;
|
|
98
|
+
export declare function json(res: http.ServerResponse, body: unknown, status?: number, headers?: Record<string, string>): void;
|
|
84
99
|
export declare function readBody(req: http.IncomingMessage): Promise<string>;
|
|
85
100
|
/** A raw upload body (attachments), capped at `max` bytes. */
|
|
86
101
|
export declare function readRawBody(req: http.IncomingMessage, max: number): Promise<Buffer>;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import { touch } from "../live.js";
|
|
3
|
-
/** A result with a status other than 200 (201 created, 409 with a body, …). */
|
|
3
|
+
/** A result with a status other than 200 (201 created, 409 with a body, …), and headers for HTTP (a cookie); the socket ignores them. */
|
|
4
4
|
export class Reply {
|
|
5
5
|
status;
|
|
6
6
|
body;
|
|
7
|
-
|
|
7
|
+
headers;
|
|
8
|
+
constructor(status, body, headers = {}) {
|
|
8
9
|
this.status = status;
|
|
9
10
|
this.body = body;
|
|
11
|
+
this.headers = headers;
|
|
10
12
|
}
|
|
11
13
|
}
|
|
12
|
-
export const reply = (status, body) => new Reply(status, body);
|
|
14
|
+
export const reply = (status, body, headers) => new Reply(status, body, headers);
|
|
13
15
|
/** A refusal with a status; the body is `{error}` unless given. */
|
|
14
16
|
export class ApiError extends Error {
|
|
15
17
|
status;
|
|
@@ -37,6 +39,16 @@ function validate(route, raw) {
|
|
|
37
39
|
}
|
|
38
40
|
/** Maps an error message to a status by the usual wording: "no <thing>" is 404, "… are off" 409, else 400. */
|
|
39
41
|
export const statusByMessage = (missing) => (message) => missing.test(message) ? 404 : /are off/.test(message) ? 409 : 400;
|
|
42
|
+
/** Why a caller may not use a route, or undefined when it may. */
|
|
43
|
+
function refusal(route, trust) {
|
|
44
|
+
if (route.access === "public")
|
|
45
|
+
return undefined;
|
|
46
|
+
if (trust.kind === "none")
|
|
47
|
+
return new ApiError(401, "pair this device first", { error: "pair this device first", pair: true });
|
|
48
|
+
if (route.access === "local" && trust.kind !== "local")
|
|
49
|
+
return new ApiError(403, "only on the machine kraftwerk runs on");
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
40
52
|
/** What a thrown error becomes: an ApiError's status and body, else the route's mapping. */
|
|
41
53
|
function failure(route, err) {
|
|
42
54
|
if (err instanceof ApiError)
|
|
@@ -44,11 +56,11 @@ function failure(route, err) {
|
|
|
44
56
|
const message = err.message;
|
|
45
57
|
return { status: typeof route.errors === "function" ? route.errors(message) : (route.errors ?? 500), data: { error: message } };
|
|
46
58
|
}
|
|
47
|
-
export function json(res, body, status = 200) {
|
|
59
|
+
export function json(res, body, status = 200, headers = {}) {
|
|
48
60
|
// A late error on an SSE response must not try to write headers again.
|
|
49
61
|
if (res.headersSent)
|
|
50
62
|
return void res.end();
|
|
51
|
-
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
63
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
|
|
52
64
|
res.end(JSON.stringify(body));
|
|
53
65
|
}
|
|
54
66
|
export function readBody(req) {
|
|
@@ -119,12 +131,16 @@ export function createRouter(routes) {
|
|
|
119
131
|
};
|
|
120
132
|
return {
|
|
121
133
|
routes,
|
|
122
|
-
async handle(req, res, url) {
|
|
134
|
+
async handle(req, res, url, trust) {
|
|
123
135
|
const seg = url.pathname.split("/").filter(Boolean);
|
|
124
136
|
const found = match(req.method ?? "GET", seg);
|
|
137
|
+
// An unpaired caller learns nothing about which routes exist.
|
|
125
138
|
if (!found)
|
|
126
|
-
return json(res, { error: "not found" }, 404);
|
|
139
|
+
return trust.kind === "none" ? json(res, { error: "pair this device first", pair: true }, 401) : json(res, { error: "not found" }, 404);
|
|
127
140
|
const { route } = found;
|
|
141
|
+
const refused = refusal(route, trust);
|
|
142
|
+
if (refused)
|
|
143
|
+
return json(res, refused.body, refused.status);
|
|
128
144
|
let bodyRead;
|
|
129
145
|
try {
|
|
130
146
|
const params = Object.fromEntries(Object.entries(found.params).map(([k, v]) => [k, decode(v)]));
|
|
@@ -133,6 +149,7 @@ export function createRouter(routes) {
|
|
|
133
149
|
res,
|
|
134
150
|
url,
|
|
135
151
|
params,
|
|
152
|
+
trust,
|
|
136
153
|
query: url.searchParams,
|
|
137
154
|
body: () => (bodyRead ??= readBody(req).then((raw) => {
|
|
138
155
|
if (!raw.trim())
|
|
@@ -153,7 +170,7 @@ export function createRouter(routes) {
|
|
|
153
170
|
if (route.raw)
|
|
154
171
|
return;
|
|
155
172
|
if (result instanceof Reply)
|
|
156
|
-
return json(res, result.body, result.status);
|
|
173
|
+
return json(res, result.body, result.status, result.headers);
|
|
157
174
|
return json(res, result);
|
|
158
175
|
}
|
|
159
176
|
catch (err) {
|
|
@@ -161,10 +178,20 @@ export function createRouter(routes) {
|
|
|
161
178
|
return json(res, f.data, f.status);
|
|
162
179
|
}
|
|
163
180
|
},
|
|
164
|
-
|
|
181
|
+
allowed(name, trust) {
|
|
182
|
+
const route = byName.get(name);
|
|
183
|
+
if (!route)
|
|
184
|
+
return { status: 404, error: `no route ${name}` };
|
|
185
|
+
const refused = refusal(route, trust);
|
|
186
|
+
return refused ? { status: refused.status, error: refused.message } : undefined;
|
|
187
|
+
},
|
|
188
|
+
async invoke(name, input, trust) {
|
|
165
189
|
const route = byName.get(name);
|
|
166
190
|
if (!route)
|
|
167
191
|
return { status: 404, data: { error: `no route ${name}` } };
|
|
192
|
+
const refused = refusal(route, trust);
|
|
193
|
+
if (refused)
|
|
194
|
+
return { status: refused.status, data: refused.body };
|
|
168
195
|
if (route.raw || route.upload)
|
|
169
196
|
return { status: 400, data: { error: `${name} is HTTP only` } };
|
|
170
197
|
try {
|
|
@@ -183,6 +210,7 @@ export function createRouter(routes) {
|
|
|
183
210
|
url: new URL(`http://invoke${route.path}`),
|
|
184
211
|
params,
|
|
185
212
|
query,
|
|
213
|
+
trust,
|
|
186
214
|
body: async () => validate(route, input.body ?? {}),
|
|
187
215
|
};
|
|
188
216
|
const result = await route.handle(c);
|
|
@@ -1,3 +1,17 @@
|
|
|
1
1
|
import type { Workspace } from "../../core/workspace.js";
|
|
2
|
+
import type { Hub } from "../server.js";
|
|
3
|
+
import type { Trust } from "../trust.js";
|
|
2
4
|
import type { Socket } from "../ws.js";
|
|
3
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `recheck` re-derives the connection's trust (a device's token may have
|
|
7
|
+
* been revoked since it connected): a device whose answer is no longer
|
|
8
|
+
* `device` is disconnected at once (close code 4401).
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* `workspaceOf` finds the workspace a message is for: its `ws` (a slug), or
|
|
12
|
+
* the connection's own (the one the socket was opened for) when it names
|
|
13
|
+
* none. A daemon's socket opened at its root has no workspace of its own:
|
|
14
|
+
* there, a message without `ws` reaches only the machine's routes (hub,
|
|
15
|
+
* devices, protocol).
|
|
16
|
+
*/
|
|
17
|
+
export declare function serveSocket(conn: Socket, workspaceOf: (slug?: string) => Workspace | undefined, version: string, trust: Trust, recheck: () => Promise<Trust>, hub: Hub): void;
|