@netnodeag/kraftwerk 0.76.0 → 0.78.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 +33 -27
- package/dist/cli/cloud.d.ts +2 -0
- package/dist/cli/cloud.js +262 -0
- package/dist/cli/daemon.js +9 -0
- package/dist/cli/doctor.js +16 -8
- package/dist/cli/init.js +2 -2
- package/dist/cli/invite.d.ts +2 -0
- package/dist/cli/invite.js +48 -0
- package/dist/cli/kraftwerk.js +7 -0
- package/dist/client/relay.d.ts +40 -0
- package/dist/client/relay.js +35 -0
- package/dist/client/routes.d.ts +5 -0
- package/dist/client/routes.js +5 -0
- package/dist/config.d.ts +15 -28
- package/dist/config.js +5 -19
- package/dist/core/chat/channel-turns.d.ts +2 -2
- package/dist/core/chat/channel-turns.js +2 -2
- package/dist/core/chat/sessions.d.ts +20 -3
- package/dist/core/chat/sessions.js +22 -7
- package/dist/core/chat/types.d.ts +21 -0
- package/dist/core/cloud.d.ts +152 -0
- package/dist/core/cloud.js +98 -0
- package/dist/core/routines.d.ts +22 -2
- package/dist/core/routines.js +26 -0
- package/dist/server/api/chats.js +45 -8
- package/dist/server/api/cloud.d.ts +86 -0
- package/dist/server/api/cloud.js +51 -0
- package/dist/server/api/devices.d.ts +15 -9
- package/dist/server/api/devices.js +3 -2
- package/dist/server/api/index.d.ts +108 -11
- package/dist/server/api/index.js +2 -0
- package/dist/server/api/router.d.ts +3 -2
- package/dist/server/api/router.js +12 -0
- package/dist/server/api/workspace.d.ts +16 -1
- package/dist/server/api/workspace.js +2 -1
- package/dist/server/cloud.d.ts +104 -33
- package/dist/server/cloud.js +279 -185
- package/dist/server/hub-context.d.ts +1 -1
- package/dist/server/hub-context.js +3 -3
- package/dist/server/members.d.ts +87 -0
- package/dist/server/members.js +117 -0
- package/dist/server/relay.d.ts +21 -1
- package/dist/server/relay.js +74 -3
- package/dist/server/server.d.ts +3 -2
- package/dist/server/server.js +13 -6
- package/dist/server/trust.d.ts +16 -0
- package/dist/server/trust.js +12 -1
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +4 -4
- package/web/dist/assets/{dist-T1sNLC62.js → dist-B-yVL5lk.js} +1 -1
- package/web/dist/assets/{dist-DFXmaDc5.js → dist-B9EMB1Ie.js} +1 -1
- package/web/dist/assets/{dist-BQpWYkxm.js → dist-BTc878j7.js} +1 -1
- package/web/dist/assets/{dist-fgB7OEJy.js → dist-C90kUzps.js} +1 -1
- package/web/dist/assets/{dist-l7foWlFx.js → dist-CpLdAqFQ.js} +1 -1
- package/web/dist/assets/{dist-flnLXEzU.js → dist-Crnb8VVO.js} +1 -1
- package/web/dist/assets/{dist-vuciRCSz.js → dist-D0rjnN8S.js} +1 -1
- package/web/dist/assets/{dist-BTVP-6On.js → dist-D4SDXoGw.js} +1 -1
- package/web/dist/assets/{dist-BwIca_Uj.js → dist-DHRhdODt.js} +1 -1
- package/web/dist/assets/{dist-BYkcY-Vl.js → dist-DQbifCX0.js} +1 -1
- package/web/dist/assets/dist-lSJ-O7kT.js +1 -0
- package/web/dist/assets/{editor-C6Ok8NPo.js → editor-DHAdW1g_.js} +3 -3
- package/web/dist/assets/{index-z4tpCCzk.js → index-D7JVAjll.js} +5 -5
- package/web/dist/index.html +1 -1
- package/web/dist-remote/sw.js +1 -1
- package/web/dist/assets/dist-BURf1l-A.js +0 -1
package/README.md
CHANGED
|
@@ -296,8 +296,8 @@ fields are optional. `workflows:` sets the workflows root, `output:` the
|
|
|
296
296
|
run-artifact directory (default `output/`), `knowledge:` the OKF bundle root
|
|
297
297
|
(default `knowledge/`), and `agents:` the agent-definition root (default
|
|
298
298
|
`agents/`). `repos:` turns on the [repositories](#repositories) folder.
|
|
299
|
-
`cloud
|
|
300
|
-
[kraftwerk cloud](#
|
|
299
|
+
`cloud: { enabled: false }` leaves the workspace out of this machine's
|
|
300
|
+
record in the [kraftwerk cloud](#this-machine-in-the-kraftwerk-cloud).
|
|
301
301
|
|
|
302
302
|
`name:` is the title shown in the UI and can change freely. `slug:` is the
|
|
303
303
|
workspace's address on this machine. It defaults to the folder name
|
|
@@ -486,38 +486,44 @@ repo. Agent logins made inside the container persist in the `agent-home`
|
|
|
486
486
|
volume. This is a different image from the `kraftwerk-runner` sandbox
|
|
487
487
|
(`runner/Dockerfile`) used by `run --sandbox`.
|
|
488
488
|
|
|
489
|
-
###
|
|
489
|
+
### This machine in the kraftwerk cloud
|
|
490
490
|
|
|
491
491
|
The kraftwerk cloud is the `~/.kraftwerk` registry made reachable over the
|
|
492
|
-
internet:
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
492
|
+
internet: the daemon (`kraftwerk daemon`) registers **this machine** once
|
|
493
|
+
when it starts and sends a heartbeat while it runs; the workspaces it serves
|
|
494
|
+
ride along as children of that record, so you can see from anywhere which
|
|
495
|
+
of your workspaces are up, and where. **This is on by default.** A
|
|
496
|
+
standalone `kraftwerk ui` never registers. Registration is best-effort: a
|
|
497
|
+
cloud that is down never affects the UI.
|
|
498
|
+
|
|
499
|
+
The machine's id is its relay id (the key in `~/.kraftwerk/relay.json`,
|
|
500
|
+
created at the daemon's first start with the relay still off), proven with
|
|
501
|
+
the relay secret. Until someone claims the machine it registers
|
|
502
|
+
anonymously: workspace names, slugs and ids only, no paths. Claiming
|
|
503
|
+
happens in the browser — no password passes through the CLI:
|
|
504
|
+
|
|
505
|
+
```bash
|
|
506
|
+
kraftwerk login --open # prints (and opens) the claim link and code, waits for the claim
|
|
507
|
+
kraftwerk cloud # registered? claimed? url, machine id, last heartbeat
|
|
508
|
+
kraftwerk logout # release the machine; a fresh claim code follows
|
|
509
|
+
kraftwerk cloud off # turn it off for this machine (the daemon says goodbye); `on [--url]` back on
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
The claim code is also shown under Settings → Cloud. The switch and the
|
|
513
|
+
cloud's origin live in `~/.kraftwerk/cloud.json` (`{ "enabled": false,
|
|
514
|
+
"url": "…" }`); `KRAFTWERK_CLOUD_URL` in the environment wins (`off` turns
|
|
515
|
+
the feature off — what the test suites set). In kraftwerk.yml, `cloud:` is
|
|
516
|
+
a per-workspace veto only:
|
|
506
517
|
|
|
507
518
|
```yaml
|
|
508
519
|
cloud:
|
|
509
|
-
|
|
510
|
-
interval: 60 # seconds between heartbeats (default 60)
|
|
511
|
-
enabled: false # opt out of the cloud for this workspace
|
|
520
|
+
enabled: false # leave this workspace out of the machine's record
|
|
512
521
|
```
|
|
513
522
|
|
|
514
|
-
`
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
`token:` key in kraftwerk.yml is refused. `/api/meta` reports the
|
|
519
|
-
connection state and the claim code; `kraftwerk doctor` prints what the
|
|
520
|
-
block will do.
|
|
523
|
+
(`cloud.url` there is deprecated — honored with a warning when the machine
|
|
524
|
+
names no origin — and `cloud.interval` is ignored: the cloud sets the
|
|
525
|
+
cadence.) `/api/meta` and `/api/cloud` report the machine's state and claim
|
|
526
|
+
code; `kraftwerk doctor` prints what happens to the workspace.
|
|
521
527
|
|
|
522
528
|
## Projects
|
|
523
529
|
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { CloudError, claimUrlFor, envCloudOff, machineCloud, machineGoodbye, machineHeartbeat, machineRegister, machineUnlink, writeCloudFile, } from "../core/cloud.js";
|
|
5
|
+
import { findDaemon } from "../core/daemon.js";
|
|
6
|
+
import { identityString, readIdentity } from "../core/identity.js";
|
|
7
|
+
import { relayId, fromBase64Url } from "../client/relay.js";
|
|
8
|
+
import { machineCredentials, readRelaySettings } from "../server/relay.js";
|
|
9
|
+
import { getPkgVersion } from "../server/version.js";
|
|
10
|
+
const POLL_MS = 2000;
|
|
11
|
+
async function daemonCloud(port, method, route, body) {
|
|
12
|
+
const r = await fetch(`http://127.0.0.1:${port}${route}`, {
|
|
13
|
+
method,
|
|
14
|
+
headers: body ? { "content-type": "application/json" } : {},
|
|
15
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
16
|
+
signal: AbortSignal.timeout(15_000),
|
|
17
|
+
});
|
|
18
|
+
const data = (await r.json().catch(() => ({})));
|
|
19
|
+
if (!r.ok)
|
|
20
|
+
throw new Error(data.error ?? `${r.status} from the daemon`);
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
const fail = (message, code = 1) => {
|
|
24
|
+
console.error(chalk.red(`✖ ${message}`));
|
|
25
|
+
process.exit(code);
|
|
26
|
+
};
|
|
27
|
+
function openBrowser(url) {
|
|
28
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
29
|
+
try {
|
|
30
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
}
|
|
34
|
+
/** The machine's id without creating a key: null before the daemon's first start (or `kraftwerk login`). */
|
|
35
|
+
async function machineIdIfAny() {
|
|
36
|
+
const s = await readRelaySettings();
|
|
37
|
+
return s ? relayId(fromBase64Url(s.publicKey)) : null;
|
|
38
|
+
}
|
|
39
|
+
async function hello() {
|
|
40
|
+
const id = await readIdentity();
|
|
41
|
+
return { name: id.machine, handle: id.handle, hostname: os.hostname(), platform: `${process.platform}-${process.arch}`, version: await getPkgVersion() };
|
|
42
|
+
}
|
|
43
|
+
function printClaim(st) {
|
|
44
|
+
if (!st.claimCode)
|
|
45
|
+
return;
|
|
46
|
+
console.log(` claim code ${chalk.bold(st.claimCode)}`);
|
|
47
|
+
console.log(` claim link ${chalk.cyan(st.claimUrl ?? claimUrlFor(st.url, st.claimCode) ?? "")}`);
|
|
48
|
+
}
|
|
49
|
+
async function showStatus(opts) {
|
|
50
|
+
const [cloud, identity, id, daemon] = await Promise.all([machineCloud(), readIdentity(), machineIdIfAny(), findDaemon()]);
|
|
51
|
+
const live = daemon ? await daemonCloud(daemon.port, "GET", "/api/cloud").catch(() => null) : null;
|
|
52
|
+
const out = {
|
|
53
|
+
enabled: cloud.enabled,
|
|
54
|
+
url: cloud.url,
|
|
55
|
+
source: cloud.source,
|
|
56
|
+
...(cloud.reason ? { reason: cloud.reason } : {}),
|
|
57
|
+
machine: identityString(identity),
|
|
58
|
+
id,
|
|
59
|
+
daemon: !!daemon,
|
|
60
|
+
state: live?.state ?? "off",
|
|
61
|
+
registered: live?.state === "connected",
|
|
62
|
+
claimed: !!live?.account,
|
|
63
|
+
account: live?.account ?? null,
|
|
64
|
+
claimCode: live?.claimCode ?? null,
|
|
65
|
+
claimUrl: live?.claimUrl ?? null,
|
|
66
|
+
lastSeen: live?.lastSeen ?? null,
|
|
67
|
+
interval: live?.interval ?? null,
|
|
68
|
+
workspaces: live?.workspaces ?? null,
|
|
69
|
+
...(live?.error ? { error: live.error } : {}),
|
|
70
|
+
};
|
|
71
|
+
if (opts.json)
|
|
72
|
+
return void console.log(JSON.stringify(out, null, 2));
|
|
73
|
+
const dot = !cloud.enabled ? chalk.dim("○") : out.claimed ? chalk.green("●") : out.registered ? chalk.yellow("●") : chalk.dim("●");
|
|
74
|
+
console.log(`${dot} kraftwerk cloud ${cloud.enabled ? chalk.green("on") : chalk.dim("off")} ${chalk.dim(`${cloud.url} (${cloud.source})`)}`);
|
|
75
|
+
if (!cloud.enabled)
|
|
76
|
+
console.log(chalk.dim(` ${cloud.reason}`));
|
|
77
|
+
console.log(` machine ${identityString(identity)} ${chalk.dim(id ?? "no id yet — created at the daemon's first start")}`);
|
|
78
|
+
if (!cloud.enabled)
|
|
79
|
+
return;
|
|
80
|
+
if (!daemon) {
|
|
81
|
+
console.log(` state ${chalk.dim("not reporting — the daemon registers this machine (`kraftwerk daemon start`)")}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!live)
|
|
85
|
+
return void console.log(` state ${chalk.yellow("the daemon did not answer")}`);
|
|
86
|
+
if (live.state === "error")
|
|
87
|
+
console.log(` state ${chalk.red("error")} ${chalk.dim(live.error ?? "")}`);
|
|
88
|
+
else if (live.state !== "connected")
|
|
89
|
+
console.log(` state ${live.state}`);
|
|
90
|
+
else if (live.account)
|
|
91
|
+
console.log(` state ${chalk.green("claimed")} by ${chalk.bold(live.account)}`);
|
|
92
|
+
else
|
|
93
|
+
console.log(` state ${chalk.yellow("registered, unclaimed")} ${chalk.dim("— `kraftwerk login` claims it")}`);
|
|
94
|
+
if (live.state === "connected" && !live.account)
|
|
95
|
+
printClaim(live);
|
|
96
|
+
if (live.lastSeen)
|
|
97
|
+
console.log(` heartbeat ${new Date(live.lastSeen).toLocaleTimeString()} ${chalk.dim(`every ${live.interval ?? 60}s · ${live.workspaces ?? 0} workspace${live.workspaces === 1 ? "" : "s"} reported`)}`);
|
|
98
|
+
}
|
|
99
|
+
async function setEnabled(enabled, url) {
|
|
100
|
+
if (url && !/^https?:\/\/[^/?#]+\/?$/.test(url))
|
|
101
|
+
fail("--url is the cloud's origin, e.g. https://srv.kraftwerk-cloud.netnode.cloud", 2);
|
|
102
|
+
await writeCloudFile({ enabled, url });
|
|
103
|
+
const cloud = await machineCloud();
|
|
104
|
+
const daemon = await findDaemon();
|
|
105
|
+
// The daemon follows at once (off: goodbye); best-effort — the file is what counts at its next start.
|
|
106
|
+
const live = daemon ? await daemonCloud(daemon.port, "POST", "/api/cloud", { enabled, ...(url ? { url } : {}) }).catch(() => null) : null;
|
|
107
|
+
if (!enabled) {
|
|
108
|
+
console.log(`${chalk.green("✔")} cloud off for this machine ${chalk.dim(daemon ? (live ? "— the daemon said goodbye" : "— the daemon did not answer; it stops reporting at its next start") : "")}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (envCloudOff())
|
|
112
|
+
console.log(chalk.yellow("⚠ KRAFTWERK_CLOUD_URL=off in this environment keeps it off here"));
|
|
113
|
+
console.log(`${chalk.green("✔")} cloud on for this machine: ${chalk.cyan(cloud.url)}`);
|
|
114
|
+
if (!daemon)
|
|
115
|
+
console.log(chalk.dim(" the daemon registers this machine when it runs (`kraftwerk daemon start`); `kraftwerk login` claims it"));
|
|
116
|
+
else if (live?.state === "connected")
|
|
117
|
+
console.log(chalk.dim(live.account ? ` registered, claimed by ${live.account}` : " registered — `kraftwerk login` claims it"));
|
|
118
|
+
else if (live?.state === "error")
|
|
119
|
+
console.log(chalk.yellow(` the cloud did not answer: ${live.error ?? ""} (the daemon retries)`));
|
|
120
|
+
}
|
|
121
|
+
async function session() {
|
|
122
|
+
const daemon = await findDaemon();
|
|
123
|
+
if (daemon) {
|
|
124
|
+
const port = daemon.port;
|
|
125
|
+
// Off in the daemon (it read cloud.json before `login` turned it on): turning it on registers.
|
|
126
|
+
let first = true;
|
|
127
|
+
return {
|
|
128
|
+
status: async () => {
|
|
129
|
+
const st = first ? await daemonCloud(port, "POST", "/api/cloud", { enabled: true }) : await daemonCloud(port, "POST", "/api/cloud/sync");
|
|
130
|
+
first = false;
|
|
131
|
+
return st;
|
|
132
|
+
},
|
|
133
|
+
end: async () => { },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const cloud = await machineCloud();
|
|
137
|
+
const version = await getPkgVersion();
|
|
138
|
+
const creds = await machineCredentials();
|
|
139
|
+
let registered = false;
|
|
140
|
+
return {
|
|
141
|
+
status: async () => {
|
|
142
|
+
try {
|
|
143
|
+
const r = registered ? await machineHeartbeat(cloud.url, creds, { version }) : await machineRegister(cloud.url, creds, await hello(), []);
|
|
144
|
+
registered = true;
|
|
145
|
+
return { state: "connected", url: cloud.url, account: r.account, claimCode: r.claimCode, claimUrl: claimUrlFor(cloud.url, r.claimCode) };
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (err instanceof CloudError && err.status === 403)
|
|
149
|
+
throw new Error(`${cloud.url} refuses this machine's secret (403) — another machine holds this id`);
|
|
150
|
+
return { state: "error", url: cloud.url, error: err.message };
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
// Without a daemon nothing reports afterwards: the record says so now instead of after the grace period.
|
|
154
|
+
end: async () => {
|
|
155
|
+
if (registered)
|
|
156
|
+
await machineGoodbye(cloud.url, creds, version).catch(() => { });
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async function login(opts) {
|
|
161
|
+
if (envCloudOff())
|
|
162
|
+
fail("KRAFTWERK_CLOUD_URL=off in this environment — unset it to log in", 2);
|
|
163
|
+
const timeout = Number(opts.timeout ?? 600);
|
|
164
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
165
|
+
fail("--timeout must be a positive number of seconds", 2);
|
|
166
|
+
const before = await machineCloud();
|
|
167
|
+
if (!before.enabled) {
|
|
168
|
+
await writeCloudFile({ enabled: true });
|
|
169
|
+
console.log(chalk.dim("cloud turned on for this machine (`kraftwerk cloud off` turns it off)"));
|
|
170
|
+
}
|
|
171
|
+
const s = await session().catch((err) => fail(err.message));
|
|
172
|
+
try {
|
|
173
|
+
let st = await s.status();
|
|
174
|
+
if (st.state === "off")
|
|
175
|
+
fail(`the daemon keeps the cloud off: ${st.reason ?? "off"}`);
|
|
176
|
+
if (st.state !== "connected")
|
|
177
|
+
fail(`could not reach ${st.url}: ${st.error ?? st.state}`);
|
|
178
|
+
if (st.account) {
|
|
179
|
+
console.log(`${chalk.green("✔")} already logged in as ${chalk.bold(st.account)} ${chalk.dim(`(${st.url}) — \`kraftwerk logout\` releases this machine`)}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!st.claimCode)
|
|
183
|
+
fail(`${st.url} handed out no claim code`);
|
|
184
|
+
console.log(`Sign in (or sign up) in the browser and claim this machine:`);
|
|
185
|
+
printClaim(st);
|
|
186
|
+
if (opts.open && st.claimUrl)
|
|
187
|
+
openBrowser(st.claimUrl);
|
|
188
|
+
console.log(chalk.dim(`waiting for the claim … (Ctrl-C stops; the code stays valid)`));
|
|
189
|
+
const until = Date.now() + timeout * 1000;
|
|
190
|
+
while (Date.now() < until) {
|
|
191
|
+
await new Promise((r) => setTimeout(r, Math.min(POLL_MS, Math.max(0, until - Date.now()))));
|
|
192
|
+
st = await s.status().catch((err) => ({ ...st, state: "error", error: err.message }));
|
|
193
|
+
if (st.account) {
|
|
194
|
+
console.log(`${chalk.green("✔")} logged in as ${chalk.bold(st.account)} ${chalk.dim(`— this machine is in your account at ${st.url}`)}`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
fail(`not claimed within ${timeout}s — the code stays valid; \`kraftwerk login\` waits again`);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
await s.end();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function logout() {
|
|
205
|
+
if (envCloudOff())
|
|
206
|
+
fail("KRAFTWERK_CLOUD_URL=off in this environment", 2);
|
|
207
|
+
const cloud = await machineCloud();
|
|
208
|
+
if (!cloud.enabled)
|
|
209
|
+
fail(`the cloud is off for this machine — ${cloud.reason}`, 2);
|
|
210
|
+
const daemon = await findDaemon();
|
|
211
|
+
let st;
|
|
212
|
+
if (daemon) {
|
|
213
|
+
st = await daemonCloud(daemon.port, "POST", "/api/cloud/unlink").catch((err) => fail(err.message));
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const creds = await machineCredentials();
|
|
217
|
+
const r = await machineUnlink(cloud.url, creds, await getPkgVersion()).catch((err) => fail(err instanceof CloudError && err.status === 404 ? `this machine is not registered at ${cloud.url}` : `${cloud.url}: ${err.message}`));
|
|
218
|
+
st = { url: cloud.url, claimCode: r.claimCode, claimUrl: claimUrlFor(cloud.url, r.claimCode) };
|
|
219
|
+
}
|
|
220
|
+
console.log(`${chalk.green("✔")} logged out — this machine is unclaimed at ${st.url}`);
|
|
221
|
+
printClaim(st);
|
|
222
|
+
console.log(chalk.dim(" `kraftwerk login` claims it again"));
|
|
223
|
+
}
|
|
224
|
+
export function registerCloudCommands(program) {
|
|
225
|
+
const cloud = program
|
|
226
|
+
.command("cloud")
|
|
227
|
+
.description("This machine in the kraftwerk cloud: status, on, off (`kraftwerk login` claims it)")
|
|
228
|
+
.option("--json", "Machine-readable output")
|
|
229
|
+
.addHelpText("after", `
|
|
230
|
+
The daemon registers this machine once; its open workspaces ride along.
|
|
231
|
+
kraftwerk cloud registered? claimed? url, machine id, last heartbeat
|
|
232
|
+
kraftwerk cloud on [--url <u>] turn it on (~/.kraftwerk/cloud.json); the daemon follows at once
|
|
233
|
+
kraftwerk cloud off turn it off; the running daemon says goodbye
|
|
234
|
+
kraftwerk login [--open] claim this machine into your account (in the browser)
|
|
235
|
+
kraftwerk logout release it
|
|
236
|
+
A workspace stays out with \`cloud: { enabled: false }\` in its kraftwerk.yml.`)
|
|
237
|
+
.action(async (opts) => showStatus(opts));
|
|
238
|
+
cloud
|
|
239
|
+
.command("status")
|
|
240
|
+
.description("Registered? claimed? url, machine id, last heartbeat")
|
|
241
|
+
.option("--json", "Machine-readable output")
|
|
242
|
+
.action(async (opts) => showStatus({ json: opts.json ?? cloud.opts().json }));
|
|
243
|
+
cloud
|
|
244
|
+
.command("on")
|
|
245
|
+
.description("Turn the cloud on for this machine (the running daemon registers at once)")
|
|
246
|
+
.option("--url <url>", "The cloud's origin (default: the kraftwerk cloud)")
|
|
247
|
+
.action(async (opts) => setEnabled(true, opts.url));
|
|
248
|
+
cloud
|
|
249
|
+
.command("off")
|
|
250
|
+
.description("Turn the cloud off for this machine (the running daemon says goodbye)")
|
|
251
|
+
.action(async () => setEnabled(false));
|
|
252
|
+
program
|
|
253
|
+
.command("login")
|
|
254
|
+
.description("Claim this machine into your kraftwerk cloud account: prints the claim link and code, waits for the claim")
|
|
255
|
+
.option("--open", "Open the claim link in the browser")
|
|
256
|
+
.option("--timeout <seconds>", "How long to wait for the claim", "600")
|
|
257
|
+
.action(async (opts) => login(opts));
|
|
258
|
+
program
|
|
259
|
+
.command("logout")
|
|
260
|
+
.description("Release this machine from its kraftwerk cloud account (a fresh claim code follows)")
|
|
261
|
+
.action(async () => logout());
|
|
262
|
+
}
|
package/dist/cli/daemon.js
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from "chalk";
|
|
|
2
2
|
import { DAEMON_PORT, daemonState, lastOpenRoots, setDaemonVersion, writeDaemonFile } from "../core/daemon.js";
|
|
3
3
|
import { tildify } from "../core/instances.js";
|
|
4
4
|
import { networkUrls } from "../server/bind.js";
|
|
5
|
+
import { startMachineCloud } from "../server/cloud.js";
|
|
5
6
|
import { startPushWatcher } from "../server/push.js";
|
|
6
7
|
import { applyRelay, relayPush } from "../server/relay.js";
|
|
7
8
|
import { startHub } from "../server/server.js";
|
|
@@ -51,6 +52,14 @@ export async function runDaemon(opts) {
|
|
|
51
52
|
console.log(` ${chalk.cyan(o.url)} ${chalk.dim(o.name)}${o.aliasPort ? chalk.dim(` · also :${o.aliasPort}`) : ""}`);
|
|
52
53
|
if (!hub.list().length)
|
|
53
54
|
console.log(chalk.dim(" no workspace open yet — `kraftwerk ui` in a workspace folder opens it here"));
|
|
55
|
+
// The machine in the kraftwerk cloud: registered once, the open workspaces as its children (`kraftwerk cloud`, `kraftwerk login`).
|
|
56
|
+
const cloud = await startMachineCloud({ version: await getPkgVersion(), workspaces: () => hub.list(), owner: hub });
|
|
57
|
+
if (cloud.state === "connected") {
|
|
58
|
+
console.log(`${chalk.green("✔")} Cloud: ${chalk.cyan(cloud.url)} ${chalk.dim(cloud.account ? `(claimed by ${cloud.account})` : `(unclaimed — \`kraftwerk login\` claims this machine)`)}`);
|
|
59
|
+
}
|
|
60
|
+
else if (cloud.state === "error") {
|
|
61
|
+
console.log(`${chalk.yellow("⚠")} Cloud: ${cloud.url} unreachable — ${cloud.error ?? "no answer"} ${chalk.dim("(retrying; nothing else depends on it)")}`);
|
|
62
|
+
}
|
|
54
63
|
// The relay (`kraftwerk remote on`): devices reach this machine from anywhere, end to end encrypted.
|
|
55
64
|
const relay = await applyRelay(hub.port, await getPkgVersion(), (line) => console.log(chalk.dim(line)));
|
|
56
65
|
// What needs you, pushed to the phones that asked (through the relay, sealed per device).
|
package/dist/cli/doctor.js
CHANGED
|
@@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
-
import {
|
|
5
|
+
import { machineCloud } from "../core/cloud.js";
|
|
6
|
+
import { addWorkspaceId, ignoreEntryFor, isDir, reposRootFor, resolveWorkspace, workspaceSlug } from "../config.js";
|
|
6
7
|
import { discoverWorkflows } from "../discover.js";
|
|
7
8
|
import { missingEnv } from "../yaml.js";
|
|
8
9
|
import { applyDotenv, DOTENV_FILE } from "../dotenv.js";
|
|
@@ -177,15 +178,22 @@ export async function runDoctor(cwd, opts = {}) {
|
|
|
177
178
|
report("warn", `${these.length} routine(s) name the retired identity ${old}`, `${names(these)} · \`kraftwerk doctor --fix\` moves them to ${me} and commits`);
|
|
178
179
|
}
|
|
179
180
|
}
|
|
180
|
-
//
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
// The kraftwerk cloud: the daemon registers the machine; doctor only says what would happen to this workspace.
|
|
182
|
+
const ymlCloud = project.config.cloud;
|
|
183
|
+
const cloud = await machineCloud(ymlCloud?.url ? [ymlCloud.url] : []);
|
|
184
|
+
if (ymlCloud?.enabled === false) {
|
|
185
|
+
report("info", "cloud: this workspace is left out", "cloud.enabled: false in kraftwerk.yml — the machine's cloud record does not list it");
|
|
186
|
+
}
|
|
187
|
+
else if (!cloud.enabled) {
|
|
188
|
+
report("info", "cloud: off", cloud.reason ?? "");
|
|
186
189
|
}
|
|
187
190
|
else {
|
|
188
|
-
report("
|
|
191
|
+
report("ok", `cloud: ${cloud.url}`, "the daemon registers this machine and lists this workspace; `kraftwerk login` claims it, `kraftwerk cloud off` opts out");
|
|
192
|
+
}
|
|
193
|
+
if (ymlCloud?.url) {
|
|
194
|
+
report("warn", "kraftwerk.yml cloud.url is deprecated", cloud.source === "kraftwerk.yml"
|
|
195
|
+
? `the cloud is the machine's now — \`kraftwerk cloud on --url ${ymlCloud.url}\`, then remove it from kraftwerk.yml`
|
|
196
|
+
: `ignored: the machine's cloud is ${cloud.url} (${cloud.source}) — remove it from kraftwerk.yml`);
|
|
189
197
|
}
|
|
190
198
|
const found = project.workflowsRoot ? await discoverWorkflows(cwd) : [];
|
|
191
199
|
if (!project.workflowsRoot) {
|
package/dist/cli/init.js
CHANGED
|
@@ -31,8 +31,8 @@ skills: ${DATA_DIR}/skills # workspace skills (shared instruction packag
|
|
|
31
31
|
# root: ${DATA_DIR}/repos # clones land here (git-ignored); a bare \`repos:\` uses repos/ instead
|
|
32
32
|
# vibeables: # small apps built live in a chat with a preview pane (uncomment to enable; one folder per app under ${DATA_DIR}/vibeables, versioned with the workspace)
|
|
33
33
|
# projects: # goal-scoped folders — brief, systems of record, links to knowledge/vibeables/repos/workflows/agents — that chats work in (uncomment to enable; one folder per project under ${DATA_DIR}/projects)
|
|
34
|
-
# cloud: # the
|
|
35
|
-
# enabled: false # uncomment both lines to
|
|
34
|
+
# cloud: # the daemon registers this MACHINE with the kraftwerk cloud and lists this workspace (\`kraftwerk login\` claims the machine)
|
|
35
|
+
# enabled: false # uncomment both lines to leave this workspace out (\`kraftwerk cloud off\` turns the cloud off for the whole machine)
|
|
36
36
|
`;
|
|
37
37
|
const WORKFLOW_TEMPLATE = `# yaml-language-server: $schema=${SCHEMA_URL}
|
|
38
38
|
name: hello
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { resolveWorkspace, workspaceSlug } from "../config.js";
|
|
3
|
+
import { findDaemon } from "../core/daemon.js";
|
|
4
|
+
const fail = (message, code = 1) => {
|
|
5
|
+
console.error(chalk.red(`✖ ${message}`));
|
|
6
|
+
process.exit(code);
|
|
7
|
+
};
|
|
8
|
+
export function registerInviteCommand(program) {
|
|
9
|
+
program
|
|
10
|
+
.command("invite")
|
|
11
|
+
.description("Invite someone into this workspace as a remote member (no clone): prints the join code and link — the machine must be claimed (`kraftwerk login`)")
|
|
12
|
+
.option("--role <role>", "member (default) or owner")
|
|
13
|
+
.option("--workspace <slug>", "A workspace the daemon serves, instead of the one in this folder")
|
|
14
|
+
.option("--json", "Machine-readable output")
|
|
15
|
+
.action(async (opts) => {
|
|
16
|
+
if (opts.role !== undefined && opts.role !== "owner" && opts.role !== "member")
|
|
17
|
+
fail("--role is member or owner", 2);
|
|
18
|
+
let which;
|
|
19
|
+
if (opts.workspace)
|
|
20
|
+
which = { slug: opts.workspace };
|
|
21
|
+
else {
|
|
22
|
+
const ws = await resolveWorkspace(process.cwd()).catch((err) => fail(err.message));
|
|
23
|
+
if (!ws.configPath)
|
|
24
|
+
fail("not in a workspace folder (no kraftwerk.yml here or above) — run it there, or pass --workspace <slug>", 2);
|
|
25
|
+
if (!ws.config.id)
|
|
26
|
+
fail("this workspace has no id (ws_…) in kraftwerk.yml — run `kraftwerk doctor --fix`");
|
|
27
|
+
which = { root: ws.root, slug: workspaceSlug(ws) };
|
|
28
|
+
}
|
|
29
|
+
const daemon = await findDaemon();
|
|
30
|
+
if (!daemon)
|
|
31
|
+
fail("the daemon is not running — `kraftwerk daemon start` (it holds this machine's place in the cloud)");
|
|
32
|
+
const r = await fetch(`http://127.0.0.1:${daemon.port}/api/cloud/invite`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "content-type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ ...which, ...(opts.role ? { role: opts.role } : {}) }),
|
|
36
|
+
signal: AbortSignal.timeout(20_000),
|
|
37
|
+
}).catch((err) => fail(`the daemon did not answer: ${err.message}`));
|
|
38
|
+
const data = (await r.json().catch(() => ({})));
|
|
39
|
+
if (!r.ok)
|
|
40
|
+
fail(data.error ?? `${r.status} from the daemon`);
|
|
41
|
+
if (opts.json)
|
|
42
|
+
return void console.log(JSON.stringify(data, null, 2));
|
|
43
|
+
console.log(`${chalk.green("✔")} invite to ${chalk.bold(data.workspace.name)} ${chalk.dim(`(${data.workspace.slug}, ${data.workspace.id})`)} as ${data.role}`);
|
|
44
|
+
console.log(` code ${chalk.bold(data.code)}`);
|
|
45
|
+
console.log(` join link ${chalk.cyan(data.url)}`);
|
|
46
|
+
console.log(chalk.dim(` valid until ${new Date(data.expiresAt).toLocaleString()} — they join in the cloud and reach the workspace through this machine`));
|
|
47
|
+
});
|
|
48
|
+
}
|
package/dist/cli/kraftwerk.js
CHANGED
|
@@ -22,6 +22,8 @@ import { registerDaemonCommand } from "./daemon.js";
|
|
|
22
22
|
import { registerRemoteCommand } from "./remote.js";
|
|
23
23
|
import { registerJournalCommands } from "./journal.js";
|
|
24
24
|
import { registerWhoamiCommand } from "./whoami.js";
|
|
25
|
+
import { registerCloudCommands } from "./cloud.js";
|
|
26
|
+
import { registerInviteCommand } from "./invite.js";
|
|
25
27
|
import { applyDotenv } from "../dotenv.js";
|
|
26
28
|
import { resolveWorkspace } from "../config.js";
|
|
27
29
|
import { registerRoutineCommands } from "./routines.js";
|
|
@@ -46,6 +48,9 @@ import { runUi } from "./ui.js";
|
|
|
46
48
|
* kraftwerk trash ... what was deleted: list, restore, purge, empty
|
|
47
49
|
* kraftwerk devices ... paired devices: list, pair one, revoke one
|
|
48
50
|
* kraftwerk daemon the machine's kraftwerk: every open workspace at <slug>.localhost
|
|
51
|
+
* kraftwerk cloud [on|off|status] this machine in the kraftwerk cloud (the daemon registers it)
|
|
52
|
+
* kraftwerk login / logout claim this machine into your cloud account (in the browser) / release it
|
|
53
|
+
* kraftwerk invite [--role owner] invite a remote member into this workspace (join code + link, via the daemon)
|
|
49
54
|
* kraftwerk doctor [--fix] preflight: harness CLIs, docker, workflows, env, routine runners
|
|
50
55
|
* kraftwerk validate [paths...] validate without executing
|
|
51
56
|
*
|
|
@@ -302,6 +307,8 @@ registerDaemonCommand(program);
|
|
|
302
307
|
registerRemoteCommand(program);
|
|
303
308
|
registerJournalCommands(program);
|
|
304
309
|
registerWhoamiCommand(program);
|
|
310
|
+
registerCloudCommands(program);
|
|
311
|
+
registerInviteCommand(program);
|
|
305
312
|
program
|
|
306
313
|
.command("ui")
|
|
307
314
|
.description("Start the inspector web UI for this project's runs and workflows")
|
package/dist/client/relay.d.ts
CHANGED
|
@@ -28,6 +28,12 @@
|
|
|
28
28
|
* presents a paired device's token like any network caller, and the daemon
|
|
29
29
|
* applies the same trust rules (src/server/trust.ts). Anyone holding the
|
|
30
30
|
* pairing link still needs a pairing code or a token.
|
|
31
|
+
*
|
|
32
|
+
* Or, as a remote member of one workspace, it sends `{t:"auth",
|
|
33
|
+
* memberToken, workspaceId}` as its first sealed message and waits for the
|
|
34
|
+
* daemon's `{t:"auth", ok, …}` (src/server/members.ts): the whole channel
|
|
35
|
+
* is then that member's, scoped to that workspace. A client that sends no
|
|
36
|
+
* auth is a device's channel exactly as before.
|
|
31
37
|
*/
|
|
32
38
|
export declare const RELAY_PROTOCOL = 1;
|
|
33
39
|
export declare function toBase64(bytes: Uint8Array): string;
|
|
@@ -96,7 +102,27 @@ export type TunnelToDaemon = {
|
|
|
96
102
|
} | {
|
|
97
103
|
t: "abort";
|
|
98
104
|
id: number;
|
|
105
|
+
}
|
|
106
|
+
/** A member session: first, once, before any request (see src/server/members.ts). */
|
|
107
|
+
| {
|
|
108
|
+
t: "auth";
|
|
109
|
+
memberToken: string;
|
|
110
|
+
workspaceId: string;
|
|
99
111
|
};
|
|
112
|
+
/** Who the cloud says the member is, and the workspace the session is scoped to. */
|
|
113
|
+
export interface MemberAuth {
|
|
114
|
+
member: {
|
|
115
|
+
email: string;
|
|
116
|
+
handle: string;
|
|
117
|
+
role: "owner" | "member";
|
|
118
|
+
};
|
|
119
|
+
/** `slug` addresses it: /w/<slug>/api/… */
|
|
120
|
+
workspace: {
|
|
121
|
+
id: string;
|
|
122
|
+
slug: string;
|
|
123
|
+
name: string;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
100
126
|
export type TunnelToClient = {
|
|
101
127
|
t: "res";
|
|
102
128
|
id: number;
|
|
@@ -113,6 +139,13 @@ export type TunnelToClient = {
|
|
|
113
139
|
t: "fail";
|
|
114
140
|
id: number;
|
|
115
141
|
error: string;
|
|
142
|
+
} | ({
|
|
143
|
+
t: "auth";
|
|
144
|
+
ok: true;
|
|
145
|
+
} & MemberAuth) | {
|
|
146
|
+
t: "auth";
|
|
147
|
+
ok: false;
|
|
148
|
+
error: string;
|
|
116
149
|
};
|
|
117
150
|
/** The socket the client talks over — the browser's WebSocket and Node's global one both fit. */
|
|
118
151
|
export interface RelaySocket {
|
|
@@ -134,6 +167,8 @@ export interface Tunnel {
|
|
|
134
167
|
close(): void;
|
|
135
168
|
/** Resolves when the connection ends, with why. */
|
|
136
169
|
readonly closed: Promise<string>;
|
|
170
|
+
/** A member session's answer (ConnectOptions.member); absent on a device's channel. */
|
|
171
|
+
readonly member?: MemberAuth;
|
|
137
172
|
}
|
|
138
173
|
export interface ConnectOptions {
|
|
139
174
|
/** The relay's client endpoint, e.g. wss://srv.kraftwerk-cloud.netnode.cloud/api/relay/client */
|
|
@@ -144,6 +179,11 @@ export interface ConnectOptions {
|
|
|
144
179
|
connect?: (url: string) => RelaySocket;
|
|
145
180
|
/** Abort the handshake after this long (default 15 s). */
|
|
146
181
|
timeoutMs?: number;
|
|
182
|
+
/** Open the channel as a remote member of one workspace instead of a device: the daemon verifies it with the cloud. */
|
|
183
|
+
member?: {
|
|
184
|
+
token: string;
|
|
185
|
+
workspaceId: string;
|
|
186
|
+
};
|
|
147
187
|
}
|
|
148
188
|
/** Connect to a machine through the relay: the handshake, then a `fetch` that goes through it. */
|
|
149
189
|
export declare function connectTunnel(opts: ConnectOptions): Promise<Tunnel>;
|
package/dist/client/relay.js
CHANGED
|
@@ -28,6 +28,12 @@
|
|
|
28
28
|
* presents a paired device's token like any network caller, and the daemon
|
|
29
29
|
* applies the same trust rules (src/server/trust.ts). Anyone holding the
|
|
30
30
|
* pairing link still needs a pairing code or a token.
|
|
31
|
+
*
|
|
32
|
+
* Or, as a remote member of one workspace, it sends `{t:"auth",
|
|
33
|
+
* memberToken, workspaceId}` as its first sealed message and waits for the
|
|
34
|
+
* daemon's `{t:"auth", ok, …}` (src/server/members.ts): the whole channel
|
|
35
|
+
* is then that member's, scoped to that workspace. A client that sends no
|
|
36
|
+
* auth is a device's channel exactly as before.
|
|
31
37
|
*/
|
|
32
38
|
const subtle = () => globalThis.crypto.subtle;
|
|
33
39
|
const enc = new TextEncoder();
|
|
@@ -167,6 +173,7 @@ export async function connectTunnel(opts) {
|
|
|
167
173
|
const closed = new Promise((r) => (resolveClosed = r));
|
|
168
174
|
const pending = new Map();
|
|
169
175
|
let nextId = 1;
|
|
176
|
+
let onAuth = null;
|
|
170
177
|
const finish = (why) => {
|
|
171
178
|
if (closedWhy)
|
|
172
179
|
return;
|
|
@@ -230,6 +237,11 @@ export async function connectTunnel(opts) {
|
|
|
230
237
|
});
|
|
231
238
|
});
|
|
232
239
|
const receive = (msg) => {
|
|
240
|
+
if (msg.t === "auth") {
|
|
241
|
+
onAuth?.(msg);
|
|
242
|
+
onAuth = null;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
233
245
|
const p = pending.get(msg.id);
|
|
234
246
|
if (!p)
|
|
235
247
|
return;
|
|
@@ -277,6 +289,28 @@ export async function connectTunnel(opts) {
|
|
|
277
289
|
ws.send(await channel.seal(JSON.stringify(msg)));
|
|
278
290
|
};
|
|
279
291
|
await ready;
|
|
292
|
+
let member;
|
|
293
|
+
if (opts.member) {
|
|
294
|
+
const m = opts.member;
|
|
295
|
+
const answer = await new Promise((resolve, reject) => {
|
|
296
|
+
const timer = setTimeout(() => reject(new Error("the machine did not answer the member's hello")), opts.timeoutMs ?? 15_000);
|
|
297
|
+
onAuth = (msg) => {
|
|
298
|
+
clearTimeout(timer);
|
|
299
|
+
resolve(msg);
|
|
300
|
+
};
|
|
301
|
+
void closed.then((why) => {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
reject(new Error(why));
|
|
304
|
+
});
|
|
305
|
+
send({ t: "auth", memberToken: m.token, workspaceId: m.workspaceId }).catch(reject);
|
|
306
|
+
});
|
|
307
|
+
if (!answer.ok) {
|
|
308
|
+
ws.close(1000, "bye");
|
|
309
|
+
finish(answer.error);
|
|
310
|
+
throw new Error(answer.error);
|
|
311
|
+
}
|
|
312
|
+
member = { member: answer.member, workspace: answer.workspace };
|
|
313
|
+
}
|
|
280
314
|
const tunnelFetch = async (input, init) => {
|
|
281
315
|
if (closedWhy)
|
|
282
316
|
throw new Error(closedWhy);
|
|
@@ -314,5 +348,6 @@ export async function connectTunnel(opts) {
|
|
|
314
348
|
finish("closed");
|
|
315
349
|
},
|
|
316
350
|
closed,
|
|
351
|
+
...(member ? { member } : {}),
|
|
317
352
|
};
|
|
318
353
|
}
|