@netnodeag/kraftwerk 0.46.2 → 0.48.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 (31) hide show
  1. package/README.md +122 -2
  2. package/dist/cli/doctor.js +39 -1
  3. package/dist/cli/init.js +15 -8
  4. package/dist/cli/kraftwerk.js +14 -1
  5. package/dist/cli/tunnel.d.ts +10 -0
  6. package/dist/cli/tunnel.js +280 -0
  7. package/dist/cli/ui.js +13 -3
  8. package/dist/config.d.ts +55 -0
  9. package/dist/config.js +87 -1
  10. package/dist/dotenv.d.ts +40 -0
  11. package/dist/dotenv.js +84 -0
  12. package/dist/inspector/access.d.ts +32 -0
  13. package/dist/inspector/access.js +121 -0
  14. package/dist/inspector/server.js +48 -4
  15. package/inspector/dist/assets/{dist-CmOlZvwa.js → dist-BNGsfwfO.js} +1 -1
  16. package/inspector/dist/assets/{dist-BOQhQPN9.js → dist-BWkBM49t.js} +1 -1
  17. package/inspector/dist/assets/{dist-CUeUDZ9j.js → dist-Bz3XVWXl.js} +1 -1
  18. package/inspector/dist/assets/{dist-DnmDnMK2.js → dist-CmH9PvLb.js} +1 -1
  19. package/inspector/dist/assets/{dist-C2Mu5bT-.js → dist-D6fPO3Jz.js} +1 -1
  20. package/inspector/dist/assets/{dist-DfAFwiGQ.js → dist-DUq31xBw.js} +1 -1
  21. package/inspector/dist/assets/{dist-D8pjVMTl.js → dist-Dk5ZcwMb.js} +1 -1
  22. package/inspector/dist/assets/{dist-CjMETDfE.js → dist-Dv40dCIn.js} +1 -1
  23. package/inspector/dist/assets/dist-JaBY_kZV.js +1 -0
  24. package/inspector/dist/assets/{dist-BNUGzZS7.js → dist-O97r5Ket.js} +1 -1
  25. package/inspector/dist/assets/{dist-CdyUhzAt.js → dist-n8GR1Slo.js} +1 -1
  26. package/inspector/dist/assets/{editor-DJbeG376.js → editor-toVFopCO.js} +3 -3
  27. package/inspector/dist/assets/{index-DgEUOA11.js → index-CC0tY40i.js} +12 -12
  28. package/inspector/dist/index.html +1 -1
  29. package/package.json +1 -1
  30. package/schema/kraftwerk.schema.json +75 -2
  31. package/inspector/dist/assets/dist-rrIMDK_-.js +0 -1
package/README.md CHANGED
@@ -164,6 +164,8 @@ kraftwerk create "was der Workflow tun soll" # for LLM agents: prints a build
164
164
  kraftwerk runner build # build the Docker sandbox image (once)
165
165
  kraftwerk run --sandbox website-check "https://..." # isolated container per run; --ssh forwards the agent
166
166
  kraftwerk runner ps / stop <run-id> # see / stop running sandbox containers
167
+ kraftwerk tunnel setup kw.example.com # Cloudflare Tunnel to the inspector: login, create, route dns, kraftwerk.yml
168
+ kraftwerk tunnel # run that tunnel alone (the UI runs elsewhere)
167
169
  ```
168
170
 
169
171
  The inspector binds `127.0.0.1` — it has no authentication of its own and
@@ -177,8 +179,9 @@ overrides the default either way. A reverse proxy in front of it must set
177
179
  `X-Forwarded-Host` to the host the browser addressed (Caddy and traefik do
178
180
  by default; nginx needs `proxy_set_header X-Forwarded-Host $host;`): the
179
181
  loopback bind answers a non-loopback `Host` only when that header is
180
- present, and state-changing requests are refused when `Origin` names a
181
- different host.
182
+ present or the name is the project's `public:` hostname, and state-changing
183
+ requests are refused when `Origin` names a different host. See
184
+ [Inspector through a Cloudflare Tunnel](#inspector-through-a-cloudflare-tunnel).
182
185
 
183
186
  ### The kraftwerk.yml project config
184
187
 
@@ -187,6 +190,18 @@ fields are optional. `workflows:` sets the workflows root, `output:` the
187
190
  run-artifact directory (default `output/`), `knowledge:` the OKF bundle root
188
191
  (default `knowledge/`), and `agents:` the agent-definition root (default
189
192
  `agents/`). `repos:` turns on the [repositories](#repositories) folder.
193
+ `public:` and `tunnel:` expose the inspector through a
194
+ [Cloudflare Tunnel](#inspector-through-a-cloudflare-tunnel).
195
+
196
+ A `.env` next to kraftwerk.yml (`KEY=value` lines, `#` comments, quotes)
197
+ is loaded into every kraftwerk process at start: `kraftwerk ui` and the
198
+ server it supervises, so a restart from the UI re-reads the file; the chat
199
+ agents, routines and workflow runs the inspector spawns; the tunnel
200
+ (`TUNNEL_TOKEN`); `requires:` checks in `run` and `doctor`. A variable the
201
+ shell already sets wins over the file. A project started from another
202
+ workspace's inspector gets its own `.env`, not the other one's. The file is
203
+ never synced (it is on the workspace git's deny list and in the .gitignore
204
+ `kraftwerk init` writes); `kraftwerk doctor` lists the names it loaded.
190
205
 
