@bakers-build/addon-ports 0.0.0-stage → 0.1.0-next.38

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.
@@ -0,0 +1,91 @@
1
+ import type { HttpProbe } from './claims.js';
2
+ import type { HostDiscoveryDeps } from './discovery.js';
3
+ import { type StateDeps } from './state.js';
4
+ /** The filesystem seams for the state file. */
5
+ export declare const nodeStateDeps: StateDeps;
6
+ /** The discovery seams: `ss` and `/proc/<pid>`. */
7
+ export declare const nodeDiscoveryDeps: HostDiscoveryDeps;
8
+ /**
9
+ * AC6 — `/proc/<pid>/stat`'s 22nd field (start time, in clock ticks since
10
+ * boot): a stronger PID-reuse guard than cmdline/deleted-cwd alone, which
11
+ * could coincidentally match a reused pid running a similar-looking process.
12
+ * Pure and exported so the field-counting (the 2nd field is `(comm)`, which
13
+ * may itself contain spaces/parens) is directly testable without `/proc`.
14
+ */
15
+ export declare function parseProcStatStartTime(stat: string): number | null;
16
+ /** `null` when the process is gone or the field cannot be parsed — no
17
+ * process to compare a start time against either way. */
18
+ export declare function processStartTime(pid: number): Promise<number | null>;
19
+ /**
20
+ * BB-681 Task 4 — a process whose cmdline carries ANY pluggable-transport
21
+ * package's own ownership marker: `addon-ports-cloudflare-quick`'s private
22
+ * per-route `--config <BB_HOME>/ports/quick/<hash>/gen-<n>/config.yaml`,
23
+ * or a Named Cloudflare connector's `tunnel run` invocation. This generic
24
+ * legacy-reap scan predates per-route managed processes (BB-676) and its
25
+ * only signal for "legacy" is `cmdline.includes('--url')` (see
26
+ * `index.ts`'s reap handler) — which the new Quick package's OWN managed
27
+ * processes also match, since they use the same `--url` flag. Without this
28
+ * guard, generic reap would treat every quick-tunnel package process as an
29
+ * orphan it's free to kill; the quick package owns its OWN revalidated
30
+ * reap (`reconcileQuick`'s `desired: 'absent'` path) and this generic scan
31
+ * must never duplicate or race it.
32
+ */
33
+ export declare function isPluggableTransportManaged(cmdline: string): boolean;
34
+ /**
35
+ * BB-676 Task 4 — scans the process table for `cloudflared`, keeping
36
+ * `cmdline` so a caller can tell a legacy `tunnel --url …` quick-tunnel
37
+ * (reap's cleanup target) apart from the machine's named `tunnel run`
38
+ * connector, which reap must never touch (plan review focus 4). BB-681
39
+ * Task 4: also excludes anything `isPluggableTransportManaged` recognizes —
40
+ * a pluggable transport's own managed process, never generic reap's to kill.
41
+ */
42
+ export declare function listCloudflaredProcesses(): Promise<{
43
+ pid: number;
44
+ cwd: string;
45
+ deleted: boolean;
46
+ cmdline: string;
47
+ startTime: number | null;
48
+ }[]>;
49
+ /**
50
+ * AC6 — read a worktree's OLD pre-BB-676 per-port quick-tunnel state
51
+ * (`.bakers/ports-tunnels.json`, written by the `transport.ts`/`state.ts` path
52
+ * this feature replaced) purely as migration evidence: a legacy-process
53
+ * candidate whose pid appears here is additional corroboration reap may use,
54
+ * never a replacement for the existing argv/owner/cwd checks. Never written
55
+ * or removed by this addon. Absent or malformed reads as "no evidence", not
56
+ * an error — a half-written or already-cleaned-up legacy file must not block
57
+ * reap.
58
+ */
59
+ export declare function readLegacyTunnelPids(worktree: string): Promise<ReadonlySet<number>>;
60
+ /**
61
+ * Is `pid` owned by this process's own user? Legacy reap must never kill
62
+ * another user's process (plan review focus 4) — even an own-user match is
63
+ * re-validated by the caller against fresh `processInfo` immediately before
64
+ * `SIGTERM`, to close the PID-reuse race between listing and killing.
65
+ */
66
+ export declare function isOwnedByCurrentUser(pid: number): Promise<boolean>;
67
+ /** BB-676 Task 4 — a bounded, non-redirecting Storybook probe over real `fetch`. */
68
+ export declare const nodeHttpProbe: HttpProbe;
69
+ /** Everything a command needs, bundled. */
70
+ export declare const nodeDeps: {
71
+ run(cmd: string, args: string[], cwd?: string): Promise<{
72
+ stdout: string;
73
+ ok: boolean;
74
+ }>;
75
+ processInfo(pid: number): Promise<{
76
+ path: string;
77
+ deleted: boolean;
78
+ cmdline: string;
79
+ } | null>;
80
+ readFile(path: string): Promise<string | null>;
81
+ };
82
+ /** BB-681 Task 2 — everything `bringUpTunnel`/`takeDownTunnel`/`reapTunnels`
83
+ * need: state + lock I/O only. Provider I/O (e.g. Cloudflare's REST API)
84
+ * lives inside the configured transport package instead. */
85
+ export declare const nodeTunnelOpsDeps: {
86
+ spawn: typeof import("child_process").spawn;
87
+ readFile(path: string): Promise<string>;
88
+ writeFile(path: string, data: string, mode: number): Promise<void>;
89
+ rename(from: string, to: string): Promise<void>;
90
+ mkdir(path: string): Promise<void>;
91
+ };
@@ -0,0 +1,234 @@
1
+ // BB-475 — the real seams behind discovery and transport, for the CLI.
2
+ //
3
+ // The I/O edge of the package: the `defaultSpawn` half of the conductor-host
4
+ // pattern. Everything here is substituted in tests; nothing here is unit-tested,
5
+ // exactly as `createConductorProcessHost`'s defaults are not — the seams exist so
6
+ // the LOGIC above them can be. `reap` and `discoverHostPorts` above carry the
7
+ // behaviour; this file only wires them to `child_process` and `/proc`.
8
+ import { execFile as execFileCallback } from 'node:child_process';
9
+ import { readFile, readlink, stat } from 'node:fs/promises';
10
+ import { promisify } from 'node:util';
11
+ import { nodeLockDeps } from './lock.js';
12
+ import { readTunnelState } from './state.js';
13
+ import { nodeTunnelStateDeps } from './tunnel-state.js';
14
+ // `execFile`, never `exec`: no shell, so a value that ends up in an argument —
15
+ // notably a tunnel `target` derived from a DISCOVERED service address — cannot
16
+ // be interpreted as a shell metacharacter. The argv array is the whole point.
17
+ const execFile = promisify(execFileCallback);
18
+ // G: a paused/hung container must not hang `list`/`reap` (which holds the
19
+ // machine lock) forever — `docker exec`/`inspect`/`ps` all get a bounded
20
+ // deadline; a timeout is reported as a failed probe (surfaced as a
21
+ // diagnostic upstream), never left to hang.
22
+ const EXEC_TIMEOUT_MS = 5_000;
23
+ /** The filesystem seams for the state file. */
24
+ export const nodeStateDeps = {
25
+ readFile: async (path) => {
26
+ try {
27
+ return await readFile(path, 'utf8');
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ },
33
+ };
34
+ /** The discovery seams: `ss` and `/proc/<pid>`. */
35
+ export const nodeDiscoveryDeps = {
36
+ run: async (cmd, args, cwd) => {
37
+ try {
38
+ const { stdout } = await execFile(cmd, args, {
39
+ maxBuffer: 8 * 1024 * 1024,
40
+ timeout: EXEC_TIMEOUT_MS,
41
+ ...(cwd ? { cwd } : {}),
42
+ });
43
+ return { stdout, ok: true };
44
+ }
45
+ catch (err) {
46
+ // A non-zero exit still carries stdout for a command like `ddev describe`
47
+ // outside a project; hand it back so the parser can decide. `code` is
48
+ // the numeric exit code when the process actually ran (e.g. 127 =
49
+ // command not found inside the container); a spawn-level failure
50
+ // carries a string errno instead, so only a number is surfaced.
51
+ // `stderr` is also threaded through (BB-681 review I3): without it,
52
+ // `isPermissionDenied126` can never fire against a REAL `docker exec`,
53
+ // so every exit 126 reads as a missing binary even when the container
54
+ // actually refused for a permission reason.
55
+ const withOut = err;
56
+ const exitCode = typeof withOut.code === 'number' ? withOut.code : undefined;
57
+ return {
58
+ stdout: withOut.stdout ?? '',
59
+ ok: false,
60
+ ...(exitCode !== undefined ? { exitCode } : {}),
61
+ ...(withOut.stderr ? { stderr: withOut.stderr } : {}),
62
+ };
63
+ }
64
+ },
65
+ processInfo: async (pid) => {
66
+ try {
67
+ const link = await readlink(`/proc/${pid}/cwd`);
68
+ // The kernel appends ` (deleted)` to the symlink TARGET when the working
69
+ // directory has been removed — the orphan signal (spec C3). `stat` on the
70
+ // magic link resolves the CURRENT directory, so it throws ENOENT for a
71
+ // reaped worktree; the two agree, and the stat is the reliable one.
72
+ const cmdlineRaw = await readFile(`/proc/${pid}/cmdline`, 'utf8').catch(() => '');
73
+ const cmdline = cmdlineRaw.replace(/\0/g, ' ').trim();
74
+ const deleted = link.endsWith(' (deleted)') ||
75
+ !(await stat(`/proc/${pid}/cwd`)
76
+ .then(() => true)
77
+ .catch(() => false));
78
+ return {
79
+ path: link.replace(/ \(deleted\)$/, ''),
80
+ deleted,
81
+ cmdline,
82
+ };
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ },
88
+ };
89
+ /**
90
+ * AC6 — `/proc/<pid>/stat`'s 22nd field (start time, in clock ticks since
91
+ * boot): a stronger PID-reuse guard than cmdline/deleted-cwd alone, which
92
+ * could coincidentally match a reused pid running a similar-looking process.
93
+ * Pure and exported so the field-counting (the 2nd field is `(comm)`, which
94
+ * may itself contain spaces/parens) is directly testable without `/proc`.
95
+ */
96
+ export function parseProcStatStartTime(stat) {
97
+ const afterComm = stat.slice(stat.lastIndexOf(')') + 2);
98
+ const fields = afterComm.split(' ');
99
+ // Field 3 is state (already consumed above as field 2's successor is index
100
+ // 0 here); starttime is the 22nd field overall, i.e. index 19 of this
101
+ // remainder (22 - 3 = 19, since fields 1-2 are `pid`/`comm`).
102
+ const startTime = Number(fields[19]);
103
+ return Number.isFinite(startTime) ? startTime : null;
104
+ }
105
+ /** `null` when the process is gone or the field cannot be parsed — no
106
+ * process to compare a start time against either way. */
107
+ export async function processStartTime(pid) {
108
+ try {
109
+ return parseProcStatStartTime(await readFile(`/proc/${pid}/stat`, 'utf8'));
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ /**
116
+ * BB-681 Task 4 — a process whose cmdline carries ANY pluggable-transport
117
+ * package's own ownership marker: `addon-ports-cloudflare-quick`'s private
118
+ * per-route `--config <BB_HOME>/ports/quick/<hash>/gen-<n>/config.yaml`,
119
+ * or a Named Cloudflare connector's `tunnel run` invocation. This generic
120
+ * legacy-reap scan predates per-route managed processes (BB-676) and its
121
+ * only signal for "legacy" is `cmdline.includes('--url')` (see
122
+ * `index.ts`'s reap handler) — which the new Quick package's OWN managed
123
+ * processes also match, since they use the same `--url` flag. Without this
124
+ * guard, generic reap would treat every quick-tunnel package process as an
125
+ * orphan it's free to kill; the quick package owns its OWN revalidated
126
+ * reap (`reconcileQuick`'s `desired: 'absent'` path) and this generic scan
127
+ * must never duplicate or race it.
128
+ */
129
+ export function isPluggableTransportManaged(cmdline) {
130
+ if (/\/ports\/quick\//.test(cmdline))
131
+ return true; // addon-ports-cloudflare-quick
132
+ if (/\btunnel\s+run\b/.test(cmdline))
133
+ return true; // Named Cloudflare connector
134
+ return false;
135
+ }
136
+ /**
137
+ * BB-676 Task 4 — scans the process table for `cloudflared`, keeping
138
+ * `cmdline` so a caller can tell a legacy `tunnel --url …` quick-tunnel
139
+ * (reap's cleanup target) apart from the machine's named `tunnel run`
140
+ * connector, which reap must never touch (plan review focus 4). BB-681
141
+ * Task 4: also excludes anything `isPluggableTransportManaged` recognizes —
142
+ * a pluggable transport's own managed process, never generic reap's to kill.
143
+ */
144
+ export async function listCloudflaredProcesses() {
145
+ const { stdout } = await execFile('ps', ['-eo', 'pid,comm'], {
146
+ timeout: EXEC_TIMEOUT_MS,
147
+ }).catch(() => ({
148
+ stdout: '',
149
+ }));
150
+ const processes = [];
151
+ for (const line of stdout.split('\n')) {
152
+ if (!/cloudflared/.test(line))
153
+ continue;
154
+ const pid = Number(line.trim().split(/\s+/)[0]);
155
+ if (!Number.isInteger(pid) || pid <= 0)
156
+ continue;
157
+ const info = await nodeDiscoveryDeps.processInfo(pid);
158
+ if (info && !isPluggableTransportManaged(info.cmdline))
159
+ processes.push({
160
+ pid,
161
+ cwd: info.path,
162
+ deleted: info.deleted,
163
+ cmdline: info.cmdline,
164
+ startTime: await processStartTime(pid),
165
+ });
166
+ }
167
+ return processes;
168
+ }
169
+ /**
170
+ * AC6 — read a worktree's OLD pre-BB-676 per-port quick-tunnel state
171
+ * (`.bakers/ports-tunnels.json`, written by the `transport.ts`/`state.ts` path
172
+ * this feature replaced) purely as migration evidence: a legacy-process
173
+ * candidate whose pid appears here is additional corroboration reap may use,
174
+ * never a replacement for the existing argv/owner/cwd checks. Never written
175
+ * or removed by this addon. Absent or malformed reads as "no evidence", not
176
+ * an error — a half-written or already-cleaned-up legacy file must not block
177
+ * reap.
178
+ */
179
+ export async function readLegacyTunnelPids(worktree) {
180
+ const state = await readTunnelState(worktree, nodeStateDeps);
181
+ return new Set(state.tunnels.map((t) => t.pid));
182
+ }
183
+ /**
184
+ * Is `pid` owned by this process's own user? Legacy reap must never kill
185
+ * another user's process (plan review focus 4) — even an own-user match is
186
+ * re-validated by the caller against fresh `processInfo` immediately before
187
+ * `SIGTERM`, to close the PID-reuse race between listing and killing.
188
+ */
189
+ export async function isOwnedByCurrentUser(pid) {
190
+ try {
191
+ const status = await readFile(`/proc/${pid}/status`, 'utf8');
192
+ const match = /^Uid:\s+(\d+)/m.exec(status);
193
+ return (match !== undefined &&
194
+ match !== null &&
195
+ Number(match[1]) === process.getuid?.());
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
201
+ /** BB-676 Task 4 — a bounded, non-redirecting Storybook probe over real `fetch`. */
202
+ export const nodeHttpProbe = async (url, opts) => {
203
+ const controller = new AbortController();
204
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
205
+ try {
206
+ const res = await fetch(url, {
207
+ signal: controller.signal,
208
+ redirect: 'manual',
209
+ });
210
+ if (res.type === 'opaqueredirect' ||
211
+ (res.status >= 300 && res.status < 400))
212
+ return null;
213
+ const body = await res.text();
214
+ return { ok: res.ok, status: res.status, body };
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ finally {
220
+ clearTimeout(timer);
221
+ }
222
+ };
223
+ /** Everything a command needs, bundled. */
224
+ export const nodeDeps = {
225
+ ...nodeStateDeps,
226
+ ...nodeDiscoveryDeps,
227
+ };
228
+ /** BB-681 Task 2 — everything `bringUpTunnel`/`takeDownTunnel`/`reapTunnels`
229
+ * need: state + lock I/O only. Provider I/O (e.g. Cloudflare's REST API)
230
+ * lives inside the configured transport package instead. */
231
+ export const nodeTunnelOpsDeps = {
232
+ ...nodeTunnelStateDeps,
233
+ ...nodeLockDeps,
234
+ };
@@ -0,0 +1,28 @@
1
+ import { parseSocketListeners, type RawDiscovery, type SocketListener } from '@bakers-build/addon-ddev';
2
+ export { parseSocketListeners, type SocketListener };
3
+ /** The injected seams for host-listener discovery. */
4
+ export interface HostDiscoveryDeps {
5
+ /** Run a command, capturing stdout; `ok` is false when it could not run. */
6
+ run(cmd: string, args: string[], cwd?: string): Promise<{
7
+ stdout: string;
8
+ ok: boolean;
9
+ }>;
10
+ /**
11
+ * A process's working directory and command line — `/proc/<pid>/cwd` and
12
+ * `/proc/<pid>/cmdline`. `deleted` is the `(deleted)` suffix `readlink`
13
+ * reports when the directory is gone (the orphan signal), and `cmdline` is
14
+ * what names the service: the socket table gives only `node`, and `node` is
15
+ * Storybook, Vite and a dozen other things.
16
+ */
17
+ processInfo(pid: number): Promise<{
18
+ path: string;
19
+ deleted: boolean;
20
+ cmdline: string;
21
+ } | null>;
22
+ }
23
+ /**
24
+ * Discover this worktree's host listeners (AC1): `ss -ltnpH`, dedupe PIDs
25
+ * before reading `/proc` (one read per pid), match owner against the
26
+ * worktree. `list` calls only this — no Docker, no Cloudflare, no mutation.
27
+ */
28
+ export declare function discoverHostPorts(worktree: string, deps: HostDiscoveryDeps): Promise<RawDiscovery>;
@@ -0,0 +1,100 @@
1
+ // BB-676 spec v2 — host-listener discovery only. Docker/DDEV discovery
2
+ // moved to `@bakers-build/addon-docker-compose`/`@bakers-build/addon-ddev` (operator
3
+ // decision 2026-10-15: `bakers ports` must not fire `docker` itself). This file
4
+ // now does exactly one thing: `ss -ltnpH` plus `/proc/<pid>` — no external
5
+ // binary beyond `ss`, no root.
6
+ //
7
+ // The parsers are pure and exported so a test drives them with captured
8
+ // output; the orchestrator takes an injected `run`/`processInfo` so no real
9
+ // socket is ever read in a test.
10
+ import { parseSocketListeners, } from '@bakers-build/addon-ddev';
11
+ // BB-676 pass-6 review cleanup — `parseSocketListeners`/`SocketListener`
12
+ // used to be reimplemented here byte-for-byte; `@bakers-build/addon-docker-compose`
13
+ // is the one home now (the lower-level package), re-exported through
14
+ // `@bakers-build/addon-ddev` because this addon may not edge to
15
+ // `@bakers-build/addon-docker-compose` directly (`scripts/assert-acyclic.mjs`).
16
+ export { parseSocketListeners };
17
+ /** Is `path` inside `worktree` (or the worktree itself)? */
18
+ function underWorktree(path, worktree) {
19
+ const root = worktree.replace(/\/+$/, '');
20
+ return path === root || path.startsWith(`${root}/`);
21
+ }
22
+ /**
23
+ * Discover this worktree's host listeners (AC1): `ss -ltnpH`, dedupe PIDs
24
+ * before reading `/proc` (one read per pid), match owner against the
25
+ * worktree. `list` calls only this — no Docker, no Cloudflare, no mutation.
26
+ */
27
+ export async function discoverHostPorts(worktree, deps) {
28
+ const diagnostics = [];
29
+ const ports = [];
30
+ // C3: a listener is only THIS worktree's observation when its owning
31
+ // process's cwd resolves under it. A foreign worktree's listener (another
32
+ // ticket, the main checkout) is not included at all — not even as an
33
+ // orphan, because it was never ours. A pid-less row (no permission to see
34
+ // the owner) cannot be attributed to any worktree, so it is also excluded
35
+ // here, with a diagnostic recording that evidence was incomplete.
36
+ // macOS compatibility: `ss`/`/proc` are Linux-only (ticket scope: "Linux
37
+ // discovery") — there is no BSD/macOS equivalent attempted here (lsof/
38
+ // netstat parity is out of scope). Rather than let a raw `ENOENT` surface
39
+ // as "ss -ltnpH failed" with no further context, name the real cause when
40
+ // we know it, so this reads as an honest platform limitation rather than a
41
+ // mysterious probe failure. Docker/DDEV-routed services are unaffected —
42
+ // the Linux VM behind Docker Desktop still answers these probes fine
43
+ // in-container; only THIS host-process scan is skipped.
44
+ const host = await deps.run('ss', ['-ltnpH']);
45
+ if (!host.ok) {
46
+ diagnostics.push({
47
+ source: 'host',
48
+ wholePass: true,
49
+ message: process.platform !== 'linux'
50
+ ? `host-process discovery requires Linux (\`ss\`/\`/proc\`), not available on ${process.platform}; container-routed services are still discoverable`
51
+ : '`ss -ltnpH` failed',
52
+ });
53
+ return { ports, diagnostics };
54
+ }
55
+ const listeners = parseSocketListeners(host.stdout);
56
+ const pids = [
57
+ ...new Set(listeners
58
+ .map((l) => l.pid)
59
+ .filter((pid) => pid !== undefined)),
60
+ ];
61
+ const infoByPid = new Map();
62
+ await Promise.all(pids.map(async (pid) => infoByPid.set(pid, await deps.processInfo(pid))));
63
+ for (const listener of listeners) {
64
+ if (listener.pid === undefined) {
65
+ diagnostics.push({
66
+ source: 'host',
67
+ message: `listening port ${listener.port} has no visible owner (permission?); evidence incomplete`,
68
+ });
69
+ continue;
70
+ }
71
+ const info = infoByPid.get(listener.pid) ?? null;
72
+ // cloudflared is the transport, never a service to tunnel (BB-500).
73
+ if (info?.cmdline.toLowerCase().includes('cloudflared'))
74
+ continue;
75
+ if (!info) {
76
+ diagnostics.push({
77
+ source: 'host',
78
+ message: `pid ${listener.pid} on port ${listener.port} vanished before its cwd could be read`,
79
+ });
80
+ continue;
81
+ }
82
+ // A deleted cwd under this worktree is a genuine orphan (kept, no rule);
83
+ // a deleted or live cwd under a DIFFERENT worktree is simply not this
84
+ // worktree's listener.
85
+ if (!underWorktree(info.path, worktree))
86
+ continue;
87
+ ports.push({
88
+ worktree,
89
+ hostAddress: listener.address,
90
+ hostPort: listener.port,
91
+ pid: listener.pid,
92
+ cmdline: info.cmdline,
93
+ cwd: info.path,
94
+ cwdDeleted: info.deleted,
95
+ listening: true,
96
+ exposed: 'unknown',
97
+ });
98
+ }
99
+ return { ports, diagnostics };
100
+ }
@@ -0,0 +1,35 @@
1
+ import type { InboxMessageHandler } from '@bakers-build/conductor/contract';
2
+ import type { ClaimedDiscovery, ClaimedService } from './claims.js';
3
+ import { type OperationContext, type PortsTransport, type TunnelDesiredRecord } from './core.js';
4
+ /** The I/O the handler needs, injected so a test drives it with fixtures.
5
+ * `loadTransport` is the SAME shared loader the CLI uses (`config.ts`'s
6
+ * `loadPortsTransport`) — one code path for both, so settings validation
7
+ * cannot be bypassed by going through the inbox instead of the CLI. A
8
+ * missing/invalid/unresolvable transport resolves to `null`: the message
9
+ * answers a stable "no ports configuration" line rather than throwing a
10
+ * raw stack trace into the ticket's addon card. */
11
+ export interface TunnelHandlerDeps {
12
+ discover(worktree: string, ticket: string, zone: string): Promise<ClaimedDiscovery>;
13
+ readRecords(worktree: string): Promise<readonly TunnelDesiredRecord[]>;
14
+ bringUp(service: ClaimedService, ctx: OperationContext): Promise<{
15
+ ok: boolean;
16
+ message: string;
17
+ }>;
18
+ /** Same shared loader the CLI uses, surfaced with its own redacted
19
+ * diagnostic on failure — BB-681 review I5: a missing config and an
20
+ * unresolvable/invalid transport package are different problems and must
21
+ * not collapse onto one generic "no configuration" line here while the
22
+ * CLI reports the real one. */
23
+ loadTransport(): Promise<{
24
+ readonly ok: true;
25
+ readonly transport: PortsTransport;
26
+ } | {
27
+ readonly ok: false;
28
+ readonly message: string;
29
+ }>;
30
+ home(): string;
31
+ }
32
+ export declare function tunnelHandlers(deps: TunnelHandlerDeps): Record<string, InboxMessageHandler>;
33
+ /** The live handlers for the conductor's effective ports entry `with`
34
+ * (`options`, machine entry wins) — the conductor passes it per dispatch. */
35
+ export declare function handlersFor(options: unknown): Record<string, InboxMessageHandler>;
@@ -0,0 +1,91 @@
1
+ // BB-676 Task 4 — the ports addon's contract-2 handler (BB-661): a single
2
+ // `tunnel` message claims every eligible service of the worktree and shares
3
+ // each one not yet applied, then answers with one Markdown list.
4
+ //
5
+ // Only a claim with `rules.length > 0` is ever shared — an unknown, dead,
6
+ // stopped or not-exposed service never reaches `bringUp` (spec "Addon
7
+ // claims": "No unclaimed tunnels"). Liveness is read from the machine's
8
+ // APPLIED state, never a per-route PID — a route is up because its record
9
+ // says so, not because some process on this host answers to a signal.
10
+ import { realpathSync } from 'node:fs';
11
+ import { homeBbDir } from '@bakers-build/core';
12
+ import { servicesToShare, shareAll } from './claim-share.js';
13
+ import { discoverClaimedServices } from './claims.js';
14
+ import { assertNoMachinePorts, bringUpTunnel, loadPortsTransport, readMachineTunnelRecords, withProblems, } from './core.js';
15
+ import { nodeDeps, nodeHttpProbe, nodeTunnelOpsDeps } from './deps-node.js';
16
+ import { serviceRules } from './service-config.js';
17
+ import { tunnelMarkdown } from './tunnel-result.js';
18
+ /** The worktree as the CLI sees it (`process.cwd()` is already real): DDEV
19
+ * labels containers with the real path and discovery matches it exactly, so a
20
+ * symlinked path from the server would find nothing (BB-693). */
21
+ function canonicalPath(path) {
22
+ try {
23
+ return realpathSync(path);
24
+ }
25
+ catch {
26
+ return path;
27
+ }
28
+ }
29
+ export function tunnelHandlers(deps) {
30
+ return {
31
+ tunnel: async (request) => {
32
+ const loaded = await deps.loadTransport();
33
+ if (!loaded.ok) {
34
+ return { markdown: loaded.message };
35
+ }
36
+ const transport = loaded.transport;
37
+ const ticket = request.ticket.identifier;
38
+ const worktree = canonicalPath(request.worktreePath);
39
+ const discovery = await deps.discover(worktree, ticket, transport.zone ?? '');
40
+ const ctx = {
41
+ worktree,
42
+ ticket,
43
+ home: deps.home(),
44
+ transport,
45
+ signal: request.signal,
46
+ };
47
+ const outcomes = await shareAll(servicesToShare(discovery), (s) => deps.bringUp(s, ctx));
48
+ const failures = outcomes
49
+ .filter(({ result }) => !result.ok)
50
+ .map(({ service, result }) => ({ service, message: result.message }));
51
+ const after = await deps.readRecords(worktree);
52
+ return { markdown: tunnelMarkdown(discovery, after, failures) };
53
+ },
54
+ };
55
+ }
56
+ /** The live handlers for the conductor's effective ports entry `with`
57
+ * (`options`, machine entry wins) — the conductor passes it per dispatch. */
58
+ export function handlersFor(options) {
59
+ return tunnelHandlers({
60
+ discover: (worktree, ticket, zone) => discoverClaimedServices(worktree, nodeDeps, {
61
+ ticket,
62
+ zone,
63
+ probe: nodeHttpProbe,
64
+ rules: serviceRules(options),
65
+ }),
66
+ readRecords: (worktree) => readMachineTunnelRecords(worktree, homeBbDir(), nodeTunnelOpsDeps),
67
+ bringUp: (service, ctx) => bringUpTunnel(service.rules[0], ctx, nodeTunnelOpsDeps),
68
+ loadTransport: async () => {
69
+ // The conductor hands the merged `with`; a bad setting answers here
70
+ // instead of a silently empty claim list.
71
+ const [problem] = withProblems(options);
72
+ if (problem)
73
+ return { ok: false, message: problem };
74
+ try {
75
+ await assertNoMachinePorts();
76
+ const transport = await loadPortsTransport(options, homeBbDir());
77
+ return { ok: true, transport };
78
+ }
79
+ catch (err) {
80
+ // The loader already produces a bounded, redacted diagnostic (missing
81
+ // entry, unresolvable package, rejected settings) — the SAME message
82
+ // the CLI prints, never a raw stack trace into the ticket's addon card.
83
+ return {
84
+ ok: false,
85
+ message: err instanceof Error ? err.message : String(err),
86
+ };
87
+ }
88
+ },
89
+ home: () => homeBbDir(),
90
+ });
91
+ }