@netnodeag/kraftwerk 0.76.0 → 0.77.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.
Files changed (45) hide show
  1. package/README.md +33 -27
  2. package/dist/cli/cloud.d.ts +2 -0
  3. package/dist/cli/cloud.js +262 -0
  4. package/dist/cli/daemon.js +9 -0
  5. package/dist/cli/doctor.js +16 -8
  6. package/dist/cli/init.js +2 -2
  7. package/dist/cli/kraftwerk.js +4 -0
  8. package/dist/client/routes.d.ts +4 -0
  9. package/dist/client/routes.js +4 -0
  10. package/dist/config.d.ts +15 -28
  11. package/dist/config.js +5 -19
  12. package/dist/core/cloud.d.ts +115 -0
  13. package/dist/core/cloud.js +94 -0
  14. package/dist/server/api/cloud.d.ts +72 -0
  15. package/dist/server/api/cloud.js +38 -0
  16. package/dist/server/api/devices.d.ts +8 -8
  17. package/dist/server/api/index.d.ts +88 -10
  18. package/dist/server/api/index.js +2 -0
  19. package/dist/server/api/workspace.d.ts +16 -1
  20. package/dist/server/api/workspace.js +2 -1
  21. package/dist/server/cloud.d.ts +65 -33
  22. package/dist/server/cloud.js +207 -188
  23. package/dist/server/hub-context.d.ts +1 -1
  24. package/dist/server/hub-context.js +3 -3
  25. package/dist/server/relay.d.ts +13 -0
  26. package/dist/server/relay.js +29 -1
  27. package/dist/server/server.d.ts +3 -2
  28. package/dist/server/server.js +6 -6
  29. package/package.json +1 -1
  30. package/schema/kraftwerk.schema.json +4 -4
  31. package/web/dist/assets/{dist-T1sNLC62.js → dist-BGHweZKi.js} +1 -1
  32. package/web/dist/assets/{dist-DFXmaDc5.js → dist-BVPxOqcx.js} +1 -1
  33. package/web/dist/assets/{dist-BYkcY-Vl.js → dist-CCNyRSAA.js} +1 -1
  34. package/web/dist/assets/{dist-flnLXEzU.js → dist-CXoAMU1b.js} +1 -1
  35. package/web/dist/assets/{dist-l7foWlFx.js → dist-DCFREMXk.js} +1 -1
  36. package/web/dist/assets/{dist-BQpWYkxm.js → dist-DT2Tjvwx.js} +1 -1
  37. package/web/dist/assets/{dist-BwIca_Uj.js → dist-DdA5PoVJ.js} +1 -1
  38. package/web/dist/assets/{dist-fgB7OEJy.js → dist-DoD3qX1P.js} +1 -1
  39. package/web/dist/assets/{dist-vuciRCSz.js → dist-FFA4-S6d.js} +1 -1
  40. package/web/dist/assets/{dist-BTVP-6On.js → dist-yGx0qYbw.js} +1 -1
  41. package/web/dist/assets/dist-zjUUP5a9.js +1 -0
  42. package/web/dist/assets/{editor-C6Ok8NPo.js → editor-CyWapLQr.js} +3 -3
  43. package/web/dist/assets/{index-z4tpCCzk.js → index-CNXcNoKJ.js} +4 -4
  44. package/web/dist/index.html +1 -1
  45. package/web/dist/assets/dist-BURf1l-A.js +0 -1
