@netnodeag/kraftwerk 0.67.0 → 0.68.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 +4 -2
- package/dist/cli/daemon-login.d.ts +18 -0
- package/dist/cli/daemon-login.js +104 -0
- package/dist/cli/daemon.js +17 -2
- package/dist/cli/doctor.js +12 -0
- package/dist/server/server.d.ts +2 -0
- package/dist/server/server.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,11 +65,12 @@ foreground; Ctrl-C stops it. That is all most people need.
|
|
|
65
65
|
|
|
66
66
|
**Several workspaces: `kraftwerk daemon`.** With more than one workspace,
|
|
67
67
|
run one kraftwerk for all of them instead of a server and a port each.
|
|
68
|
-
Start the daemon once and leave it running
|
|
69
|
-
|
|
68
|
+
Start the daemon once and leave it running, or let your Mac start it at
|
|
69
|
+
login:
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
kraftwerk daemon # the machine's kraftwerk, on http://localhost:1980
|
|
73
|
+
kraftwerk daemon install # or: start it at login from now on (macOS; `uninstall` undoes it)
|
|
73
74
|
|
|
74
75
|
cd ~/work/team-blau && kraftwerk ui # opens this workspace in the daemon:
|
|
75
76
|
# http://team-blau.localhost:1980
|
|
@@ -230,6 +231,7 @@ kraftwerk runs # past runs from output/*/trace.jsonl; r
|
|
|
230
231
|
kraftwerk knowledge # Knowledge: OKF bundles (list/get/put/verify/search/...)
|
|
231
232
|
kraftwerk ui # start this workspace: http://localhost:1981 (or open it in the daemon); --port, --lan, --standalone
|
|
232
233
|
kraftwerk daemon # one kraftwerk for every open workspace: http://<slug>.localhost:1980; --port, --lan
|
|
234
|
+
kraftwerk daemon install # start the daemon at login (macOS LaunchAgent); `daemon uninstall` removes it
|
|
233
235
|
kraftwerk workspaces # every workspace on this machine; workspaces start|stop|forget <ref>
|
|
234
236
|
kraftwerk devices # paired devices (a phone, another computer); devices pair|revoke
|
|
235
237
|
kraftwerk projects # goal-scoped project folders; projects create|show|link|log|remove
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kraftwerk daemon install` / `uninstall`: the daemon as a macOS login item
|
|
3
|
+
* (a LaunchAgent), so it runs after a reboot without a terminal or the
|
|
4
|
+
* desktop app. launchd starts the same `kraftwerk daemon` a terminal would —
|
|
5
|
+
* this node, this install — with the PATH of the shell that installed it,
|
|
6
|
+
* because launchd's own PATH would not find the agents (claude, codex, pi).
|
|
7
|
+
* The daemon's supervisor restarts it after a self-update; launchd only
|
|
8
|
+
* starts it at login.
|
|
9
|
+
*/
|
|
10
|
+
export declare const LAUNCH_LABEL = "ch.netnode.kraftwerk.daemon";
|
|
11
|
+
/** The LaunchAgent: `program` at login, `env` set, output appended to `log`. */
|
|
12
|
+
export declare function renderPlist(program: string[], env: Record<string, string>, log: string): string;
|
|
13
|
+
export declare function installDaemon(opts: {
|
|
14
|
+
port?: string;
|
|
15
|
+
}): Promise<void>;
|
|
16
|
+
export declare function uninstallDaemon(): Promise<void>;
|
|
17
|
+
/** For doctor: whether the login item is installed. */
|
|
18
|
+
export declare const daemonLoginItem: () => string | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { findDaemon } from "../core/daemon.js";
|
|
7
|
+
import { selfCommand } from "../core/self-command.js";
|
|
8
|
+
import { tildify } from "../core/instances.js";
|
|
9
|
+
/**
|
|
10
|
+
* `kraftwerk daemon install` / `uninstall`: the daemon as a macOS login item
|
|
11
|
+
* (a LaunchAgent), so it runs after a reboot without a terminal or the
|
|
12
|
+
* desktop app. launchd starts the same `kraftwerk daemon` a terminal would —
|
|
13
|
+
* this node, this install — with the PATH of the shell that installed it,
|
|
14
|
+
* because launchd's own PATH would not find the agents (claude, codex, pi).
|
|
15
|
+
* The daemon's supervisor restarts it after a self-update; launchd only
|
|
16
|
+
* starts it at login.
|
|
17
|
+
*/
|
|
18
|
+
export const LAUNCH_LABEL = "ch.netnode.kraftwerk.daemon";
|
|
19
|
+
const plistPath = () => path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCH_LABEL}.plist`);
|
|
20
|
+
const logPath = () => path.join(os.homedir(), ".kraftwerk", "logs", "daemon.log");
|
|
21
|
+
/** A stand-in launchctl (tests); the real one otherwise. */
|
|
22
|
+
const launchctl = () => process.env.KRAFTWERK_LAUNCHCTL || "launchctl";
|
|
23
|
+
const domain = () => `gui/${os.userInfo().uid}`;
|
|
24
|
+
const xml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
25
|
+
/** The LaunchAgent: `program` at login, `env` set, output appended to `log`. */
|
|
26
|
+
export function renderPlist(program, env, log) {
|
|
27
|
+
const strings = (xs) => xs.map((x) => `\t\t<string>${xml(x)}</string>`).join("\n");
|
|
28
|
+
const vars = Object.entries(env)
|
|
29
|
+
.map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t<string>${xml(v)}</string>`)
|
|
30
|
+
.join("\n");
|
|
31
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
32
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
33
|
+
<plist version="1.0">
|
|
34
|
+
<dict>
|
|
35
|
+
\t<key>Label</key>
|
|
36
|
+
\t<string>${LAUNCH_LABEL}</string>
|
|
37
|
+
\t<key>ProgramArguments</key>
|
|
38
|
+
\t<array>
|
|
39
|
+
${strings(program)}
|
|
40
|
+
\t</array>
|
|
41
|
+
\t<key>EnvironmentVariables</key>
|
|
42
|
+
\t<dict>
|
|
43
|
+
${vars}
|
|
44
|
+
\t</dict>
|
|
45
|
+
\t<key>WorkingDirectory</key>
|
|
46
|
+
\t<string>${xml(os.homedir())}</string>
|
|
47
|
+
\t<key>RunAtLoad</key>
|
|
48
|
+
\t<true/>
|
|
49
|
+
\t<key>StandardOutPath</key>
|
|
50
|
+
\t<string>${xml(log)}</string>
|
|
51
|
+
\t<key>StandardErrorPath</key>
|
|
52
|
+
\t<string>${xml(log)}</string>
|
|
53
|
+
</dict>
|
|
54
|
+
</plist>
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
function requireMac() {
|
|
58
|
+
if (process.platform !== "darwin") {
|
|
59
|
+
console.error(chalk.red("✖ a login item is macOS only for now — run `kraftwerk daemon` from your system's startup instead"));
|
|
60
|
+
process.exit(2);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function installDaemon(opts) {
|
|
64
|
+
requireMac();
|
|
65
|
+
const { cmd, args } = selfCommand(["daemon", ...(opts.port ? ["--port", opts.port] : [])]);
|
|
66
|
+
const env = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
|
|
67
|
+
if (process.env.KRAFTWERK_DAEMON_PORT && !opts.port)
|
|
68
|
+
env.KRAFTWERK_DAEMON_PORT = process.env.KRAFTWERK_DAEMON_PORT;
|
|
69
|
+
const file = plistPath();
|
|
70
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
71
|
+
await fs.mkdir(path.dirname(logPath()), { recursive: true });
|
|
72
|
+
await fs.writeFile(file, renderPlist([cmd, ...args], env, logPath()));
|
|
73
|
+
// An older version of the item is replaced: unload it first (an error just means it was not loaded).
|
|
74
|
+
spawnSync(launchctl(), ["bootout", `${domain()}/${LAUNCH_LABEL}`], { stdio: "ignore" });
|
|
75
|
+
const running = await findDaemon();
|
|
76
|
+
if (running) {
|
|
77
|
+
// Loading now would start a second daemon, which refuses to run. launchd loads the file at the next login.
|
|
78
|
+
console.log(`${chalk.green("✔")} login item installed: ${chalk.dim(tildify(file))}`);
|
|
79
|
+
console.log(chalk.dim(` a daemon already runs (pid ${running.pid}); from the next login on, launchd starts it`));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const r = spawnSync(launchctl(), ["bootstrap", domain(), file], { encoding: "utf8" });
|
|
83
|
+
if (r.status !== 0) {
|
|
84
|
+
console.error(chalk.red(`✖ launchctl bootstrap failed: ${(r.stderr || r.stdout || "").trim() || `exit ${r.status}`}`));
|
|
85
|
+
console.error(chalk.dim(` the file is in place (${tildify(file)}) and loads at the next login`));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
console.log(`${chalk.green("✔")} login item installed and started: ${chalk.dim(tildify(file))}`);
|
|
89
|
+
console.log(chalk.dim(` log: ${tildify(logPath())} · \`kraftwerk daemon uninstall\` removes it`));
|
|
90
|
+
}
|
|
91
|
+
export async function uninstallDaemon() {
|
|
92
|
+
requireMac();
|
|
93
|
+
const file = plistPath();
|
|
94
|
+
if (!existsSync(file)) {
|
|
95
|
+
console.log(chalk.dim("No login item installed."));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Unloading stops the daemon launchd started; one started from a terminal keeps running.
|
|
99
|
+
spawnSync(launchctl(), ["bootout", `${domain()}/${LAUNCH_LABEL}`], { stdio: "ignore" });
|
|
100
|
+
await fs.rm(file, { force: true });
|
|
101
|
+
console.log(`${chalk.green("✔")} login item removed ${chalk.dim(`(${tildify(file)})`)}`);
|
|
102
|
+
}
|
|
103
|
+
/** For doctor: whether the login item is installed. */
|
|
104
|
+
export const daemonLoginItem = () => (existsSync(plistPath()) ? plistPath() : null);
|
package/dist/cli/daemon.js
CHANGED
|
@@ -4,6 +4,7 @@ import { tildify } from "../core/instances.js";
|
|
|
4
4
|
import { networkUrls } from "../server/bind.js";
|
|
5
5
|
import { startHub } from "../server/server.js";
|
|
6
6
|
import { ensureBuilt, supervise } from "./ui.js";
|
|
7
|
+
import { installDaemon, uninstallDaemon } from "./daemon-login.js";
|
|
7
8
|
/**
|
|
8
9
|
* `kraftwerk daemon` — the machine's kraftwerk: one process serving every
|
|
9
10
|
* open workspace, each at `<slug>.localhost:<port>` (and its own port, when
|
|
@@ -49,12 +50,26 @@ export async function runDaemon(opts) {
|
|
|
49
50
|
console.log(`${chalk.green("✔")} On your network: ${lan.map((u) => chalk.cyan(u)).join(", ")} ${chalk.dim("(add /w/<slug>/; devices pair first)")}`);
|
|
50
51
|
}
|
|
51
52
|
export function registerDaemonCommand(program) {
|
|
52
|
-
program
|
|
53
|
+
const daemon = program
|
|
53
54
|
.command("daemon")
|
|
54
|
-
.description("Run the machine's kraftwerk: one process serving every open workspace at <slug>.localhost")
|
|
55
|
+
.description("Run the machine's kraftwerk: one process serving every open workspace at <slug>.localhost; install it as a login item")
|
|
55
56
|
.option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
|
|
56
57
|
.option("--lan", "Listen on the network too, for paired devices")
|
|
57
58
|
.action(async (opts) => {
|
|
58
59
|
await runDaemon(opts);
|
|
59
60
|
});
|
|
61
|
+
daemon
|
|
62
|
+
.command("install")
|
|
63
|
+
.description("Start the daemon at login (macOS LaunchAgent), and now unless one runs")
|
|
64
|
+
.option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
|
|
65
|
+
.action(async (opts) => {
|
|
66
|
+
// `--port` after `install` is taken by `daemon` itself, which has the same option.
|
|
67
|
+
await installDaemon({ port: opts.port ?? daemon.opts().port });
|
|
68
|
+
});
|
|
69
|
+
daemon
|
|
70
|
+
.command("uninstall")
|
|
71
|
+
.description("Remove the login item (stops a daemon launchd started)")
|
|
72
|
+
.action(async () => {
|
|
73
|
+
await uninstallDaemon();
|
|
74
|
+
});
|
|
60
75
|
}
|
package/dist/cli/doctor.js
CHANGED
|
@@ -9,6 +9,8 @@ import { applyDotenv, DOTENV_FILE } from "../dotenv.js";
|
|
|
9
9
|
import { legacyFindings, versionLt } from "./doctor-legacy.js";
|
|
10
10
|
import { listDevices } from "../core/devices.js";
|
|
11
11
|
import { slugClashes } from "../core/instances.js";
|
|
12
|
+
import { findDaemon } from "../core/daemon.js";
|
|
13
|
+
import { daemonLoginItem } from "./daemon-login.js";
|
|
12
14
|
const ICONS = {
|
|
13
15
|
ok: chalk.green("✔"),
|
|
14
16
|
warn: chalk.yellow("⚠"),
|
|
@@ -210,6 +212,16 @@ export async function runDoctor(cwd) {
|
|
|
210
212
|
if (process.env.KRAFTWERK_UI_TRUST_NETWORK === "1")
|
|
211
213
|
report("warn", "KRAFTWERK_UI_TRUST_NETWORK=1", "anyone who reaches the UI over the network uses it without pairing — unset it and pair devices instead (`kraftwerk devices pair`)");
|
|
212
214
|
report("info", devices.length ? `${devices.length} paired device(s)` : "no paired devices", devices.length ? devices.map((d) => d.name).join(", ") : "a phone or another computer can use kraftwerk once paired: `kraftwerk ui --lan`, then `kraftwerk devices pair`");
|
|
215
|
+
// The daemon: whether one runs, and whether it starts at login.
|
|
216
|
+
const daemon = await findDaemon();
|
|
217
|
+
const loginItem = daemonLoginItem();
|
|
218
|
+
report("info", daemon ? `kraftwerk daemon running on :${daemon.port}` : "no kraftwerk daemon running", loginItem
|
|
219
|
+
? "starts at login (`kraftwerk daemon uninstall` removes that)"
|
|
220
|
+
: process.platform === "darwin"
|
|
221
|
+
? "`kraftwerk daemon install` starts it at login"
|
|
222
|
+
: daemon
|
|
223
|
+
? undefined
|
|
224
|
+
: "`kraftwerk daemon` serves every workspace in one process");
|
|
213
225
|
// Legacy: what older versions left behind, and what still runs or is installed at one.
|
|
214
226
|
for (const f of await legacyFindings(project, pkg.version))
|
|
215
227
|
report(f.level, f.label, f.detail);
|
package/dist/server/server.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface OpenWorkspace {
|
|
|
25
25
|
/** Its own port too (kraftwerk.yml `port`), when the hub listens there for it — old links keep working. */
|
|
26
26
|
aliasPort?: number;
|
|
27
27
|
ws: Workspace;
|
|
28
|
+
/** Its instance file being written (see openNow): closing waits for it, so no file lands after the close. */
|
|
29
|
+
registered?: Promise<void>;
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
32
|
* One HTTP server serving workspaces by slug (see target.ts): a daemon
|
package/dist/server/server.js
CHANGED
|
@@ -388,7 +388,7 @@ export async function startHub(opts) {
|
|
|
388
388
|
startRoutineScheduler();
|
|
389
389
|
startGitSync();
|
|
390
390
|
// Best-effort, in the background: none of them may hold up (or, failing, break) the open.
|
|
391
|
-
|
|
391
|
+
entry.registered = registerInstance(entry.aliasPort ?? port, abs, opts.daemon ? { hub: true, slug, url: entry.url } : {}).catch(() => { });
|
|
392
392
|
void registerWorkspace(abs).catch(() => { });
|
|
393
393
|
void startCloudSync(entry.url, version).catch(() => { });
|
|
394
394
|
}));
|
|
@@ -419,6 +419,7 @@ export async function startHub(opts) {
|
|
|
419
419
|
await cloudGoodbye();
|
|
420
420
|
stopCloudSync();
|
|
421
421
|
}));
|
|
422
|
+
await entry.registered;
|
|
422
423
|
unregisterInstance(abs);
|
|
423
424
|
markWorkspaceStopped(abs);
|
|
424
425
|
entry.ws.close();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netnodeag/kraftwerk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
4
|
"description": "Open-source agentic workspace for teams — where people and AI agents get work done together: agents (Claude Code, Codex, Pi), skills, shared knowledge, repeatable workflows, and deterministic verification",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|