191
206
  `switcher:` links other kraftwerk workspaces from the inspector header. The
192
207
  workspace name becomes a dropdown listing them:
@@ -283,6 +298,111 @@ repo. Agent logins made inside the container persist in the `agent-home`
283
298
  volume. This is a different image from the `kraftwerk-runner` sandbox
284
299
  (`runner/Dockerfile`) used by `run --sandbox`.
285
300
 
301
+ ### Inspector through a Cloudflare Tunnel
302
+
303
+ The other way to reach a laptop's or server's inspector from elsewhere: no
304
+ open port, no reverse proxy, no certificate. `kraftwerk ui` runs
305
+ [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)
306
+ next to the inspector and the loopback bind becomes reachable at a hostname
307
+ on a domain you have on Cloudflare.
308
+
309
+ You need a Cloudflare account with a domain added to it (the free plan is
310
+ enough), and cloudflared on the machine:
311
+
312
+ ```bash
313
+ brew install cloudflared # macOS; Linux packages: developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
314
+ ```
315
+
316
+ **1. Create the tunnel and route a hostname to it.** From the project root:
317
+
318
+ ```bash
319
+ kraftwerk tunnel setup kw.example.com
320
+ ```
321
+
322
+ This opens the browser for `cloudflared tunnel login` when there is no
323
+ certificate yet (pick the zone `kw.example.com` belongs to), creates a
324
+ tunnel named `kraftwerk-<project name>` (or `--name`), adds the CNAME from
325
+ the hostname to it, and writes the result into kraftwerk.yml:
326
+
327
+ ```yaml
328
+ public: https://kw.example.com # the hostname the tunnel routes to
329
+ tunnel:
330
+ name: kraftwerk-agent-playground
331
+ ```
332
+
333
+ A second run reuses the login and the tunnel. If the hostname already has a
334
+ DNS record, setup says so and `--overwrite-dns` replaces it. If cloudflared
335
+ quietly routed `kw.example.com.other-zone.com` instead, because the hostname
336
+ is outside the zone you logged in to, setup refuses and names the stray
337
+ record to delete before you log in to the right zone and run it again.
338
+
339
+ **2. Put a Cloudflare Access policy on the hostname.** The UI has no login
340
+ of its own and its chat runs coding agents against the workspace, so the
341
+ tunnel must never be the only thing between the internet and it. In the
342
+ [Zero Trust dashboard](https://one.dash.cloudflare.com/) go to Access →
343
+ Applications → Add an application → Self-hosted, enter `kw.example.com` as
344
+ the domain, and add a policy: emails ending in your domain with a one-time
345
+ PIN, a Google or GitHub login, whatever fits. Access is free for up to 50
346
+ users. From then on Cloudflare shows a login page before anything reaches
347
+ the tunnel.
348
+
349
+ **3. Let the inspector verify that login.** On the application's overview
350
+ page copy the Application Audience (AUD) tag, and take your team name from
351
+ the team domain `https://<team>.cloudflareaccess.com`:
352
+
353
+ ```yaml
354
+ tunnel:
355
+ name: kraftwerk-agent-playground
356
+ access:
357
+ team: my-team
358
+ aud: 4714c1358e65fe4b408ad6d432a5f878f08194bdb4752441fd56faefa9b2b6f2
359
+ ```
360
+
361
+ With this block the inspector checks the `Cf-Access-Jwt-Assertion` token
362
+ Access adds to every request, against the team's public keys, the audience
363
+ tag and the clock. A removed or misconfigured policy then fails closed with
364
+ a 401 instead of exposing the UI. Skipping the block works, and
365
+ `kraftwerk doctor` warns about it every time.
366
+
367
+ **4. Start.** `kraftwerk ui` now starts cloudflared with the inspector,
368
+ keeps it across UI restarts and stops it with the UI:
369
+
370
+ ```
371
+ ✔ Kraftwerk UI: http://localhost:1981
372
+ ✔ Public URL: https://kw.example.com
373
+ ↗ tunnel: running kraftwerk-agent-playground → https://kw.example.com → http://127.0.0.1:1981
374
+ cloudflared │ ... Registered tunnel connection ...
375
+ ```
376
+
377
+ Open `https://kw.example.com` from anywhere, log in through Access, and you
378
+ are in the same inspector. `localhost:1981` on the machine itself keeps
379
+ working without a login. `kraftwerk doctor` reports the tunnel, the Access
380
+ block and whether cloudflared is installed.
381
+
382
+ **Variants.** `kraftwerk tunnel` runs the configured tunnel alone, for an
383
+ inspector that already runs elsewhere (started by `kraftwerk projects
384
+ start`, or in a container). For a tunnel created in the Zero Trust
385
+ dashboard instead (Networks → Tunnels), leave `name` out, route the
386
+ hostname to `http://localhost:<port>` there and export its token as
387
+ `TUNNEL_TOKEN` before `kraftwerk ui`. `public:` on its own, without
388
+ `tunnel:`, is for any other proxy that forwards the browser's `Host`
389
+ without setting `X-Forwarded-Host`.
390
+
391
+ **Troubleshooting.** cloudflared's lines appear prefixed with
392
+ `cloudflared │`. "Cannot determine default origin certificate path" means no
393
+ login on this machine: run `cloudflared tunnel login` or setup again. "tunnel
394
+ not found" means the name in kraftwerk.yml does not exist in the account
395
+ that logged in; `cloudflared tunnel list` shows what does. A tunnel that
396
+ dies three times right after launch is given up and the UI stays local
397
+ until the next start. A 421 "unexpected Host header" in the browser means
398
+ `public:` does not match the hostname you opened. A 401 means Access is
399
+ configured in kraftwerk.yml but the token is missing or wrong: the
400
+ hostname has no Access application, or `team`/`aud` do not match it.
401
+
402
+ Quick tunnels (`trycloudflare.com`) are deliberately not supported: Access
403
+ cannot be attached to them, which would leave the UI open to anyone with
404
+ the URL.
405
+
286
406
  ## Persistent agents
287
407
 
288
408
  The inspector's "agents" screen turns chat agents into persistent teammates. An
@@ -2,9 +2,10 @@ import { spawnSync } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
- import { ignoreEntryFor, isDir, reposRootFor, resolveProject } from "../config.js";
5
+ import { ignoreEntryFor, isDir, publicHostFor, reposRootFor, resolveProject, tunnelFor } from "../config.js";
6
6
  import { discoverWorkflows } from "../discover.js";
7
7
  import { missingEnv } from "../yaml.js";
8
+ import { applyDotenv, DOTENV_FILE } from "../dotenv.js";
8
9
  const ICONS = {
9
10
  ok: chalk.green("✔"),
10
11
  warn: chalk.yellow("⚠"),
@@ -106,6 +107,16 @@ export async function runDoctor(cwd) {
106
107
  }
107
108
  }
108
109
  }