@@ -1,53 +1,85 @@
1
+ import { type CloudUrlSource } from "../core/cloud.js";
1
2
  /**
2
- * The cloud manager client: kraftwerk.yml `cloud:` makes this instance
3
- * register with the kraftwerk cloud when the inspector starts and heartbeat
4
- * while it runs — the same record ~/.kraftwerk/instances holds locally,
5
- * kept on a server so an admin (and, with an account token, the user)
6
- * sees every instance across machines.
3
+ * The machine client: the daemon registers THIS MACHINE with the kraftwerk
4
+ * cloud once and heartbeats while it runs; the workspaces it serves ride
5
+ * along as children of that record (see core/cloud.ts for the contract and
6
+ * the settings). One per process, and only the daemon starts it
7
+ * (startMachineCloud from `kraftwerk daemon`): a standalone `kraftwerk ui`
8
+ * and test fixtures never talk to the cloud.
7
9
  *
8
- * Identity: the first registration hands out an id + secret, stored under
9
- * ~/.kraftwerk/cloud/<hash(url, root)>.json and presented on every later
10
- * call, so a restart updates the record instead of creating a new one.
11
- * When the cloud does not recognise the id (data reset) it hands out a
12
- * fresh one and the file is rewritten.
13
- *
14
- * Everything is best-effort: a cloud that is down never affects the UI.
15
- * The status is reported in /api/meta so the UI can show it.
10
+ * start ensure the machine key (relay.json, relay stays off), drop the
11
+ * retired per-workspace identities (~/.kraftwerk/cloud/), and,
12
+ * unless cloud.json or KRAFTWERK_CLOUD_URL=off says no, register
13
+ * — anonymous: the answer carries a claim code until someone
14
+ * claims the machine (`kraftwerk login`, or the code in the UI)
15
+ * heartbeat every `interval` the cloud asked for; the workspace list rides
16
+ * along whenever it changed (opened, closed, vetoed, claimed).
17
+ * A claim made in the cloud arrives in the answer
18
+ * privacy while unclaimed, entries carry name/slug/ws-id only — no
19
+ * roots; once claimed, roots too
20
+ * recovery a cloud that no longer knows the machine (data reset) gets a
21
+ * fresh registration; a cloud that is down is retried at the
22
+ * same cadence and never affects the UI
23
+ * goodbye on shutdown (bounded), and when `kraftwerk cloud off` reaches
24
+ * the running daemon
16
25
  */
17
26
  export type CloudState = "off" | "connecting" | "connected" | "error";
18
27
  export interface CloudStatus {
19
28
  url: string;
20
29
  state: CloudState;
21
- /** Last error message while connecting or heartbeating. */
30
+ /** Why it is off, when it is. */
31
+ reason?: string;
32
+ /** Where the URL came from (env, cloud.json, a deprecated kraftwerk.yml cloud.url, the default). */
33
+ source?: CloudUrlSource;
34
+ /** Last error message while registering or heartbeating. */
22
35
  error?: string;
23
- /** Instance id in the cloud once registered. */
36
+ /** The machine's id in the cloud (its relay id). */
24
37
  id?: string;
25
- /** Email of the linked kraftwerk account, when a token was presented and accepted. */
38
+ /** This machine's handle@machine. */
39
+ machine?: string;
40
+ /** Email of the account that claimed the machine; null while unclaimed. */
26
41
  account?: string | null;
27
- /** Effective seconds between heartbeats (the cloud may raise the configured one). */
42
+ /** Effective seconds between heartbeats (what the cloud asked for). */
28
43
  interval?: number;
44
+ /** Last successful register or heartbeat. */
29
45
  lastSeen?: string;
30
- /** XXXX-XXXX the user types into the cloud UI to claim this instance; null once claimed. */
46
+ /** XXXX-XXXX that claims this machine in the cloud; null once claimed. */
31
47
  claimCode?: string | null;
32
- /** The cloud page that claims this instance after sign-in. */
48
+ /** The cloud page that claims this machine after sign-in. */
33
49
  claimUrl?: string | null;
50
+ /** Workspaces reported (vetoed ones left out). */
51
+ workspaces?: number;
34
52
  }
53
+ /** What the daemon serves right now. */
54
+ export type WorkspaceSource = () => Array<{
55
+ root: string;
56
+ slug: string;
57
+ name: string;
58
+ }>;
35
59
  export declare const cloudStatus: () => CloudStatus;
36
60
  /**
37
- * Start the client once the inspector listens. Resolves after the first
38
- * registration attempt (success or not); the heartbeat loop runs on its
39
- * own afterwards.
61
+ * Start the machine client (the daemon, once its workspaces are open).
62
+ * Resolves after the first registration attempt; the heartbeat runs on its
63
+ * own afterwards. A second start in the same process is ignored.
64
+ */
65
+ export declare function startMachineCloud(opts: {
66
+ version: string;
67
+ workspaces: WorkspaceSource;
68
+ owner?: unknown;
69
+ }): Promise<CloudStatus>;
70
+ /**
71
+ * Settings changed (`kraftwerk cloud on|off`, a URL): turn the client on or
72
+ * off to match; off says goodbye first. Without a started client (a
73
+ * standalone server) nothing happens.
40
74
  */
