@netnodeag/kraftwerk 0.71.1 → 0.73.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 +13 -0
- package/dist/cli/daemon-control.d.ts +15 -0
- package/dist/cli/daemon-control.js +228 -0
- package/dist/cli/daemon-login.d.ts +2 -0
- package/dist/cli/daemon-login.js +9 -2
- package/dist/cli/daemon.js +52 -3
- package/dist/cli/doctor.js +3 -1
- package/dist/cli/ui.js +33 -11
- package/dist/client/routes.d.ts +2 -0
- package/dist/client/routes.js +2 -0
- package/dist/core/daemon.d.ts +16 -0
- package/dist/core/daemon.js +18 -1
- package/dist/core/devices.d.ts +21 -0
- package/dist/core/devices.js +26 -1
- package/dist/core/skills.js +4 -2
- package/dist/server/api/devices.d.ts +13 -2
- package/dist/server/api/devices.js +26 -1
- package/dist/server/api/index.d.ts +13 -2
- package/dist/server/live.d.ts +4 -2
- package/dist/server/live.js +3 -2
- package/dist/server/push.d.ts +52 -0
- package/dist/server/push.js +67 -0
- package/dist/server/relay.d.ts +5 -0
- package/dist/server/relay.js +15 -0
- package/dist/server/server.js +2 -1
- package/package.json +1 -1
- package/web/dist/assets/{dist-BcEOC-ps.js → dist-BLd_aA1g.js} +1 -1
- package/web/dist/assets/{dist-5bpaJOp6.js → dist-Bu0rxkzF.js} +1 -1
- package/web/dist/assets/{dist-BFetH-PC.js → dist-C8xoYPYE.js} +1 -1
- package/web/dist/assets/{dist-4mt46bwa.js → dist-CVuec2Mm.js} +1 -1
- package/web/dist/assets/{dist-Coej_0FF.js → dist-CYnXUgv0.js} +1 -1
- package/web/dist/assets/dist-D-uSPljm.js +1 -0
- package/web/dist/assets/{dist-CANEcq7F.js → dist-DFZgINkL.js} +1 -1
- package/web/dist/assets/{dist-BmsKU6ix.js → dist-GJo81x50.js} +1 -1
- package/web/dist/assets/{dist-Du3myOTO.js → dist-H3OFzCSU.js} +1 -1
- package/web/dist/assets/{dist-8As2tNSv.js → dist-LUiVqSl6.js} +1 -1
- package/web/dist/assets/{dist-FeMkWwZl.js → dist-WCCGMh5F.js} +1 -1
- package/web/dist/assets/{editor-B3am-j9U.js → editor-6D-WNjZY.js} +3 -3
- package/web/dist/assets/{index-CrBIiOwc.js → index-D8-2MY0Q.js} +3 -3
- package/web/dist/index.html +1 -1
- package/web/dist/assets/dist-CWrRm3RK.js +0 -1
package/README.md
CHANGED
|
@@ -70,8 +70,13 @@ login:
|
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
kraftwerk daemon # the machine's kraftwerk, on http://localhost:1980
|
|
73
|
+
kraftwerk daemon start # or in the background (`kraftwerk daemon -d`)
|
|
73
74
|
kraftwerk daemon install # or: start it at login from now on (macOS; `uninstall` undoes it)
|
|
74
75
|
|
|
76
|
+
kraftwerk daemon status # does it run? pid, uptime, version, workspaces, remote access
|
|
77
|
+
kraftwerk daemon logs -f # what it says (a daemon in the background writes ~/.kraftwerk/logs/daemon.log)
|
|
78
|
+
kraftwerk daemon restart # relaunch it with the current install; `daemon stop` stops it
|
|
79
|
+
|
|
75
80
|
cd ~/work/team-blau && kraftwerk ui # opens this workspace in the daemon:
|
|
76
81
|
# http://team-blau.localhost:1980
|
|
77
82
|
cd ~/work/relaunch && kraftwerk ui # http://relaunch.localhost:1980
|
|
@@ -246,6 +251,7 @@ kraftwerk knowledge # Knowledge: OKF bundles (list/get/put/v
|
|
|
246
251
|
kraftwerk ui # start this workspace: http://localhost:1981 (or open it in the daemon); --port, --lan, --standalone
|
|
247
252
|
kraftwerk daemon # one kraftwerk for every open workspace: http://<slug>.localhost:1980; --port, --lan
|
|
248
253
|
kraftwerk daemon install # start the daemon at login (macOS LaunchAgent); --lan, --port; `daemon uninstall` removes it
|
|
254
|
+
kraftwerk daemon status # whether it runs (exit 3 when not; --json); daemon start|stop|restart; daemon logs [-n N] [-f]
|
|
249
255
|
kraftwerk workspaces # every workspace on this machine; workspaces start|stop|forget <ref>
|
|
250
256
|
kraftwerk devices # paired devices (a phone, another computer); devices pair|revoke
|
|
251
257
|
kraftwerk remote on|off|pair # reach this machine from anywhere through the relay, end to end encrypted
|
|
@@ -452,6 +458,13 @@ Everything between the phone and the machine is end to end encrypted:
|
|
|
452
458
|
- One trust remains: the remote page's code comes from the cloud that serves
|
|
453
459
|
it. A native app will remove that.
|
|
454
460
|
|
|
461
|
+
**Push notifications.** The kraftwerk iOS app gets a notification when
|
|
462
|
+
something new needs you: an approval, a question from an agent, a failed run
|
|
463
|
+
or routine. Each paired phone gives the machine a key of its own, and the
|
|
464
|
+
machine seals every notification with it. The relay and Apple pass along only
|
|
465
|
+
"Something needs you" and the badge count. The app opens the sealed text on
|
|
466
|
+
the phone. Turning notifications off on the phone, or unpairing it, stops them.
|
|
467
|
+
|
|
455
468
|
The machine's key and its relay secret live in `~/.kraftwerk/relay.json`
|
|
456
469
|
(owner only). The daemon must run (`kraftwerk daemon install`), and the Mac
|
|
457
470
|
must be awake.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function daemonStatus(opts: {
|
|
2
|
+
json?: boolean;
|
|
3
|
+
}): Promise<void>;
|
|
4
|
+
export declare function startDaemon(opts: {
|
|
5
|
+
port?: string;
|
|
6
|
+
}): Promise<void>;
|
|
7
|
+
export declare function stopDaemon(): Promise<void>;
|
|
8
|
+
/** Restart: the running daemon relaunches itself with the current install (its supervisor stays); none running — start one. */
|
|
9
|
+
export declare function restartDaemon(opts: {
|
|
10
|
+
port?: string;
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
export declare function daemonLogs(opts: {
|
|
13
|
+
lines?: string;
|
|
14
|
+
follow?: boolean;
|
|
15
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, openSync, promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { daemonLogPath, findDaemon, readDaemonFile } from "../core/daemon.js";
|
|
6
|
+
import { tildify } from "../core/instances.js";
|
|
7
|
+
import { selfCommand } from "../core/self-command.js";
|
|
8
|
+
import { getPkgVersion } from "../server/version.js";
|
|
9
|
+
import { daemonLoginItem, startLoginItem } from "./daemon-login.js";
|
|
10
|
+
/**
|
|
11
|
+
* `kraftwerk daemon status | start | stop | restart | logs`: the daemon from
|
|
12
|
+
* the outside, without a terminal of its own. `start` (and `kraftwerk daemon
|
|
13
|
+
* -d`) runs it in the background — through the login item when one is
|
|
14
|
+
* installed, else as a detached process — writing to the daemon log, which
|
|
15
|
+
* `logs` shows (`-f` follows). `status` asks the daemon itself, never the
|
|
16
|
+
* file alone, and exits 3 when none runs (as `systemctl status` does), so a
|
|
17
|
+
* script can tell.
|
|
18
|
+
*/
|
|
19
|
+
const NOT_RUNNING = 3;
|
|
20
|
+
const alive = (pid) => {
|
|
21
|
+
if (!pid)
|
|
22
|
+
return false;
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const ago = (iso) => {
|
|
32
|
+
const ms = iso ? Date.now() - Date.parse(iso) : NaN;
|
|
33
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
34
|
+
return "";
|
|
35
|
+
const m = Math.floor(ms / 60_000);
|
|
36
|
+
if (m < 1)
|
|
37
|
+
return "just now";
|
|
38
|
+
if (m < 60)
|
|
39
|
+
return `${m}m`;
|
|
40
|
+
const h = Math.floor(m / 60);
|
|
41
|
+
return h < 24 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d ${h % 24}h`;
|
|
42
|
+
};
|
|
43
|
+
async function getJson(port, p) {
|
|
44
|
+
try {
|
|
45
|
+
const r = await fetch(`http://127.0.0.1:${port}${p}`, { signal: AbortSignal.timeout(2000) });
|
|
46
|
+
return r.ok ? (await r.json()) : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** The last lines of the daemon log ([] without one). */
|
|
53
|
+
async function tail(n) {
|
|
54
|
+
try {
|
|
55
|
+
const lines = (await fs.readFile(daemonLogPath(), "utf8")).split("\n");
|
|
56
|
+
if (lines.at(-1) === "")
|
|
57
|
+
lines.pop();
|
|
58
|
+
return lines.slice(-n);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function status() {
|
|
65
|
+
const [running, rec, installed] = await Promise.all([findDaemon(), readDaemonFile(), getPkgVersion()]);
|
|
66
|
+
const base = { installed, loginItem: daemonLoginItem() !== null, log: daemonLogPath() };
|
|
67
|
+
if (!running)
|
|
68
|
+
return { running: false, ...base };
|
|
69
|
+
const [hub, relay] = await Promise.all([
|
|
70
|
+
getJson(running.port, "/api/hub"),
|
|
71
|
+
getJson(running.port, "/api/devices/relay"),
|
|
72
|
+
]);
|
|
73
|
+
return {
|
|
74
|
+
running: true,
|
|
75
|
+
...base,
|
|
76
|
+
pid: running.pid,
|
|
77
|
+
port: running.port,
|
|
78
|
+
...(rec?.version ? { version: rec.version } : {}),
|
|
79
|
+
...(rec?.startedAt ? { startedAt: rec.startedAt } : {}),
|
|
80
|
+
workspaces: (hub?.workspaces ?? []).map(({ slug, name, url }) => ({ slug, name, url })),
|
|
81
|
+
...(relay?.enabled ? { remote: { state: relay.state ?? "unknown", ...(relay.url ? { url: relay.url } : {}), clients: relay.clients ?? 0 } } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export async function daemonStatus(opts) {
|
|
85
|
+
const s = await status();
|
|
86
|
+
if (opts.json) {
|
|
87
|
+
console.log(JSON.stringify(s, null, 2));
|
|
88
|
+
process.exit(s.running ? 0 : NOT_RUNNING);
|
|
89
|
+
}
|
|
90
|
+
if (s.running) {
|
|
91
|
+
const up = ago(s.startedAt);
|
|
92
|
+
const behind = s.version && s.version !== s.installed ? chalk.yellow(` — ${s.installed} is installed: \`kraftwerk daemon restart\``) : "";
|
|
93
|
+
console.log(`${chalk.green("●")} kraftwerk daemon ${chalk.green("running")} ${chalk.dim(`pid ${s.pid}${up ? `, up ${up}` : ""}${s.version ? `, ${s.version}` : ""}`)}${behind}`);
|
|
94
|
+
console.log(` ${chalk.cyan(`http://localhost:${s.port}`)} · ${s.workspaces?.length ?? 0} workspace${s.workspaces?.length === 1 ? "" : "s"} open`);
|
|
95
|
+
for (const w of s.workspaces ?? [])
|
|
96
|
+
console.log(` ${w.name} ${chalk.dim(w.url)}`);
|
|
97
|
+
if (s.remote) {
|
|
98
|
+
const ok = s.remote.state === "connected";
|
|
99
|
+
const devices = ok ? chalk.dim(` · ${s.remote.clients} device connection${s.remote.clients === 1 ? "" : "s"} now`) : "";
|
|
100
|
+
console.log(` remote access: ${ok ? chalk.green("connected") : chalk.yellow(s.remote.state)}${s.remote.url ? chalk.dim(` through ${s.remote.url}`) : ""}${devices}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
console.log(`${chalk.red("○")} kraftwerk daemon ${chalk.red("not running")}`);
|
|
105
|
+
const last = await tail(400);
|
|
106
|
+
const crash = [...last].reverse().find((l) => /crashed:|stopped unexpectedly/.test(l));
|
|
107
|
+
if (crash)
|
|
108
|
+
console.log(chalk.dim(` last trouble in the log: ${crash.trim().slice(0, 200)}`));
|
|
109
|
+
console.log(chalk.dim(" `kraftwerk daemon start` starts it in the background"));
|
|
110
|
+
}
|
|
111
|
+
console.log(chalk.dim(` login item: ${s.loginItem ? "starts at login" : "not installed (`kraftwerk daemon install` starts it at login)"}`));
|
|
112
|
+
console.log(chalk.dim(` log: ${tildify(s.log)} · \`kraftwerk daemon logs -f\` follows it`));
|
|
113
|
+
if (!s.running)
|
|
114
|
+
process.exit(NOT_RUNNING);
|
|
115
|
+
}
|
|
116
|
+
/** Wait until `ok` holds (polling), up to `ms`; whether it did. */
|
|
117
|
+
async function until(ok, ms) {
|
|
118
|
+
const end = Date.now() + ms;
|
|
119
|
+
while (Date.now() < end) {
|
|
120
|
+
if (await ok())
|
|
121
|
+
return true;
|
|
122
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
123
|
+
}
|
|
124
|
+
return ok();
|
|
125
|
+
}
|
|
126
|
+
/** Start it in the background: the login item when installed (launchd owns it then), else a detached process writing to the log. */
|
|
127
|
+
async function launch(port) {
|
|
128
|
+
if (daemonLoginItem() && !port && startLoginItem())
|
|
129
|
+
return "login item";
|
|
130
|
+
const log = daemonLogPath();
|
|
131
|
+
mkdirSync(path.dirname(log), { recursive: true });
|
|
132
|
+
const out = openSync(log, "a");
|
|
133
|
+
const { cmd, args } = selfCommand(["daemon", ...(port ? ["--port", port] : [])]);
|
|
134
|
+
const child = spawn(cmd, args, { detached: true, stdio: ["ignore", out, out], cwd: process.env.HOME, env: { ...process.env, FORCE_COLOR: "0" } });
|
|
135
|
+
child.unref();
|
|
136
|
+
return "background";
|
|
137
|
+
}
|
|
138
|
+
export async function startDaemon(opts) {
|
|
139
|
+
const running = await findDaemon();
|
|
140
|
+
if (running) {
|
|
141
|
+
console.log(`${chalk.green("✔")} the daemon already runs ${chalk.dim(`(pid ${running.pid}, http://localhost:${running.port})`)}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const how = await launch(opts.port);
|
|
145
|
+
process.stdout.write(chalk.dim(`starting the daemon (${how}) …`));
|
|
146
|
+
const up = await until(async () => (await findDaemon()) !== null, 45_000);
|
|
147
|
+
process.stdout.write("\n");
|
|
148
|
+
if (!up) {
|
|
149
|
+
console.error(chalk.red("✖ the daemon did not come up — its log says:"));
|
|
150
|
+
for (const l of await tail(15))
|
|
151
|
+
console.error(chalk.dim(` ${l}`));
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
await daemonStatus({});
|
|
155
|
+
}
|
|
156
|
+
/** Stop it: its supervisor first (else it would start the daemon again), then wait until it is gone. */
|
|
157
|
+
async function stopRunning() {
|
|
158
|
+
const [running, rec] = await Promise.all([findDaemon(), readDaemonFile()]);
|
|
159
|
+
if (!running)
|
|
160
|
+
return false;
|
|
161
|
+
const target = rec?.pid === running.pid && alive(rec.supervisor) ? rec.supervisor : running.pid;
|
|
162
|
+
process.kill(target, "SIGTERM");
|
|
163
|
+
const gone = await until(async () => !alive(running.pid) && !alive(target) && (await findDaemon()) === null, 20_000);
|
|
164
|
+
if (!gone) {
|
|
165
|
+
for (const pid of [target, running.pid])
|
|
166
|
+
if (alive(pid))
|
|
167
|
+
process.kill(pid, "SIGKILL");
|
|
168
|
+
await until(async () => (await findDaemon()) === null, 5_000);
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
export async function stopDaemon() {
|
|
173
|
+
if (!(await stopRunning())) {
|
|
174
|
+
console.log(chalk.dim("No daemon running."));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
console.log(`${chalk.green("✔")} daemon stopped`);
|
|
178
|
+
if (daemonLoginItem())
|
|
179
|
+
console.log(chalk.dim(" it starts again at the next login (`kraftwerk daemon uninstall` stops that)"));
|
|
180
|
+
}
|
|
181
|
+
/** Restart: the running daemon relaunches itself with the current install (its supervisor stays); none running — start one. */
|
|
182
|
+
export async function restartDaemon(opts) {
|
|
183
|
+
const running = await findDaemon();
|
|
184
|
+
if (!running)
|
|
185
|
+
return startDaemon(opts);
|
|
186
|
+
// The restart route lives in every workspace (the daemon's root answers machine routes only); any open one will do.
|
|
187
|
+
const hub = await getJson(running.port, "/api/hub");
|
|
188
|
+
const slug = hub?.workspaces?.[0]?.slug;
|
|
189
|
+
const asked = !!slug &&
|
|
190
|
+
(await fetch(`http://127.0.0.1:${running.port}/w/${encodeURIComponent(slug)}/api/restart`, { method: "POST", signal: AbortSignal.timeout(5000) }).then((r) => r.ok, () => false));
|
|
191
|
+
if (!asked) {
|
|
192
|
+
// Not supervised (an old daemon, a foreground one started oddly): stop it and start a new one.
|
|
193
|
+
await stopRunning();
|
|
194
|
+
return startDaemon(opts);
|
|
195
|
+
}
|
|
196
|
+
process.stdout.write(chalk.dim("restarting the daemon …"));
|
|
197
|
+
const back = await until(async () => {
|
|
198
|
+
const d = await findDaemon();
|
|
199
|
+
return d !== null && d.pid !== running.pid;
|
|
200
|
+
}, 45_000);
|
|
201
|
+
process.stdout.write("\n");
|
|
202
|
+
if (!back) {
|
|
203
|
+
console.error(chalk.red("✖ the daemon did not come back — its log says:"));
|
|
204
|
+
for (const l of await tail(15))
|
|
205
|
+
console.error(chalk.dim(` ${l}`));
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
await daemonStatus({});
|
|
209
|
+
}
|
|
210
|
+
export async function daemonLogs(opts) {
|
|
211
|
+
const log = daemonLogPath();
|
|
212
|
+
const n = Math.max(1, Number(opts.lines ?? 50) || 50);
|
|
213
|
+
if (!existsSync(log)) {
|
|
214
|
+
console.log(chalk.dim(`No daemon log yet (${tildify(log)}): a daemon in the background writes it — one in a terminal writes there instead.`));
|
|
215
|
+
if (!opts.follow)
|
|
216
|
+
return;
|
|
217
|
+
mkdirSync(path.dirname(log), { recursive: true });
|
|
218
|
+
await fs.appendFile(log, "");
|
|
219
|
+
}
|
|
220
|
+
if (!opts.follow) {
|
|
221
|
+
for (const l of await tail(n))
|
|
222
|
+
console.log(l);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// tail -F keeps following when the file is replaced; Ctrl-C ends it.
|
|
226
|
+
const child = spawn("tail", ["-n", String(n), "-F", log], { stdio: "inherit" });
|
|
227
|
+
await new Promise((resolve) => child.once("exit", () => resolve()));
|
|
228
|
+
}
|
|
@@ -15,5 +15,7 @@ export declare function installDaemon(opts: {
|
|
|
15
15
|
lan?: boolean;
|
|
16
16
|
}): Promise<void>;
|
|
17
17
|
export declare function uninstallDaemon(): Promise<void>;
|
|
18
|
+
/** Start the login item's daemon now (`kraftwerk daemon start`): load the item, or kick it when loaded; false when launchctl refused. */
|
|
19
|
+
export declare function startLoginItem(): boolean;
|
|
18
20
|
/** For doctor: whether the login item is installed. */
|
|
19
21
|
export declare const daemonLoginItem: () => string | null;
|
package/dist/cli/daemon-login.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, promises as fs } from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import chalk from "chalk";
|
|
6
|
-
import { findDaemon } from "../core/daemon.js";
|
|
6
|
+
import { daemonLogPath, findDaemon } from "../core/daemon.js";
|
|
7
7
|
import { selfCommand } from "../core/self-command.js";
|
|
8
8
|
import { tildify } from "../core/instances.js";
|
|
9
9
|
/**
|
|
@@ -17,7 +17,7 @@ import { tildify } from "../core/instances.js";
|
|
|
17
17
|
*/
|
|
18
18
|
export const LAUNCH_LABEL = "ch.netnode.kraftwerk.daemon";
|
|
19
19
|
const plistPath = () => path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCH_LABEL}.plist`);
|
|
20
|
-
const logPath =
|
|
20
|
+
const logPath = daemonLogPath;
|
|
21
21
|
/** A stand-in launchctl (tests); the real one otherwise. */
|
|
22
22
|
const launchctl = () => process.env.KRAFTWERK_LAUNCHCTL || "launchctl";
|
|
23
23
|
const domain = () => `gui/${os.userInfo().uid}`;
|
|
@@ -102,5 +102,12 @@ export async function uninstallDaemon() {
|
|
|
102
102
|
await fs.rm(file, { force: true });
|
|
103
103
|
console.log(`${chalk.green("✔")} login item removed ${chalk.dim(`(${tildify(file)})`)}`);
|
|
104
104
|
}
|
|
105
|
+
/** Start the login item's daemon now (`kraftwerk daemon start`): load the item, or kick it when loaded; false when launchctl refused. */
|
|
106
|
+
export function startLoginItem() {
|
|
107
|
+
const kick = spawnSync(launchctl(), ["kickstart", `${domain()}/${LAUNCH_LABEL}`], { stdio: "ignore" });
|
|
108
|
+
if (kick.status === 0)
|
|
109
|
+
return true;
|
|
110
|
+
return spawnSync(launchctl(), ["bootstrap", domain(), plistPath()], { stdio: "ignore" }).status === 0;
|
|
111
|
+
}
|
|
105
112
|
/** For doctor: whether the login item is installed. */
|
|
106
113
|
export const daemonLoginItem = () => (existsSync(plistPath()) ? plistPath() : null);
|
package/dist/cli/daemon.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { DAEMON_PORT, findDaemon, lastOpenRoots, writeDaemonFile } from "../core/daemon.js";
|
|
2
|
+
import { DAEMON_PORT, findDaemon, 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 {
|
|
5
|
+
import { startPushWatcher } from "../server/push.js";
|
|
6
|
+
import { applyRelay, relayPush } from "../server/relay.js";
|
|
6
7
|
import { startHub } from "../server/server.js";
|
|
7
8
|
import { getPkgVersion } from "../server/version.js";
|
|
8
9
|
import { ensureBuilt, supervise } from "./ui.js";
|
|
9
10
|
import { installDaemon, uninstallDaemon } from "./daemon-login.js";
|
|
11
|
+
import { daemonLogs, daemonStatus, restartDaemon, startDaemon, stopDaemon } from "./daemon-control.js";
|
|
10
12
|
/**
|
|
11
13
|
* `kraftwerk daemon` — the machine's kraftwerk: one process serving every
|
|
12
14
|
* open workspace, each at `<slug>.localhost:<port>` (and its own port, when
|
|
@@ -26,6 +28,7 @@ export async function runDaemon(opts) {
|
|
|
26
28
|
process.exit(1);
|
|
27
29
|
}
|
|
28
30
|
const port = Number(opts.port ?? process.env.KRAFTWERK_DAEMON_PORT ?? DAEMON_PORT);
|
|
31
|
+
setDaemonVersion(await getPkgVersion());
|
|
29
32
|
const reopen = await lastOpenRoots();
|
|
30
33
|
const hub = await startHub({
|
|
31
34
|
port,
|
|
@@ -42,13 +45,15 @@ export async function runDaemon(opts) {
|
|
|
42
45
|
console.log(`${chalk.yellow("⚠")} not reopened: ${tildify(root)} — ${err.message}`);
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
|
-
console.log(`${chalk.green("✔")} kraftwerk daemon on ${chalk.cyan(`http://localhost:${hub.port}`)} ${chalk.dim(`(pid ${process.pid})`)}`);
|
|
48
|
+
console.log(`${chalk.green("✔")} kraftwerk daemon on ${chalk.cyan(`http://localhost:${hub.port}`)} ${chalk.dim(`(pid ${process.pid}, ${new Date().toISOString()})`)}`);
|
|
46
49
|
for (const o of hub.list())
|
|
47
50
|
console.log(` ${chalk.cyan(o.url)} ${chalk.dim(o.name)}${o.aliasPort ? chalk.dim(` · also :${o.aliasPort}`) : ""}`);
|
|
48
51
|
if (!hub.list().length)
|
|
49
52
|
console.log(chalk.dim(" no workspace open yet — `kraftwerk ui` in a workspace folder opens it here"));
|
|
50
53
|
// The relay (`kraftwerk remote on`): devices reach this machine from anywhere, end to end encrypted.
|
|
51
54
|
const relay = await applyRelay(hub.port, await getPkgVersion(), (line) => console.log(chalk.dim(line)));
|
|
55
|
+
// What needs you, pushed to the phones that asked (through the relay, sealed per device).
|
|
56
|
+
startPushWatcher(hub, relayPush);
|
|
52
57
|
if (relay.state !== "off")
|
|
53
58
|
console.log(`${chalk.green("✔")} Reachable from anywhere through ${chalk.cyan(relay.url ?? "")} ${chalk.dim("(`kraftwerk remote pair` pairs a device)")}`);
|
|
54
59
|
const lan = networkUrls(hub.port);
|
|
@@ -61,9 +66,53 @@ export function registerDaemonCommand(program) {
|
|
|
61
66
|
.description("Run the machine's kraftwerk: one process serving every open workspace at <slug>.localhost; install it as a login item")
|
|
62
67
|
.option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
|
|
63
68
|
.option("--lan", "Listen on the network too, for paired devices")
|
|
69
|
+
.option("-d, --detach", "Run in the background (same as `kraftwerk daemon start`)")
|
|
70
|
+
.addHelpText("after", `
|
|
71
|
+
In the background:
|
|
72
|
+
kraftwerk daemon start start it (the login item when installed), output to the daemon log
|
|
73
|
+
kraftwerk daemon status whether it runs: pid, uptime, version, workspaces, remote access
|
|
74
|
+
kraftwerk daemon logs -f its log, followed
|
|
75
|
+
kraftwerk daemon restart relaunch it with the current install
|
|
76
|
+
kraftwerk daemon stop stop it`)
|
|
64
77
|
.action(async (opts) => {
|
|
78
|
+
if (opts.detach)
|
|
79
|
+
return startDaemon(opts);
|
|
65
80
|
await runDaemon(opts);
|
|
66
81
|
});
|
|
82
|
+
daemon
|
|
83
|
+
.command("status")
|
|
84
|
+
.description("Whether the daemon runs: pid, uptime, version, workspaces, remote access (exit 3 when not)")
|
|
85
|
+
.option("--json", "As JSON")
|
|
86
|
+
.action(async (opts) => {
|
|
87
|
+
await daemonStatus(opts);
|
|
88
|
+
});
|
|
89
|
+
daemon
|
|
90
|
+
.command("start")
|
|
91
|
+
.description("Start the daemon in the background (the login item when installed), writing to the daemon log")
|
|
92
|
+
.option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
|
|
93
|
+
.action(async (opts) => {
|
|
94
|
+
await startDaemon({ port: opts.port ?? daemon.opts().port });
|
|
95
|
+
});
|
|
96
|
+
daemon
|
|
97
|
+
.command("stop")
|
|
98
|
+
.description("Stop the running daemon")
|
|
99
|
+
.action(async () => {
|
|
100
|
+
await stopDaemon();
|
|
101
|
+
});
|
|
102
|
+
daemon
|
|
103
|
+
.command("restart")
|
|
104
|
+
.description("Relaunch the daemon with the current install (starts one when none runs)")
|
|
105
|
+
.action(async () => {
|
|
106
|
+
await restartDaemon({});
|
|
107
|
+
});
|
|
108
|
+
daemon
|
|
109
|
+
.command("logs")
|
|
110
|
+
.description("Show the daemon log (a daemon in the background writes it)")
|
|
111
|
+
.option("-n, --lines <n>", "How many lines", "50")
|
|
112
|
+
.option("-f, --follow", "Keep following it (Ctrl-C ends)")
|
|
113
|
+
.action(async (opts) => {
|
|
114
|
+
await daemonLogs(opts);
|
|
115
|
+
});
|
|
67
116
|
daemon
|
|
68
117
|
.command("install")
|
|
69
118
|
.description("Start the daemon at login (macOS LaunchAgent), and now unless one runs")
|
package/dist/cli/doctor.js
CHANGED
|
@@ -217,7 +217,9 @@ export async function runDoctor(cwd) {
|
|
|
217
217
|
const daemon = await findDaemon();
|
|
218
218
|
const loginItem = daemonLoginItem();
|
|
219
219
|
report("info", daemon ? `kraftwerk daemon running on :${daemon.port}` : "no kraftwerk daemon running", loginItem
|
|
220
|
-
?
|
|
220
|
+
? daemon
|
|
221
|
+
? "starts at login · `kraftwerk daemon status` for more"
|
|
222
|
+
: "starts at login, but not running now · `kraftwerk daemon start`, `kraftwerk daemon logs` says why it stopped"
|
|
221
223
|
: process.platform === "darwin"
|
|
222
224
|
? "`kraftwerk daemon install` starts it at login"
|
|
223
225
|
: daemon
|
package/dist/cli/ui.js
CHANGED
|
@@ -98,8 +98,9 @@ export async function runUi(cwd, opts) {
|
|
|
98
98
|
/**
|
|
99
99
|
* Respawn loop around the real server. Spawns this same bin script via the
|
|
100
100
|
* current node (selfCommand) — works identically for global installs,
|
|
101
|
-
* local node_modules, npx, and a file: dev checkout.
|
|
102
|
-
*
|
|
101
|
+
* local node_modules, npx, and a file: dev checkout. RESTART_EXIT_CODE
|
|
102
|
+
* relaunches; so does a crash once the server was up (with a growing
|
|
103
|
+
* pause); any other exit ends the loop. A SIGTERM/SIGINT to the supervisor is
|
|
103
104
|
* forwarded to the server, so `kill <supervisor pid>` (what `kraftwerk
|
|
104
105
|
* workspaces start` reports) takes the whole UI down; Ctrl-C signals the
|
|
105
106
|
* foreground group and reaches both anyway.
|
|
@@ -110,12 +111,18 @@ async function superviseUi(opts) {
|
|
|
110
111
|
/** The respawn loop for any long-running kraftwerk process (`ui`, `daemon`): see superviseUi. */
|
|
111
112
|
export async function supervise(argv, what) {
|
|
112
113
|
const { cmd, args } = selfCommand(argv);
|
|
114
|
+
let backoff = 1000;
|
|
113
115
|
for (;;) {
|
|
116
|
+
const started = Date.now();
|
|
114
117
|
const child = spawn(cmd, args, {
|
|
115
118
|
stdio: "inherit",
|
|
116
|
-
env: { ...process.env, KRAFTWERK_UI_SUPERVISED: "1" },
|
|
119
|
+
env: { ...process.env, KRAFTWERK_UI_SUPERVISED: "1", KRAFTWERK_SUPERVISOR_PID: String(process.pid) },
|
|
117
120
|
});
|
|
118
|
-
|
|
121
|
+
let stopping = false;
|
|
122
|
+
const forward = (sig) => () => {
|
|
123
|
+
stopping = true;
|
|
124
|
+
child.kill(sig);
|
|
125
|
+
};
|
|
119
126
|
const handlers = { SIGTERM: forward("SIGTERM"), SIGINT: forward("SIGINT") };
|
|
120
127
|
process.on("SIGTERM", handlers.SIGTERM);
|
|
121
128
|
process.on("SIGINT", handlers.SIGINT);
|
|
@@ -124,13 +131,28 @@ export async function supervise(argv, what) {
|
|
|
124
131
|
});
|
|
125
132
|
process.off("SIGTERM", handlers.SIGTERM);
|
|
126
133
|
process.off("SIGINT", handlers.SIGINT);
|
|
127
|
-
if (result.code
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
process.exit(128 + (os.constants.signals[result.signal] ?? 1));
|
|
132
|
-
process.exit(result.code ?? 1);
|
|
134
|
+
if (result.code === RESTART_EXIT_CODE) {
|
|
135
|
+
console.log(chalk.dim(`↻ relaunching ${what} with the current install ...`));
|
|
136
|
+
backoff = 1000;
|
|
137
|
+
continue;
|
|
133
138
|
}
|
|
134
|
-
|
|
139
|
+
// A crash after it was up (not asked to stop, not a clean exit): start it again, waiting longer each time it keeps crashing.
|
|
140
|
+
// One that fails right at start (a taken port, a second daemon) would only fail again — that exits, as before.
|
|
141
|
+
const ran = Date.now() - started;
|
|
142
|
+
const crashed = !stopping && result.code !== 0 && ran >= Number(process.env.KRAFTWERK_RESTART_AFTER_MS ?? 10_000);
|
|
143
|
+
if (crashed) {
|
|
144
|
+
if (ran > 120_000)
|
|
145
|
+
backoff = 1000;
|
|
146
|
+
const how = result.signal ? `signal ${result.signal}` : `exit ${result.code}`;
|
|
147
|
+
console.error(chalk.yellow(`↻ ${new Date().toISOString()} ${what} stopped unexpectedly (${how}) — starting it again in ${backoff / 1000}s`));
|
|
148
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
149
|
+
backoff = Math.min(backoff * 2, 60_000);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// A signal-killed server is not a clean exit — say so in the exit code
|
|
153
|
+
// (128 + signal, the shell convention) so callers can tell.
|
|
154
|
+
if (result.signal)
|
|
155
|
+
process.exit(128 + (os.constants.signals[result.signal] ?? 1));
|
|
156
|
+
process.exit(result.code ?? 1);
|
|
135
157
|
}
|
|
136
158
|
}
|
package/dist/client/routes.d.ts
CHANGED
|
@@ -44,11 +44,13 @@ export declare const ROUTES: {
|
|
|
44
44
|
readonly "chats.steer": readonly ["POST", "/api/chats/:id/steer"];
|
|
45
45
|
readonly "chats.stopTask": readonly ["POST", "/api/chats/:id/task-stop"];
|
|
46
46
|
readonly "devices.code": readonly ["POST", "/api/devices/code"];
|
|
47
|
+
readonly "devices.dropPush": readonly ["DELETE", "/api/devices/push"];
|
|
47
48
|
readonly "devices.list": readonly ["GET", "/api/devices"];
|
|
48
49
|
readonly "devices.pair": readonly ["POST", "/api/pair"];
|
|
49
50
|
readonly "devices.relay": readonly ["GET", "/api/devices/relay"];
|
|
50
51
|
readonly "devices.revoke": readonly ["DELETE", "/api/devices/:id"];
|
|
51
52
|
readonly "devices.self": readonly ["GET", "/api/devices/self"];
|
|
53
|
+
readonly "devices.setPush": readonly ["POST", "/api/devices/push"];
|
|
52
54
|
readonly "devices.setRelay": readonly ["POST", "/api/devices/relay"];
|
|
53
55
|
readonly "files.delete": readonly ["DELETE", "/api/files"];
|
|
54
56
|
readonly "files.list": readonly ["GET", "/api/files/list"];
|
package/dist/client/routes.js
CHANGED
|
@@ -45,11 +45,13 @@ export const ROUTES = {
|
|
|
45
45
|
"chats.steer": ["POST", "/api/chats/:id/steer"],
|
|
46
46
|
"chats.stopTask": ["POST", "/api/chats/:id/task-stop"],
|
|
47
47
|
"devices.code": ["POST", "/api/devices/code"],
|
|
48
|
+
"devices.dropPush": ["DELETE", "/api/devices/push"],
|
|
48
49
|
"devices.list": ["GET", "/api/devices"],
|
|
49
50
|
"devices.pair": ["POST", "/api/pair"],
|
|
50
51
|
"devices.relay": ["GET", "/api/devices/relay"],
|
|
51
52
|
"devices.revoke": ["DELETE", "/api/devices/:id"],
|
|
52
53
|
"devices.self": ["GET", "/api/devices/self"],
|
|
54
|
+
"devices.setPush": ["POST", "/api/devices/push"],
|
|
53
55
|
"devices.setRelay": ["POST", "/api/devices/relay"],
|
|
54
56
|
"files.delete": ["DELETE", "/api/files"],
|
|
55
57
|
"files.list": ["GET", "/api/files/list"],
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -9,9 +9,25 @@
|
|
|
9
9
|
* asking it (findDaemon), never by the file alone.
|
|
10
10
|
*/
|
|
11
11
|
export declare const DAEMON_PORT = 1980;
|
|
12
|
+
export interface DaemonFile {
|
|
13
|
+
pid: number;
|
|
14
|
+
port: number;
|
|
15
|
+
startedAt: string;
|
|
16
|
+
/** The supervisor around it (`kraftwerk daemon stop` signals that one, so it does not start the daemon again). */
|
|
17
|
+
supervisor?: number;
|
|
18
|
+
version?: string;
|
|
19
|
+
/** The roots it serves — reopened when the daemon starts again (a restart, a self-update). */
|
|
20
|
+
open?: string[];
|
|
21
|
+
}
|
|
22
|
+
/** The version the running daemon writes into its file (status shows it: the install may be newer than what runs). */
|
|
23
|
+
export declare const setDaemonVersion: (v: string) => void;
|
|
24
|
+
/** Where a daemon in the background (`kraftwerk daemon start`, the login item) writes what it says. */
|
|
25
|
+
export declare const daemonLogPath: () => string;
|
|
12
26
|
export declare function writeDaemonFile(port: number, open: string[]): Promise<void>;
|
|
13
27
|
/** What the last daemon served, to open again; [] without a file. */
|
|
14
28
|
export declare function lastOpenRoots(): Promise<string[]>;
|
|
29
|
+
/** The daemon file as the last daemon left it; null without one. */
|
|
30
|
+
export declare function readDaemonFile(): Promise<DaemonFile | null>;
|
|
15
31
|
/** The running daemon (its file, and a hub that answers on its port), or null. */
|
|
16
32
|
export declare function findDaemon(): Promise<{
|
|
17
33
|
pid: number;
|
package/dist/core/daemon.js
CHANGED
|
@@ -14,10 +14,18 @@ import path from "node:path";
|
|
|
14
14
|
export const DAEMON_PORT = 1980;
|
|
15
15
|
const daemonFile = () => path.join(os.homedir(), ".kraftwerk", "daemon.json");
|
|
16
16
|
const startedAt = new Date().toISOString();
|
|
17
|
+
let version;
|
|
18
|
+
/** The version the running daemon writes into its file (status shows it: the install may be newer than what runs). */
|
|
19
|
+
export const setDaemonVersion = (v) => {
|
|
20
|
+
version = v;
|
|
21
|
+
};
|
|
22
|
+
/** Where a daemon in the background (`kraftwerk daemon start`, the login item) writes what it says. */
|
|
23
|
+
export const daemonLogPath = () => path.join(os.homedir(), ".kraftwerk", "logs", "daemon.log");
|
|
17
24
|
export async function writeDaemonFile(port, open) {
|
|
18
25
|
await fs.mkdir(path.dirname(daemonFile()), { recursive: true });
|
|
19
26
|
const tmp = `${daemonFile()}.${process.pid}.tmp`;
|
|
20
|
-
|
|
27
|
+
const supervisor = Number(process.env.KRAFTWERK_SUPERVISOR_PID) || undefined;
|
|
28
|
+
await fs.writeFile(tmp, JSON.stringify({ pid: process.pid, port, startedAt, open, ...(supervisor ? { supervisor } : {}), ...(version ? { version } : {}) }));
|
|
21
29
|
await fs.rename(tmp, daemonFile());
|
|
22
30
|
}
|
|
23
31
|
/** What the last daemon served, to open again; [] without a file. */
|
|
@@ -30,6 +38,15 @@ export async function lastOpenRoots() {
|
|
|
30
38
|
return [];
|
|
31
39
|
}
|
|
32
40
|
}
|
|
41
|
+
/** The daemon file as the last daemon left it; null without one. */
|
|
42
|
+
export async function readDaemonFile() {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(await fs.readFile(daemonFile(), "utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
33
50
|
/** The running daemon (its file, and a hub that answers on its port), or null. */
|
|
34
51
|
export async function findDaemon() {
|
|
35
52
|
let rec;
|
package/dist/core/devices.d.ts
CHANGED
|
@@ -15,6 +15,18 @@ export interface Device {
|
|
|
15
15
|
name: string;
|
|
16
16
|
createdAt: string;
|
|
17
17
|
lastSeenAt?: string;
|
|
18
|
+
/** Whether the device gets push notifications (its app registered for them). */
|
|
19
|
+
push?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Where a device's push notifications go: its APNs token and environment, and
|
|
23
|
+
* the key (32 bytes, base64) the device made for this machine — what is pushed
|
|
24
|
+
* is sealed with it, so the relay and Apple carry it without reading it.
|
|
25
|
+
*/
|
|
26
|
+
export interface PushTarget {
|
|
27
|
+
token: string;
|
|
28
|
+
environment: "sandbox" | "production";
|
|
29
|
+
key: string;
|
|
18
30
|
}
|
|
19
31
|
/** How long a pairing code works. */
|
|
20
32
|
export declare const PAIR_CODE_TTL_MS: number;
|
|
@@ -33,3 +45,12 @@ export declare function deviceForToken(token: string): Promise<Device | null>;
|
|
|
33
45
|
export declare function listDevices(): Promise<Device[]>;
|
|
34
46
|
/** Unpair a device: its token stops working at once. */
|
|
35
47
|
export declare function revokeDevice(id: string): Promise<boolean>;
|
|
48
|
+
/** Register (or, with null, drop) where a device's push notifications go. */
|
|
49
|
+
export declare function setDevicePush(id: string, target: PushTarget | null): Promise<boolean>;
|
|
50
|
+
/** Every device that gets push notifications, with where they go. */
|
|
51
|
+
export declare function pushTargets(): Promise<Array<{
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
} & PushTarget>>;
|
|
55
|
+
/** Apple says a token is gone (the app was deleted): stop pushing to it. */
|
|
56
|
+
export declare function dropPushToken(token: string): Promise<void>;
|