110
+ // .env next to kraftwerk.yml: already applied by the CLI hook; this call
111
+ // only reports what it holds (names, never values).
112
+ const dotenv = await applyDotenv(project.root);
113
+ if (dotenv.file) {
114
+ const names = [...dotenv.applied, ...dotenv.kept.map((k) => `${k} (shell wins)`)];
115
+ report("ok", `${DOTENV_FILE}: ${names.length} variable(s) loaded`, names.join(", ") || "empty");
116
+ }
117
+ else {
118
+ report("info", `no ${DOTENV_FILE}`, "variables for agents, workflows and the tunnel can live there — loaded on every start and restart");
119
+ }
109
120
  // Repositories: the clones root must stay out of the workspace git. git
110
121
  // itself decides — that covers worktrees (.git is a file), a workspace
111
122
  // nested in a larger repo, a .gitignore at the toplevel, global excludes.
@@ -123,6 +134,33 @@ export async function runDoctor(cwd) {
123
134
  else
124
135
  report("ok", label, "not inside a git repository");
125
136
  }
137
+ // Public hostname + Cloudflare Tunnel. The UI has no login of its own, so
138
+ // a tunnel without Access verification is worth a warning every time.
139
+ const publicHost = publicHostFor(project);
140
+ const tunnel = tunnelFor(project);
141
+ if (publicHost && !tunnel)
142
+ report("info", `public: ${publicHost}`, "served when a tunnel or reverse proxy delivers that Host");
143
+ if (tunnel) {
144
+ const cloudflared = cliVersion("cloudflared");
145
+ if (cloudflared)
146
+ report("ok", `cloudflared`, cloudflared);
147
+ else {
148
+ report("fail", "cloudflared missing", "needed by tunnel: — brew install cloudflared (or see developers.cloudflare.com)");
149
+ failures++;
150
+ }
151
+ if (tunnel.name)
152
+ report("ok", `tunnel: ${tunnel.name} → ${publicHost}`, `cloudflared tunnel run --url http://127.0.0.1:${project.config.port ?? 1981} ${tunnel.name}`);
153
+ else if (process.env.TUNNEL_TOKEN)
154
+ report("ok", `tunnel: dashboard-managed → ${publicHost}`, "TUNNEL_TOKEN is set");
155
+ else {
156
+ report("fail", "tunnel: nothing to run", "set tunnel.name (locally-managed) or the TUNNEL_TOKEN env var (dashboard-managed)");
157
+ failures++;
158
+ }
159
+ if (tunnel.access)
160
+ report("ok", "tunnel.access", `tokens verified against ${tunnel.access.team}.cloudflareaccess.com`);
161
+ else
162
+ report("warn", "tunnel without access", "the UI has no login of its own — put a Cloudflare Access policy on the hostname and set tunnel.access so a removed policy fails closed");
163
+ }
126
164
  const found = project.workflowsRoot ? await discoverWorkflows(cwd) : [];
