@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 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 (a terminal tab, or a login
69
- item):
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[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);
@@ -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
  }
@@ -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);
@@ -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
@@ -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
- void registerInstance(entry.aliasPort ?? port, abs, opts.daemon ? { hub: true, slug, url: entry.url } : {}).catch(() => { });
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.67.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",