41
- export declare function startCloudSync(url: string, version: string): Promise<void>;
42
- /** Stop the heartbeat loop (server close, tests). */
43
- export declare function stopCloudSync(): void;
75
+ export declare function applyMachineCloud(): Promise<CloudStatus>;
76
+ /** A round now (the CLI's `login` waits for a claim): registers when needed, else heartbeats. */
77
+ export declare function syncMachineCloud(): Promise<CloudStatus>;
78
+ /** Release the claim (`kraftwerk logout`): the cloud hands out a fresh claim code. */
79
+ export declare function unlinkMachineCloud(): Promise<CloudStatus>;
44
80
  /**
45
- * Tell the cloud we are going away, so the record flips to "stopped" now
46
- * instead of after the grace period. Bounded: an exit handler waits for
47
- * this, and a slow cloud must not hold the process.
81
+ * Stop the client and tell the cloud the machine is going away (bounded:
82
+ * an exit handler waits for this). `owner`: only the client that owner
83
+ * started (a hub stopping in a test leaves another's alone).
48
84
  */
49
- export declare function cloudGoodbye(): Promise<void>;
50
- /** Every served workspace: stop its heartbeat (the server closes). */
51
- export declare function stopAllCloudSync(): void;
52
- /** Every served workspace says goodbye, in parallel (still bounded per workspace). */
53
- export declare function cloudGoodbyeAll(): Promise<void>;
85
+ export declare function stopMachineCloud(owner?: unknown): Promise<void>;
@@ -1,232 +1,251 @@
1
- import { createHash } from "node:crypto";
2
1
  import { promises as fs } from "node:fs";
3
2
  import os from "node:os";