127
165
  if (!project.workflowsRoot) {
128
166
  report("warn", "no workflows root", "expected src/workflows/ or workflows/ — `kraftwerk init` scaffolds one");
package/dist/cli/init.js CHANGED
@@ -2,6 +2,7 @@ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import chalk from "chalk";
4
4
  import { CONFIG_SCHEMA_URL, gitignoreHas, SCHEMA_URL } from "../config.js";
5
+ import { DOTENV_FILE } from "../dotenv.js";
5
6
  import { initBundle, writeConcept } from "../okf.js";
6
7
  /**
7
8
  * `kraftwerk init` — make any repository a kraftwerk consumer in one
@@ -25,6 +26,12 @@ skills: ${DATA_DIR}/skills # workspace skills (shared instruction packag
25
26
  # repos: # git repositories the agents work on (uncomment both lines to enable)
26
27
  # root: ${DATA_DIR}/repos # clones land here (git-ignored); a bare \`repos:\` uses repos/ instead
27
28
  # vibeables: # small apps built live in a chat with a preview pane (uncomment to enable; one folder per app under ${DATA_DIR}/vibeables, versioned with the workspace)
29
+ # public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
30
+ # tunnel: # Cloudflare Tunnel run by \`kraftwerk ui\` (needs public:; put a Cloudflare Access policy on the hostname — the UI has no login of its own)
31
+ # name: kraftwerk # locally-managed tunnel (cloudflared tunnel create/route dns); omit and export TUNNEL_TOKEN for a dashboard-managed one
32
+ # access: # verify the Access login on every request via public: (team = Zero Trust team name, aud = the application's audience tag)
33
+ # team: my-team
34
+ # aud: 4714c1358e65fe4b408ad6d432a5f878f08194bdb4752441fd56faefa9b2b6f2
28
35
  `;
29
36
  const WORKFLOW_TEMPLATE = `# yaml-language-server: $schema=${SCHEMA_URL}
30
37
  name: hello
@@ -127,24 +134,24 @@ export async function runInit(cwd) {
127
134
  await writeConcept(knowledgeRoot, DEMO_BUNDLE, "playbooks/refunds", DEMO_CONCEPT, "kraftwerk-init");
128
135
  created.push(bundleRel);
129
136
  }
130
- // .gitignore: the output dir and the repos root (clones must never become
131
- // gitlinks of the workspace repo). A missing file gets both in one write;
132
- // an existing one only the entries it lacks.
137
+ // .gitignore: the output dir, the repos root (clones must never become
138
+ // gitlinks of the workspace repo) and .env (secrets). A missing file gets
139
+ // all in one write; an existing one only the entries it lacks.
133
140
  const gitignorePath = path.join(cwd, ".gitignore");
134
141
  const gitignore = (await readFile(gitignorePath, "utf8").catch(() => null)) ?? null;
135
- const entries = [`${DATA_DIR}/output`, `${DATA_DIR}/repos`];
142
+ const entries = [`${DATA_DIR}/output/`, `${DATA_DIR}/repos/`, DOTENV_FILE];
136
143
  if (gitignore === null) {
137
- await writeFile(gitignorePath, entries.map((e) => `${e}/\n`).join(""));
144
+ await writeFile(gitignorePath, entries.map((e) => `${e}\n`).join(""));
138
145
  created.push(".gitignore");
139
146
  }
140
147
  else {
141
- const missing = entries.filter((e) => !gitignoreHas(gitignore, e));
148
+ const missing = entries.filter((e) => !gitignoreHas(gitignore, e.replace(/\/$/, "")));
142
149
  if (missing.length === 0) {
143
150
  skipped.push(".gitignore");
144
151
  }
145
152
  else {
146
- await appendFile(gitignorePath, `${gitignore.endsWith("\n") ? "" : "\n"}${missing.map((e) => `${e}/\n`).join("")}`);
147
- created.push(`.gitignore (${missing.map((e) => `${e}/`).join(", ")} added)`);
153
+ await appendFile(gitignorePath, `${gitignore.endsWith("\n") ? "" : "\n"}${missing.map((e) => `${e}\n`).join("")}`);
154
+ created.push(`.gitignore (${missing.join(", ")} added)`);
148
155
  }
149
156
  }
150
157
  for (const f of created)
@@ -15,6 +15,9 @@ import { registerKnowledgeCommands } from "./knowledge.js";
15
15
  import { registerProjectCommands } from "./projects.js";
16
16
  import { registerRepoCommands } from "./repos.js";
17
17
  import { registerVibeableCommands } from "./vibeables.js";
18
+ import { registerTunnelCommands } from "./tunnel.js";
19
+ import { applyDotenv } from "../dotenv.js";
20
+ import { resolveProject } from "../config.js";
18
21
  import { registerRoutineCommands } from "./routines.js";
19
22
  import { listRuns, showRun } from "./runs.js";
20
23
  import { runUi } from "./ui.js";
@@ -30,6 +33,7 @@ import { runUi } from "./ui.js";
30
33
  * kraftwerk ui start the inspector web UI (localhost:1981)
31
34
  * kraftwerk projects ... known projects on this machine (list/start/forget)
32
35
  * kraftwerk repos ... repositories the agents work on (list/add/update/remove)
36
+ * kraftwerk tunnel [setup <host>] Cloudflare Tunnel to the inspector: run it alone, or set one up
33
37
  * kraftwerk doctor preflight: harness CLIs, docker, workflows, env
34
38
  * kraftwerk validate [paths...] validate without executing
35
39
  *
@@ -59,7 +63,15 @@ const pkg = JSON.parse(await readFile(new URL("../../package.json", import.meta.
59
63
  const program = new Command()
60
64
  .name("kraftwerk")
61
65
  .description("kraftwerk — agentic workspace for teams: agents, skills, knowledge, workflows, and the inspector UI")
62
- .version(pkg.version);
66
+ .version(pkg.version)
67
+ // The project's .env, before any command runs (and so before `kraftwerk
68
+ // ui` spawns its server, and again in that server on every restart). A
69
+ // broken kraftwerk.yml is the command's own error to report, not the hook's.
70
+ .hook("preAction", async () => {
71
+ const project = await resolveProject(process.cwd()).catch(() => null);
72
+ if (project)
73
+ await applyDotenv(project.root);
74
+ });
63
75
  const agentLabel = (workflow) => workflow.meta.agents
64
76
  .map((a) => {
65
77
  const where = [a.harness && a.harness !== "claude" ? a.harness : "", a.protocol === "acp" ? "acp" : ""]
@@ -270,6 +282,7 @@ registerRoutineCommands(program);
270
282
  registerProjectCommands(program);
271
283
  registerRepoCommands(program);
272
284
  registerVibeableCommands(program);
285
+ registerTunnelCommands(program);
273
286
  program
274
287
  .command("ui")
275
288
  .description("Start the inspector web UI for this project's runs and workflows")
@@ -0,0 +1,10 @@
1
+ import type { Command } from "commander";
2
+ import { type Project } from "../config.js";
3
+ export interface RunningTunnel {
4
+ stop(): void;
5
+ /** Settles when the tunnel was stopped, or gave up on a cloudflared that keeps dying. */
6
+ done: Promise<"stopped" | "failed">;
7
+ }
8
+ /** Start cloudflared for the project's tunnel, or return undefined (nothing to run, or told why not). */
9
+ export declare function startTunnel(project: Project, port: number): RunningTunnel | undefined;
10
+ export declare function registerTunnelCommands(program: Command): void;
@@ -0,0 +1,280 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import http from "node:http";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import chalk from "chalk";
8
+ import { parseDocument } from "yaml";
9
+ import { parsePublic, publicHostFor, resolveProject, tunnelFor } from "../config.js";
10
+ /**
11
+ * Cloudflare Tunnel next to the inspector. `kraftwerk ui` runs cloudflared
12
+ * as a sibling of the server so the loopback bind is reachable at the
13
+ * project's public hostname without opening a port. The tunnel itself is
14
+ * created once, by `kraftwerk tunnel setup` or by hand:
15
+ *
16
+ * locally-managed cloudflared tunnel login
17
+ * cloudflared tunnel create kraftwerk
18
+ * cloudflared tunnel route dns kraftwerk kw.example.com
19
+ * → tunnel: { name: kraftwerk }; everything routes to the inspector port
20
+ * dashboard-managed create the tunnel in Zero Trust → Networks → Tunnels, route the
21
+ * public hostname to http://localhost:<port> there, export TUNNEL_TOKEN
22
+ * → tunnel: {} ; cloudflared reads the token from the environment
23
+ *
24
+ * cloudflared reconnects on its own; this only respawns it when the process
25
+ * itself dies, after a pause. A process that keeps dying right after launch
26
+ * (no login, unknown tunnel name) is a configuration problem: after a few
27
+ * such exits the tunnel gives up and says so, and the UI stays local.
28
+ *
29
+ * kraftwerk tunnel run the configured tunnel alone (the UI runs elsewhere)
30
+ * kraftwerk tunnel setup <hostname> login, create, route dns, write public: + tunnel.name
31
+ */
32
+ const RESPAWN_DELAY = 5_000;
33
+ /** An exit this soon after launch is a configuration problem, not a hiccup. */
34
+ const FAST_EXIT = 10_000;
35
+ const MAX_FAST_EXITS = 3;
36
+ /** Start cloudflared for the project's tunnel, or return undefined (nothing to run, or told why not). */
37
+ export function startTunnel(project, port) {
38
+ const tunnel = tunnelFor(project);
39
+ if (!tunnel)
40
+ return undefined;
41
+ const host = publicHostFor(project);
42
+ let args;
43
+ if (tunnel.name) {
44
+ args = ["tunnel", "--no-autoupdate", "run", "--url", `http://127.0.0.1:${port}`, tunnel.name];
45
+ }
46
+ else if (process.env.TUNNEL_TOKEN) {
47
+ args = ["tunnel", "--no-autoupdate", "run"];
48
+ }
49
+ else {
50
+ console.error(`${chalk.red("✖")} tunnel: nothing to run — set ${chalk.bold("tunnel.name")} in kraftwerk.yml (locally-managed) ` +
51
+ `or the ${chalk.bold("TUNNEL_TOKEN")} environment variable (dashboard-managed). The UI stays local.`);
52
+ return undefined;
53
+ }
54
+ let child;
55
+ let stopped = false;
56
+ let timer;
57
+ let fastExits = 0;
58
+ let settle = () => { };
59
+ const done = new Promise((resolve) => (settle = resolve));
60
+ const launch = () => {
61
+ const startedAt = Date.now();
62
+ console.log(`${chalk.dim("↗")} tunnel: ${tunnel.name ? `running ${chalk.bold(tunnel.name)}` : "running the dashboard-managed tunnel"}` +
63
+ chalk.dim(` → https://${host} → http://127.0.0.1:${port}`));
64
+ child = spawn("cloudflared", args, { stdio: ["ignore", "pipe", "pipe"] });
65
+ // cloudflared logs on stderr, one line per event; keep them recognisable.
66
+ const relay = (chunk) => {
67
+ for (const line of chunk.toString().split("\n"))
68
+ if (line.trim())
69
+ console.log(chalk.dim(` cloudflared │ ${line}`));
70
+ };
71
+ child.stdout?.on("data", relay);
72
+ child.stderr?.on("data", relay);
73
+ child.once("error", (err) => {
74
+ stopped = true;
75
+ if (err.code === "ENOENT") {
76
+ console.error(`${chalk.red("✖")} tunnel: cloudflared not found — brew install cloudflared (or see developers.cloudflare.com). The UI stays local.`);
77
+ }
78
+ else {
79
+ console.error(`${chalk.red("✖")} tunnel: cannot start cloudflared: ${err.message}`);
80
+ }
81
+ settle("failed");
82
+ });
83
+ // "close", not "exit": the last log lines are still in flight when the
84
+ // process ends, and they are what explains a failed launch.
85
+ child.once("close", (code, signal) => {
86
+ child = undefined;
87
+ if (stopped)
88
+ return settle("stopped");
89
+ fastExits = Date.now() - startedAt < FAST_EXIT ? fastExits + 1 : 0;
90
+ if (fastExits >= MAX_FAST_EXITS) {
91
+ console.error(`${chalk.red("✖")} tunnel: cloudflared exited ${MAX_FAST_EXITS} times right after launch — giving up. ` +
92
+ `Check the messages above (cloudflared tunnel login? does the tunnel exist?), then restart the UI. The UI stays local.`);
93
+ return settle("failed");
94
+ }
95
+ console.error(`${chalk.red("✖")} tunnel: cloudflared exited (${signal ?? `code ${code}`}) — retrying in ${RESPAWN_DELAY / 1000}s`);
96
+ timer = setTimeout(launch, RESPAWN_DELAY);
97
+ });
98
+ };
99
+ launch();
100
+ return {
101
+ done,
102
+ stop() {
103
+ stopped = true;
104
+ if (timer)
105
+ clearTimeout(timer);
106
+ if (child)
107
+ child.kill("SIGTERM");
108
+ else
109
+ settle("stopped");
110
+ },
111
+ };
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // CLI
115
+ const die = (msg, code = 1) => {
116
+ console.error(chalk.red(msg));
117
+ process.exit(code);
118
+ };
119
+ /** cloudflared's version line, or undefined when the binary is not on PATH. */
120
+ function cloudflaredVersion() {
121
+ const r = spawnSync("cloudflared", ["--version"], { encoding: "utf8", timeout: 10_000 });
122
+ if (r.error || r.status !== 0)
123
+ return undefined;
124
+ return (r.stdout || r.stderr).trim().split("\n")[0];
125
+ }
126
+ /** Run one cloudflared command to completion, capturing its output (stderr carries the log lines). */
127
+ function cloudflared(args) {
128
+ const r = spawnSync("cloudflared", args, { encoding: "utf8", timeout: 120_000, stdio: ["inherit", "pipe", "pipe"] });
129
+ return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
130
+ }
131
+ /** Whether something already answers on the inspector port (so the tunnel has an origin). */
132
+ function inspectorListening(port) {
133
+ return new Promise((resolve) => {
134
+ const req = http.get({ host: "127.0.0.1", port, path: "/api/meta?probe=1", timeout: 1_500 }, (res) => {
135
+ res.resume();
136
+ resolve(true);
137
+ });
138
+ req.on("error", () => resolve(false));
139
+ req.on("timeout", () => {
140
+ req.destroy();
141
+ resolve(false);
142
+ });
143
+ });
144
+ }
145
+ /** A tunnel name from the project's display name or folder: "kraftwerk-agent-playground". */
146
+ function defaultTunnelName(project) {
147
+ const base = (project.config.name ?? path.basename(project.root)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
148
+ return `kraftwerk-${base || "inspector"}`;
149
+ }
150
+ /** The origin certificate `cloudflared tunnel login` writes; its presence means an authorized zone. */
151
+ function originCertPath() {
152
+ return process.env.TUNNEL_ORIGIN_CERT || path.join(os.homedir(), ".cloudflared", "cert.pem");
153
+ }
154
+ /** Write public: and tunnel.name into kraftwerk.yml, keeping everything else (comments, an access block) as it is. */
155
+ async function writeTunnelConfig(project, publicUrl, name) {
156
+ const configPath = project.configPath;
157
+ const doc = parseDocument(await readFile(configPath, "utf8"));
158
+ if (doc.errors.length > 0)
159
+ throw new Error(`${path.basename(configPath)}: ${doc.errors[0].message}`);
160
+ doc.set("public", publicUrl);
161
+ const tunnel = doc.get("tunnel");
162
+ if (tunnel && typeof tunnel === "object") {
163
+ doc.setIn(["tunnel", "name"], name);
164
+ doc.deleteIn(["tunnel", "enabled"]);
165
+ }
166
+ else {
167
+ doc.set("tunnel", { name });
168
+ }
169
+ await writeFile(configPath, doc.toString());
170
+ return configPath;
171
+ }
172
+ export function registerTunnelCommands(program) {
173
+ const tunnel = program
174
+ .command("tunnel")
175
+ .description("Cloudflare Tunnel to the inspector: run the configured one alone, or set one up");
176
+ tunnel
177
+ .command("run", { isDefault: true })
178
+ .description("Run the project's tunnel in the foreground (the inspector runs separately, e.g. `kraftwerk ui` elsewhere)")
179
+ .option("--port <port>", "Inspector port to route to (default: kraftwerk.yml `port`, else 1981)")
180
+ .action(async (opts) => {
181
+ const project = await resolveProject(process.cwd()).catch((err) => die(err.message, 2));
182
+ if (!tunnelFor(project)) {
183
+ die("tunnel is off — `kraftwerk tunnel setup <hostname>` creates one, or add `public:` and `tunnel:` to kraftwerk.yml (see kraftwerk doctor).");
184
+ }
185
+ const port = opts.port ? Number(opts.port) : (project.config.port ?? 1981);
186
+ if (!Number.isInteger(port) || port <= 0)
187
+ die("--port must be a port number", 2);
188
+ if (!(await inspectorListening(port))) {
189
+ console.log(chalk.yellow(`⚠ nothing answers on http://127.0.0.1:${port} yet — start \`kraftwerk ui\`; the tunnel serves errors until then.`));
190
+ }
191
+ const running = startTunnel(project, port);
192
+ if (!running)
193
+ process.exit(1);
194
+ for (const sig of ["SIGINT", "SIGTERM"])
195
+ process.once(sig, () => running.stop());
196
+ const outcome = await running.done;
197
+ process.exit(outcome === "failed" ? 1 : 0);
198
+ });
199
+ tunnel
200
+ .command("setup <hostname>")
201
+ .description("Create a locally-managed tunnel for this project and route the hostname to it (login, create, route dns, kraftwerk.yml)")
202
+ .option("--name <name>", "Tunnel name (default: kraftwerk-<project name>)")
203
+ .option("--overwrite-dns", "Replace an existing DNS record for the hostname")
204
+ .action(async (hostname, opts) => {
205
+ const host = parsePublic(hostname)?.hostname ?? die(`"${hostname}" is not a hostname — expected something like kw.example.com`, 2);
206
+ const project = await resolveProject(process.cwd()).catch((err) => die(err.message, 2));
207
+ if (!project.configPath)
208
+ die("no kraftwerk.yml here — `kraftwerk init` scaffolds one, then run setup again.", 2);
209
+ const name = opts.name ?? defaultTunnelName(project);
210
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
211
+ die(`"${name}" is not a plain tunnel name (letters, digits, ".", "_", "-")`, 2);
212
+ const version = cloudflaredVersion();
213
+ if (!version)
214
+ die("cloudflared not found — brew install cloudflared (or see developers.cloudflare.com), then run setup again.");
215
+ console.log(`${chalk.green("✔")} ${version}`);
216
+ // 1. Login: one authorized zone at a time, stored as cert.pem.
217
+ if (existsSync(originCertPath())) {
218
+ console.log(`${chalk.green("✔")} logged in to Cloudflare ${chalk.dim(`(${originCertPath()})`)}`);
219
+ }
220
+ else {
221
+ console.log(`${chalk.dim("↗")} cloudflared tunnel login — pick the zone that ${chalk.bold(host)} belongs to in the browser`);
222
+ const login = spawnSync("cloudflared", ["tunnel", "login"], { stdio: "inherit" });
223
+ if (login.error || login.status !== 0 || !existsSync(originCertPath()))
224
+ die("cloudflared tunnel login did not complete.");
225
+ console.log(`${chalk.green("✔")} logged in to Cloudflare`);
226
+ }
227
+ // 2. The tunnel: reuse one of that name, else create it.
228
+ const list = cloudflared(["tunnel", "list", "--output", "json", "--name", name]);
229
+ if (!list.ok)
230
+ die(`cloudflared tunnel list failed:\n${list.out}`);
231
+ let existing;
232
+ try {
233
+ existing = JSON.parse(list.out || "[]").find((t) => t.name === name);
234
+ }
235
+ catch {
236
+ die(`cloudflared tunnel list returned no JSON:\n${list.out}`);
237
+ }
238
+ let id = existing?.id;
239
+ if (existing) {
240
+ console.log(`${chalk.green("✔")} tunnel ${chalk.bold(name)} exists ${chalk.dim(id ?? "")}`);
241
+ }
242
+ else {
243
+ const created = cloudflared(["tunnel", "create", "--output", "json", name]);
244
+ if (!created.ok)
245
+ die(`cloudflared tunnel create failed:\n${created.out}`);
246
+ try {
247
+ id = JSON.parse(created.out).id;
248
+ }
249
+ catch {
250
+ // Not fatal: the route step addresses the tunnel by name.
251
+ }
252
+ console.log(`${chalk.green("✔")} created tunnel ${chalk.bold(name)} ${chalk.dim(id ?? "")}`);
253
+ }
254
+ // 3. DNS: a CNAME from the hostname to the tunnel. cloudflared can
255
+ // exit 0 after quietly appending the authorized zone to a hostname
256
+ // outside it — compare the record it reports with the one asked for.
257
+ const route = cloudflared(["tunnel", "route", "dns", ...(opts.overwriteDns ? ["--overwrite-dns"] : []), name, host]);
258
+ const added = /Added CNAME (\S+)/.exec(route.out)?.[1]?.replace(/\.$/, "").toLowerCase();
259
+ if (!route.ok) {
260
+ const exists = /already exists/i.test(route.out);
261
+ die(`cloudflared tunnel route dns failed:\n${route.out}` +
262
+ (exists && !opts.overwriteDns ? `\n\nA record for ${host} exists. If it should point at this tunnel, run setup again with --overwrite-dns.` : ""));
263
+ }
264
+ if (added && added !== host.toLowerCase()) {
265
+ die(`cloudflared routed ${chalk.bold(added)} instead of ${chalk.bold(host)}: the hostname is outside the zone you logged in to.\n` +
266
+ `Delete the stray CNAME ${added} in the Cloudflare dashboard, run \`cloudflared tunnel login\` for the right zone, then setup again.`);
267
+ }
268
+ console.log(`${chalk.green("✔")} ${host} → tunnel ${name}`);
269
+ // 4. kraftwerk.yml
270
+ const publicUrl = `https://${host}`;
271
+ const configPath = await writeTunnelConfig(project, publicUrl, name).catch((err) => die(err.message));
272
+ console.log(`${chalk.green("✔")} ${path.basename(configPath)}: public: ${publicUrl}, tunnel.name: ${name}`);
273
+ const port = project.config.port ?? 1981;
274
+ console.log(`\n${chalk.bold("Next:")} put a Cloudflare Access policy on ${host} — the UI has no login of its own.\n` +
275
+ ` Zero Trust → Access → Applications → Add an application → Self-hosted, domain ${host}: https://one.dash.cloudflare.com/\n` +
276
+ ` Then copy the team name and the application's Audience tag into kraftwerk.yml so the inspector verifies every login:\n` +
277
+ chalk.dim(` tunnel:\n name: ${name}\n access:\n team: <team> # https://<team>.cloudflareaccess.com\n aud: <audience tag>\n`) +
278
+ `\n\`kraftwerk ui\` now starts the tunnel with the inspector (port ${port}); \`kraftwerk tunnel\` runs it alone. \`kraftwerk doctor\` checks it.`);
279
+ });
280
+ }