4
- import path from "node:path";
5
- import { cloudFor, resolveWorkspace } from "../config.js";
6
- import { workspaceRoot } from "../core/context.js";
7
- import { perWorkspace, workspaceOpen } from "../core/workspace.js";
8
- import { listAgents, toSummary } from "../core/agents.js";
9
- import { listChannels, toChannelSummary } from "../core/channels.js";
10
- import { tildify } from "../core/instances.js";
11
- const claimUrlFor = (url, code) => (code ? `${url}/claim/${encodeURIComponent(code)}` : null);
12
- const REQUEST_TIMEOUT_MS = 8_000;
13
- const GOODBYE_TIMEOUT_MS = 800;
14
- /** The client's state, one per workspace: each served workspace registers and heartbeats on its own. */
15
- const client = perWorkspace(() => ({
16
- status: { url: "", state: "off" },
17
- identity: null,
3
+ import { resolveWorkspace } from "../config.js";
4
+ import { claimUrlFor, machineCloud, machineGoodbye, machineHeartbeat, machineRegister, machineUnlink, retiredCloudDir, unknownMachine, } from "../core/cloud.js";
5
+ import { identityString, readIdentity } from "../core/identity.js";
6
+ import { machineCredentials } from "./relay.js";
7
+ const DEFAULT_INTERVAL = 60;
8
+ const STANDALONE = { url: "", state: "off", reason: "only the daemon registers this machine with the cloud (`kraftwerk daemon start`)" };
9
+ const m = {
10
+ status: { ...STANDALONE },
11
+ creds: null,
12
+ registered: false,
18
13
  timer: null,
19
- stopped: false,
20
- lastRoster: "",
21
- /** Where this workspace is reached on this machine, as the cloud lists it. */
22
- selfUrl: "",
23
- selfVersion: "",
24
- startedAt: new Date().toISOString(),
25
- }));
26
- export const cloudStatus = () => client().status;
27
- const cloudDir = () => path.join(os.homedir(), ".kraftwerk", "cloud");
28
- const identityFile = (url, root) => path.join(cloudDir(), `${createHash("sha1").update(`${url}\n${root}`).digest("hex").slice(0, 16)}.json`);
29
- async function readIdentity(file) {
30
- try {
31
- const rec = JSON.parse(await fs.readFile(file, "utf8"));
32
- return typeof rec.id === "string" && typeof rec.secret === "string" ? { id: rec.id, secret: rec.secret } : null;
33
- }
34
- catch {
35
- return null;
36
- }
14
+ started: false,
15
+ owner: null,
16
+ source: (() => []),
17
+ version: "",
18
+ /** The workspace list the cloud last acknowledged, as JSON: sent again only when it differs. */
19
+ reported: "",
20
+ /** One round at a time: a timer tick, a sync from the CLI and a workspace change never overlap. */
21
+ lock: Promise.resolve(),
22
+ warned: new Set(),
23
+ };
24
+ function serial(fn) {
25
+ const run = m.lock.then(fn, fn);
26
+ m.lock = run.catch(() => { });
27
+ return run;
37
28
  }
38
- async function writeIdentity(file, url, root, id) {
39
- try {
40
- await fs.mkdir(cloudDir(), { recursive: true });
41
- await fs.writeFile(file, JSON.stringify({ ...id, url, root, updatedAt: new Date().toISOString() }), { mode: 0o600 });
29
+ export const cloudStatus = () => m.status;
30
+ /** The open workspaces as the cloud lists them: vetoed ones left out, roots only once claimed. */
31
+ async function entries(withRoots) {
32
+ const list = [];
33
+ const ymlUrls = [];
34
+ for (const o of m.source()) {
35
+ const ws = await resolveWorkspace(o.root).catch(() => null);
36
+ const c = ws?.config.cloud;
37
+ if (c?.enabled === false)
38
+ continue;
39
+ if (c?.url)
40
+ ymlUrls.push(c.url.replace(/\/+$/, ""));
41
+ list.push({ ...(ws?.config.id ? { id: ws.config.id } : {}), name: o.name, slug: o.slug, ...(withRoots ? { root: o.root } : {}) });
42
42
  }
43
- catch { } // best-effort — the next start registers again
44
- }
45
- /** The agents and channels of this workspace, as the cloud lists them. */
46
- async function roster() {
47
- const [agents, channels] = await Promise.all([
48
- listAgents().then((l) => l.map(toSummary)).catch(() => []),
49
- listChannels().then((l) => l.map(toChannelSummary)).catch(() => []),
50
- ]);
51
- return { agents, channels };
43
+ return { list, ymlUrls };
52
44
  }
53
- async function post(url, body, timeout = REQUEST_TIMEOUT_MS) {
54
- const c = client();
55
- const r = await fetch(url, {
56
- method: "POST",
57
- headers: { "content-type": "application/json", "user-agent": `kraftwerk/${c.selfVersion}` },
58
- body: JSON.stringify(body),
59
- signal: AbortSignal.timeout(timeout),
60
- });
61
- const text = await r.text();
62
- let data;
63
- try {
64
- data = text ? JSON.parse(text) : {};
45
+ const claimed = () => typeof m.status.account === "string" && m.status.account.length > 0;
46
+ function warnYml(cloud, ymlUrls) {
47
+ if (!ymlUrls.length)
48
+ return;
49
+ const key = `${cloud.source}:${ymlUrls[0]}`;
50
+ if (m.warned.has(key))
51
+ return;
52
+ m.warned.add(key);
53
+ if (cloud.source === "kraftwerk.yml") {
54
+ console.warn(`⚠ kraftwerk.yml cloud.url is deprecated — the cloud is the machine's now: \`kraftwerk cloud on --url ${cloud.url}\` (using ${cloud.url} for now)`);
65
55
  }
66
- catch {
67
- throw new Error(`${r.status} from ${url}: not JSON`);
56
+ else {
57
+ console.warn(`⚠ kraftwerk.yml cloud.url (${ymlUrls[0]}) is ignored: the machine's cloud is ${cloud.url} (${cloud.source}); remove it from kraftwerk.yml`);
68
58
  }
69
- if (!r.ok)
70
- throw new Error(data.error ?? `${r.status} from ${url}`);
71
- return data;
72
59
  }
73
- /** Register (or re-register) this instance. Returns false when the cloud refused or is unreachable. */
74
60
  async function register() {
75
- const c = client();
76
- const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
77
- const cloud = project ? cloudFor(project) : undefined;
78
- if (!project || !cloud)
61
+ const { list, ymlUrls } = await entries(false);
62
+ const cloud = await machineCloud(ymlUrls);
63
+ warnYml(cloud, ymlUrls);
64
+ if (!cloud.enabled) {
65
+ m.status = { url: cloud.url, state: "off", source: cloud.source, ...(cloud.reason ? { reason: cloud.reason } : {}) };
79
66
  return false;
80
- const file = identityFile(cloud.url, project.root);
81
- c.identity ??= await readIdentity(file);
82
- const { agents, channels } = await roster();
83
- c.lastRoster = JSON.stringify({ agents, channels });
84
- const body = {
85
- ...(c.identity ?? {}),
86
- name: project.config.name ?? path.basename(project.root),
87
- icon: project.config.icon ?? "",
88
- color: project.config.color ?? "",
89
- root: project.root,
90
- rootLabel: tildify(project.root),
91
- hostname: os.hostname(),
92
- platform: `${process.platform}-${process.arch}`,
93
- version: c.selfVersion,
94
- url: c.selfUrl,
95
- // Part of the cloud's register contract; always "" since the Cloudflare Tunnel was removed (0.65).
96
- publicUrl: "",
97
- agents,
98
- channels,
99
- interval: cloud.interval,
100
- startedAt: c.startedAt,
101
- ...(cloud.token ? { accountToken: cloud.token } : {}),
102
- };
67
+ }
68
+ const identity = await readIdentity();
103
69
  try {
104
- const r = await post(`${cloud.url}/api/instances/register`, body);
105
- if (!c.identity || c.identity.id !== r.id || c.identity.secret !== r.secret) {
106
- c.identity = { id: r.id, secret: r.secret };
107
- await writeIdentity(file, cloud.url, project.root, c.identity);
108
- }
109
- c.status = {
110
- url: cloud.url, state: "connected", id: r.id, account: r.account, interval: r.interval, lastSeen: new Date().toISOString(),
111
- claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(cloud.url, r.claimCode),
70
+ m.creds ??= await machineCredentials();
71
+ }
72
+ catch (err) {
73
+ m.status = { url: cloud.url, source: cloud.source, state: "error", error: err.message };
74
+ return false;
75
+ }
76
+ const base = { url: cloud.url, source: cloud.source, id: m.creds.id, machine: identityString(identity) };
77
+ if (m.status.state === "off" || m.status.url !== cloud.url)
78
+ m.status = { ...base, state: "connecting" };
79
+ try {
80
+ const r = await machineRegister(cloud.url, m.creds, {
81
+ name: identity.machine,
82
+ handle: identity.handle,
83
+ hostname: os.hostname(),
84
+ platform: `${process.platform}-${process.arch}`,
85
+ version: m.version,
86
+ }, list);
87
+ m.registered = true;
88
+ // An unclaimed register carries no roots: the list counts as reported only when that is all the cloud should know.
89
+ m.reported = r.account ? "" : JSON.stringify(list);
90
+ m.status = {
91
+ ...base, state: "connected", account: r.account ?? null, interval: r.interval || DEFAULT_INTERVAL, lastSeen: new Date().toISOString(),
92
+ claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(cloud.url, r.claimCode), workspaces: list.length,
112
93
  };
94
+ // Already claimed: the roots follow at once.
95
+ if (claimed())
96
+ await heartbeat();
113
97
  return true;
114
98
  }
115
99
  catch (err) {
116
- c.status = { url: cloud.url, state: "error", error: err.message, ...(c.identity ? { id: c.identity.id } : {}) };
100
+ m.registered = false;
101
+ m.status = { ...m.status, ...base, state: "error", error: err.message };
117
102
  return false;
118
103
  }
119
104
  }
120
105
  async function heartbeat() {
121
- const c = client();
122
- if (!c.identity || c.status.state === "off")
106
+ if (!m.creds || !m.registered)
123
107
  return;
124
- const { agents, channels } = await roster();
125
- const rosterNow = JSON.stringify({ agents, channels });
126
- const body = {
127
- ...c.identity,
128
- version: c.selfVersion,
129
- ...(rosterNow !== c.lastRoster ? { agents, channels } : {}),
130
- };
108
+ const wasClaimed = claimed();
109
+ const { list } = await entries(wasClaimed);
110
+ const now = JSON.stringify(list);
131
111
  try {
132
- const r = await post(`${c.status.url}/api/instances/heartbeat`, body);
133
- c.lastRoster = rosterNow;
134
- c.status = {
135
- ...c.status, state: "connected", error: undefined, interval: r.interval, lastSeen: new Date().toISOString(),
112
+ const r = await machineHeartbeat(m.status.url, m.creds, { version: m.version, ...(now !== m.reported ? { workspaces: list } : {}) });
113
+ m.reported = now;
114
+ m.status = {
115
+ ...m.status, state: "connected", error: undefined, interval: r.interval || m.status.interval || DEFAULT_INTERVAL, lastSeen: new Date().toISOString(),
136
116
  ...(r.account !== undefined ? { account: r.account } : {}),
137
- ...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(c.status.url, r.claimCode) } : {}),
117
+ ...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(m.status.url, r.claimCode) } : {}),
118
+ workspaces: list.length,
138
119
  };
120
+ // Just claimed: the roots go out now, not a whole interval later.
121
+ if (!wasClaimed && claimed())
122
+ await heartbeat();
139
123
  }
140
124
  catch (err) {
141
- const message = err.message;
142
- c.status = { ...c.status, state: "error", error: message };
143
- // An unknown identity (the cloud lost it) is fixed by registering again, which hands out a new one.
144
- if (/unknown instance|wrong instance secret|id and secret required/.test(message)) {
145
- c.identity = null;
146
- // Not after a goodbye: a stopped client registers nothing again.
147
- if (!c.stopped && workspaceOpen())
125
+ m.status = { ...m.status, state: "error", error: err.message };
126
+ if (unknownMachine(err)) {
127
+ m.registered = false;
128
+ if (m.started)
148
129
  await register();
149
130
  }
150
131
  }
151
132
  }
152
- /** Schedule the next tick after the effective interval; a failed cloud is retried at the same cadence. */
133
+ /** One round: register when not registered, else heartbeat. */
134
+ const round = () => serial(async () => {
135
+ if (!m.started)
136
+ return;
137
+ if (m.registered)
138
+ await heartbeat();
139
+ else
140
+ await register();
141
+ });
153
142
  function schedule() {
154
- const c = client();
155
- if (c.stopped)
143
+ if (m.timer)
144
+ clearTimeout(m.timer);
145
+ m.timer = null;
146
+ if (!m.started || m.status.state === "off")
156
147
  return;
157
- const seconds = c.status.interval && c.status.interval > 0 ? c.status.interval : 60;
158
- c.timer = setTimeout(async () => {
159
- // Stopped (or the workspace closed) while this timer waited, or while the call below ran: no next round.
160
- if (c.stopped || !workspaceOpen())
161
- return;
162
- if (c.status.state === "connected" || c.status.state === "error") {
163
- if (c.identity)
164
- await heartbeat();
165
- else
166
- await register();
167
- }
168
- if (!c.stopped && workspaceOpen())
169
- schedule();
148
+ const seconds = m.status.interval && m.status.interval > 0 ? m.status.interval : DEFAULT_INTERVAL;
149
+ m.timer = setTimeout(async () => {
150
+ m.timer = null;
151
+ await round();
152
+ schedule();
170
153
  }, seconds * 1000);
171
- c.timer.unref();
154
+ m.timer.unref();
172
155
  }
173
156
  /**
174
- * Start the client once the inspector listens. Resolves after the first
175
- * registration attempt (success or not); the heartbeat loop runs on its
176
- * own afterwards.
157
+ * Start the machine client (the daemon, once its workspaces are open).
158
+ * Resolves after the first registration attempt; the heartbeat runs on its
159
+ * own afterwards. A second start in the same process is ignored.
177
160
  */
178
- export async function startCloudSync(url, version) {
179
- const c = client();
180
- c.selfUrl = url;
181
- c.selfVersion = version;
182
- c.stopped = false;
183
- const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
184
- const cloud = project ? cloudFor(project) : undefined;
185
- if (!cloud) {
186
- c.status = { url: "", state: "off" };
187
- return;
188
- }
189
- // The workspace may close (or the client stop) while this waits: then it does not register or schedule at all.
190
- if (c.stopped || !workspaceOpen())
191
- return;
192
- c.status = { url: cloud.url, state: "connecting", interval: cloud.interval };
193
- await register();
194
- if (c.stopped || !workspaceOpen())
195
- return;
196
- // Until the cloud answers, retry at the configured cadence; once it does, at what it asked for.
197
- if (c.status.state !== "connected")
198
- c.status.interval = cloud.interval;
161
+ export async function startMachineCloud(opts) {
162
+ if (m.started)
163
+ return m.status;
164
+ m.started = true;
165
+ m.owner = opts.owner ?? null;
166
+ m.source = opts.workspaces;
167
+ m.version = opts.version;
168
+ m.status = { url: "", state: "off" };
169
+ // The per-workspace identities of before (one record per workspace) are retired with the machine record.
170
+ await fs.rm(retiredCloudDir(), { recursive: true, force: true }).catch(() => { });
171
+ // The machine's id exists from the daemon's first start on, with the relay still off.
172
+ m.creds = await machineCredentials().catch(() => null);
173
+ await serial(register);
199
174
  schedule();
200
- }
201
- /** Stop the heartbeat loop (server close, tests). */
202
- export function stopCloudSync() {
203
- const c = client();
204
- c.stopped = true;
205
- if (c.timer)
206
- clearTimeout(c.timer);
207
- c.timer = null;
175
+ return m.status;
208
176
  }
209
177
  /**
210
- * Tell the cloud we are going away, so the record flips to "stopped" now
211
- * instead of after the grace period. Bounded: an exit handler waits for
212
- * this, and a slow cloud must not hold the process.
178
+ * Settings changed (`kraftwerk cloud on|off`, a URL): turn the client on or
179
+ * off to match; off says goodbye first. Without a started client (a
180
+ * standalone server) nothing happens.
213
181
  */
214
- export async function cloudGoodbye() {
215
- const c = client();
216
- stopCloudSync();
217
- if (!c.identity || c.status.state === "off" || !c.status.url)
218
- return;
219
- try {
220
- await post(`${c.status.url}/api/instances/goodbye`, c.identity, GOODBYE_TIMEOUT_MS);
182
+ export async function applyMachineCloud() {
183
+ if (!m.started)
184
+ return m.status;
185
+ const cloud = await machineCloud((await entries(false)).ymlUrls);
186
+ if (!cloud.enabled || (m.registered && cloud.url !== m.status.url)) {
187
+ await serial(async () => {
188
+ if (m.registered && m.creds)
189
+ await machineGoodbye(m.status.url, m.creds, m.version).catch(() => { });
190
+ m.registered = false;
191
+ m.reported = "";
192
+ });
221
193
  }
222
- catch { }
194
+ if (!cloud.enabled) {
195
+ if (m.timer)
196
+ clearTimeout(m.timer);
197
+ m.timer = null;
198
+ m.status = { url: cloud.url, state: "off", source: cloud.source, ...(cloud.reason ? { reason: cloud.reason } : {}) };
199
+ return m.status;
200
+ }
201
+ if (!m.registered)
202
+ await serial(register);
203
+ schedule();
204
+ return m.status;
205
+ }
206
+ /** A round now (the CLI's `login` waits for a claim): registers when needed, else heartbeats. */
207
+ export async function syncMachineCloud() {
208
+ if (!m.started)
209
+ return m.status;
210
+ if (m.status.state === "off")
211
+ return applyMachineCloud();
212
+ await round();
213
+ schedule();
214
+ return m.status;
223
215
  }
224
- /** Every served workspace: stop its heartbeat (the server closes). */
225
- export function stopAllCloudSync() {
226
- for (const [ws] of client.entries())
227
- ws.run(stopCloudSync);
216
+ /** Release the claim (`kraftwerk logout`): the cloud hands out a fresh claim code. */
217
+ export async function unlinkMachineCloud() {
218
+ if (!m.started || m.status.state === "off")
219
+ return m.status;
220
+ return serial(async () => {
221
+ if (!m.registered)
222
+ await register();
223
+ if (!m.creds || !m.registered)
224
+ throw new Error(m.status.error ?? "not registered with the cloud");
225
+ const r = await machineUnlink(m.status.url, m.creds, m.version);
226
+ // The next heartbeat sends the list again — without roots now.
227
+ m.reported = "";
228
+ m.status = { ...m.status, account: null, claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(m.status.url, r.claimCode) };
229
+ return m.status;
230
+ });
228
231
  }
229
- /** Every served workspace says goodbye, in parallel (still bounded per workspace). */
230
- export async function cloudGoodbyeAll() {
231
- await Promise.all(client.entries().map(([ws]) => ws.run(cloudGoodbye)));
232
+ /**
233
+ * Stop the client and tell the cloud the machine is going away (bounded:
234
+ * an exit handler waits for this). `owner`: only the client that owner
235
+ * started (a hub stopping in a test leaves another's alone).
236
+ */
237
+ export async function stopMachineCloud(owner) {
238
+ if (!m.started || (owner !== undefined && owner !== m.owner))
239
+ return;
240
+ m.started = false;
241
+ if (m.timer)
242
+ clearTimeout(m.timer);
243
+ m.timer = null;
244
+ const { creds, registered } = m;
245
+ const url = m.status.url;
246
+ m.registered = false;
247
+ m.reported = "";
248
+ m.status = { ...STANDALONE };
249
+ if (registered && creds && url)
250
+ await machineGoodbye(url, creds, m.version).catch(() => { });
232
251
  }
@@ -1,7 +1,7 @@
1
1
  import type { Hub } from "./server.js";
2
2
  export declare const runInHub: <T>(hub: Hub, fn: () => T) => T;
3
3
  export declare function currentHub(): Hub;
4
- /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
4
+ /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the cloud record, the protocol, the socket). */
5
5
  export declare const MACHINE_PATHS: RegExp;
6
6
  /** Route names callable over the socket without a workspace. */
7
7
  export declare const MACHINE_ROUTES: RegExp;
@@ -12,7 +12,7 @@ export function currentHub() {
12
12
  throw new Error("no hub here");
13
13
  return hub;
14
14
  }
15
- /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
16
- export const MACHINE_PATHS = /^\/api\/(hub(\/|$)|pair$|devices(\/|$)|protocol$|ws$)/;
15
+ /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the cloud record, the protocol, the socket). */
16
+ export const MACHINE_PATHS = /^\/api\/(hub(\/|$)|pair$|devices(\/|$)|cloud(\/|$)|protocol$|ws$)/;
17
17
  /** Route names callable over the socket without a workspace. */
18
- export const MACHINE_ROUTES = /^(hub|devices|protocol)\./;
18
+ export const MACHINE_ROUTES = /^(hub|devices|cloud|protocol)\./;
@@ -44,6 +44,19 @@ export interface RelayStatus {
44
44
  export declare function readRelaySettings(): Promise<RelaySettings | null>;
45
45
  /** Turn the relay on or off (creating the machine's key on first use); `url` switches relays. */
46
46
  export declare function setRelay(enabled: boolean, url?: string): Promise<RelaySettings>;
47
+ /**
48
+ * The machine's key pair and secret, created (with the relay staying off)
49
+ * when relay.json is missing: the daemon's first start, `kraftwerk login`.
50
+ * The cloud knows the machine by the same id and secret the relay does; a
51
+ * damaged file is refused, never overwritten (it holds the id).
52
+ */
53
+ export declare function ensureRelayKeys(): Promise<RelaySettings>;
54
+ /** The machine's id (its relay id), public key and secret: what the cloud knows it by. */
55
+ export declare function machineCredentials(): Promise<{
56
+ id: string;
57
+ key: string;
58
+ secret: string;
59
+ }>;
47
60
  /** wss://host/api/relay/<role> for a cloud URL. */
48
61
  export declare const relayEndpoint: (cloudUrl: string, role: "server" | "client") => string;
49
62
  /**