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

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-ports
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bakers.build ports command plugin: discover a ticket worktree's running services (DDEV, Designbook, ad-hoc listeners) and bring shareable tunnels up and down. The command half of BB-475; usable without the cockpit half.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,33 @@
1
+ import type { ClaimedDiscovery, ClaimedService } from './claims.js';
2
+ /** Eligible = a healthy (or prepared-Drupal-passthrough) claim with a rule. */
3
+ export declare function eligible(service: ClaimedService): boolean;
4
+ /** Every eligible service of `discovery` is shared on every "bring up
5
+ * everything" pass — BB-681 review C4: an already-`applied` record is
6
+ * never short-circuited here either, because the SPEC requires every
7
+ * up/down/reap to reconcile the complete desired set (a dead Quick process
8
+ * or a lost frp registration must recover on a repeated `up`, not only when
9
+ * the recorded fields disagree with the freshly observed rule).
10
+ * `bringUp`/`reconcileTunnels` own the actual idempotence (a live, matching
11
+ * route is a cheap no-op there); this is the pure selection half of "bring
12
+ * up everything" (spec v5's `bakers ports up` with no key, and the `tunnel`
13
+ * inbox message), kept separate from the I/O loop so a test drives it
14
+ * without a real `bringUp`. BB-681 review over-engineering pass: no
15
+ * per-record state check is needed any more — every call site already has
16
+ * no further reason to pass `records` here, so it was dropped. */
17
+ export declare function servicesToShare(discovery: ClaimedDiscovery): ClaimedService[];
18
+ /** One `bringUp` outcome, paired with the service it was for. */
19
+ export interface ShareOutcome<R> {
20
+ readonly service: ClaimedService;
21
+ readonly result: R;
22
+ }
23
+ /**
24
+ * BB-676 pass-6 review cleanup — the "bring up everything" I/O loop, shared
25
+ * between `bakers ports up` (no key) and the `tunnel` inbox message so the two
26
+ * "share everything" entry points cannot drift apart: one call per selected
27
+ * service, in order, keeping going past a single failure, every outcome kept
28
+ * for the caller to report its own way (stdout lines vs. a Markdown list).
29
+ */
30
+ export declare function shareAll<R extends {
31
+ ok: boolean;
32
+ message: string;
33
+ }>(services: readonly ClaimedService[], bringUp: (service: ClaimedService) => Promise<R>): Promise<ShareOutcome<R>[]>;
@@ -0,0 +1,38 @@
1
+ // BB-676 spec v5 — shared "bring up everything eligible" predicate AND I/O
2
+ // loop (`shareAll`, pass-6 review cleanup), used by both the `tunnel` inbox
3
+ // message and `bakers ports up` with no service key, so the two "share
4
+ // everything" entry points cannot drift apart.
5
+ /** Eligible = a healthy (or prepared-Drupal-passthrough) claim with a rule. */
6
+ export function eligible(service) {
7
+ return service.rules.length > 0;
8
+ }
9
+ /** Every eligible service of `discovery` is shared on every "bring up
10
+ * everything" pass — BB-681 review C4: an already-`applied` record is
11
+ * never short-circuited here either, because the SPEC requires every
12
+ * up/down/reap to reconcile the complete desired set (a dead Quick process
13
+ * or a lost frp registration must recover on a repeated `up`, not only when
14
+ * the recorded fields disagree with the freshly observed rule).
15
+ * `bringUp`/`reconcileTunnels` own the actual idempotence (a live, matching
16
+ * route is a cheap no-op there); this is the pure selection half of "bring
17
+ * up everything" (spec v5's `bakers ports up` with no key, and the `tunnel`
18
+ * inbox message), kept separate from the I/O loop so a test drives it
19
+ * without a real `bringUp`. BB-681 review over-engineering pass: no
20
+ * per-record state check is needed any more — every call site already has
21
+ * no further reason to pass `records` here, so it was dropped. */
22
+ export function servicesToShare(discovery) {
23
+ return discovery.services.filter(eligible);
24
+ }
25
+ /**
26
+ * BB-676 pass-6 review cleanup — the "bring up everything" I/O loop, shared
27
+ * between `bakers ports up` (no key) and the `tunnel` inbox message so the two
28
+ * "share everything" entry points cannot drift apart: one call per selected
29
+ * service, in order, keeping going past a single failure, every outcome kept
30
+ * for the caller to report its own way (stdout lines vs. a Markdown list).
31
+ */
32
+ export async function shareAll(services, bringUp) {
33
+ const outcomes = [];
34
+ for (const service of services) {
35
+ outcomes.push({ service, result: await bringUp(service) });
36
+ }
37
+ return outcomes;
38
+ }
@@ -0,0 +1,39 @@
1
+ import type { ClaimContext, ClaimedDiscovery, HttpProbe, RawDiscovery } from '@bakers-build/addon-ddev';
2
+ import { type HostDiscoveryDeps } from './discovery.js';
3
+ import { type ServiceRules } from './service-config.js';
4
+ export type { ClaimContext, ClaimedDiscovery, ClaimedService, ClaimStatus, HttpProbe, TunnelRule, } from '@bakers-build/addon-ddev';
5
+ /**
6
+ * `https://<lowercase-ticket>-<key>.<zone>` — pure, no I/O (spec: naming stays
7
+ * a pure exported function). Throws on an invalid ticket, key, zone or a
8
+ * combined label over 63 bytes; never truncates.
9
+ */
10
+ export declare function publicHostname(ticket: string, key: string, zone: string): string;
11
+ /**
12
+ * Inspector ports a command line names: `--inspect`, `--inspect-brk`,
13
+ * `--inspect-wait` or `--inspect-port`, as `=port`, `=host:port` or a separate
14
+ * argument; a bare `--inspect*` means the default 9229. Pure, for tests.
15
+ */
16
+ export declare function inspectorPorts(cmdline: string): number[];
17
+ /** Run claimers in precedence order over one raw discovery pass, specific
18
+ * before broad: configured process entries (with the default `storybook`),
19
+ * DDEV (generic `web`/`web-<host>` naming), then the opt-in all-Node rule.
20
+ * DDEV's keys never change: a process claim on a key DDEV produced becomes
21
+ * `<key>-<port>`. */
22
+ export declare function claimServices(raw: RawDiscovery, ctx: ClaimContext, rules: ServiceRules): Promise<ClaimedDiscovery>;
23
+ /** `.ddev/config.yaml`'s own raw-text reader — kept distinct from
24
+ * `HostDiscoveryDeps` since it is plain file I/O, not a command exec. */
25
+ export interface ConfigFileDeps {
26
+ /** `null` for ENOENT; throwing is reserved for a real I/O failure. */
27
+ readFile(path: string): Promise<string | null>;
28
+ }
29
+ /** Compose raw lookup and claims — the `list`/`up` entry point.
30
+ * Host-listener discovery (process-rule candidates) stays local; DDEV's own
31
+ * container discovery AND effective-host discovery (`ddev utility
32
+ * configyaml`) are delegated to `@bakers-build/addon-ddev`, never read/called
33
+ * directly here. */
34
+ export declare function discoverClaimedServices(worktree: string, deps: HostDiscoveryDeps & ConfigFileDeps, opts: {
35
+ readonly ticket: string;
36
+ readonly zone: string;
37
+ readonly probe: HttpProbe;
38
+ readonly rules: ServiceRules;
39
+ }): Promise<ClaimedDiscovery>;
@@ -0,0 +1,286 @@
1
+ // BB-676 Task 2 (spec v2 split) — claim raw observations into named,
2
+ // tunnel-ready services. A claimer consumes raw IDENTITIES (array indices
3
+ // into one `RawDiscovery` pass), never a global port number: two containers
4
+ // or two site rules can share a port, so "port already seen" would silently
5
+ // merge them. Claimers run in precedence order (configured processes, DDEV,
6
+ // then the opt-in all-Node rule — specific before broad); each receives only
7
+ // what the previous claimer left in `remaining`.
8
+ //
9
+ // This package owns naming/reconciliation and the process claimers (host
10
+ // processes matched by the ports addon's `services` settings, no Docker
11
+ // call). Which processes are tunnelled is configuration, not code here; an
12
+ // unmatched listener stays unclaimed — still no numeric-port fallback. DDEV's own discovery/claim logic — everything
13
+ // that fires `docker` or interprets `HTTP(S)_EXPOSE`/`VIRTUAL_HOST`, and the
14
+ // neutral `{key, service, hostHeader?, insecureTls?}` rules it emits (BB-681:
15
+ // no CMS-specific addon or policy layer over its output any more) — lives in
16
+ // `@bakers-build/addon-ddev` (operator decision 2026-10-15: ports must not fire
17
+ // Docker calls itself). `claimServices` below runs the process rules, then
18
+ // DDEV, then all-Node, over one merged raw-discovery pass.
19
+ import { claimDdev, discoverDdevHosts, discoverDdevPorts, parseWebExtraExposedPorts, } from '@bakers-build/addon-ddev';
20
+ import { discoverHostPorts } from './discovery.js';
21
+ import { DEFAULT_STORYBOOK, LABEL_RE, } from './service-config.js';
22
+ const TICKET_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*-\d+$/;
23
+ /**
24
+ * `https://<lowercase-ticket>-<key>.<zone>` — pure, no I/O (spec: naming stays
25
+ * a pure exported function). Throws on an invalid ticket, key, zone or a
26
+ * combined label over 63 bytes; never truncates.
27
+ */
28
+ export function publicHostname(ticket, key, zone) {
29
+ if (!zone || /[:/*]/.test(zone) || zone.includes('://')) {
30
+ throw new Error(`invalid zone (must be a bare hostname): ${zone}`);
31
+ }
32
+ const t = ticket.toLowerCase();
33
+ if (!TICKET_RE.test(t)) {
34
+ throw new Error(`invalid ticket identifier (expected project-key-number): ${ticket}`);
35
+ }
36
+ const k = key.toLowerCase();
37
+ if (!LABEL_RE.test(k)) {
38
+ throw new Error(`invalid service key (must be a DNS-safe label): ${key}`);
39
+ }
40
+ const label = `${t}-${k}`;
41
+ if (Buffer.byteLength(label, 'utf8') > 63) {
42
+ throw new Error(`combined label exceeds 63 bytes: ${label}`);
43
+ }
44
+ return `${label}.${zone}`;
45
+ }
46
+ /** The Node default inspector port. */
47
+ const INSPECTOR_DEFAULT_PORT = 9229;
48
+ const INSPECT_FLAG_RE = /^--inspect(-brk|-wait|-port)?(?:=(.*))?$/;
49
+ const PORT_VALUE_RE = /^(?:.*:)?(\d+)$/;
50
+ /**
51
+ * Inspector ports a command line names: `--inspect`, `--inspect-brk`,
52
+ * `--inspect-wait` or `--inspect-port`, as `=port`, `=host:port` or a separate
53
+ * argument; a bare `--inspect*` means the default 9229. Pure, for tests.
54
+ */
55
+ export function inspectorPorts(cmdline) {
56
+ const tokens = cmdline.split(/\s+/);
57
+ const ports = [];
58
+ tokens.forEach((token, i) => {
59
+ const flag = INSPECT_FLAG_RE.exec(token);
60
+ if (!flag)
61
+ return;
62
+ const value = flag[2] ?? tokens[i + 1] ?? '';
63
+ const port = PORT_VALUE_RE.exec(value)?.[1];
64
+ if (port !== undefined)
65
+ ports.push(Number(port));
66
+ else if (flag[2] === undefined && flag[1] !== '-port')
67
+ ports.push(INSPECTOR_DEFAULT_PORT);
68
+ });
69
+ return ports;
70
+ }
71
+ /** Where a host listener answers HTTP: loopback for a loopback or wildcard
72
+ * bind, else the bound address. */
73
+ function probeOrigin(p) {
74
+ const a = p.hostAddress ?? '';
75
+ if (a.includes(':'))
76
+ return a === '::' || a === '::1'
77
+ ? `[::1]:${p.hostPort}`
78
+ : `[${a}]:${p.hostPort}`;
79
+ const loopback = a === '' ||
80
+ a === '*' ||
81
+ a === '0.0.0.0' ||
82
+ a === 'localhost' ||
83
+ a.startsWith('127.');
84
+ return `${loopback ? '127.0.0.1' : a}:${p.hostPort}`;
85
+ }
86
+ /** Probe `/json/version`: `'inspector'` for a Node inspector, `null` for no
87
+ * HTTP answer at all, else `'http'`. */
88
+ async function probeInspector(p, probe) {
89
+ let result;
90
+ try {
91
+ result = await probe(`http://${probeOrigin(p)}/json/version`, {
92
+ timeoutMs: 300,
93
+ });
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ if (!result)
99
+ return null;
100
+ try {
101
+ const browser = JSON.parse(result.body).Browser;
102
+ if (typeof browser === 'string' && browser.startsWith('node.js/'))
103
+ return 'inspector';
104
+ }
105
+ catch {
106
+ // Not JSON: an ordinary HTTP server.
107
+ }
108
+ return 'http';
109
+ }
110
+ /** A loopback `/index.json` answering as a Storybook v5 static build. */
111
+ async function isStaticStorybook(p, probe) {
112
+ let result;
113
+ try {
114
+ result = await probe(`http://127.0.0.1:${p.hostPort}/index.json`, {
115
+ timeoutMs: 300,
116
+ });
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ if (!result || !result.ok)
122
+ return false;
123
+ try {
124
+ const b = JSON.parse(result.body);
125
+ return b.v === 5 && !!b.entries && typeof b.entries === 'object';
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ function processClaim(key, claim, p) {
132
+ return {
133
+ key,
134
+ claim,
135
+ status: 'healthy',
136
+ local: `http://127.0.0.1:${p.hostPort}`,
137
+ rawPorts: [p],
138
+ rules: [
139
+ {
140
+ key,
141
+ service: `http://127.0.0.1:${p.hostPort}`,
142
+ // Dev servers (Vite, VitePress, Storybook) reject a foreign Host.
143
+ hostHeader: `localhost:${p.hostPort}`,
144
+ },
145
+ ],
146
+ };
147
+ }
148
+ /**
149
+ * The process claimers over one pass: an inspector exclusion, then each
150
+ * configured entry in order (a match on the command line; the entry keyed
151
+ * `storybook` also claims a loopback static build by its `/index.json`).
152
+ * Further matches of one entry get `<key>-<port>`. Only worktree-owned host
153
+ * listeners are considered. Each listener a claimer would take is first
154
+ * checked for a Node inspector (port 9229, an `--inspect*` flag of its own
155
+ * process, or a `/json/version` probe) and stays unclaimed when it is one.
156
+ */
157
+ async function claimProcesses(raw, remaining, ctx, rules) {
158
+ const hosts = [...remaining].filter((i) => raw[i].hostPort !== undefined && raw[i].containerId === undefined);
159
+ // Inspectors are never claimed but stay listed as unclaimed.
160
+ const blocked = new Set(hosts.filter((i) => {
161
+ const p = raw[i];
162
+ return (p.hostPort === INSPECTOR_DEFAULT_PORT ||
163
+ inspectorPorts(p.cmdline ?? '').includes(p.hostPort));
164
+ }));
165
+ const probed = new Map();
166
+ const answer = (i) => {
167
+ let r = probed.get(i);
168
+ if (!r) {
169
+ r = probeInspector(raw[i], ctx.probe);
170
+ probed.set(i, r);
171
+ }
172
+ return r;
173
+ };
174
+ const free = () => hosts.filter((i) => remaining.has(i) && !blocked.has(i));
175
+ // The listeners of `matched` the probe verdict lets through, by port.
176
+ const take = async (matched, keep) => {
177
+ const verdicts = await Promise.all(matched.map(answer));
178
+ const ok = matched.filter((_, n) => keep(verdicts[n]));
179
+ matched.forEach((i, n) => {
180
+ if (verdicts[n] === 'inspector')
181
+ blocked.add(i);
182
+ });
183
+ return ok.sort((a, b) => raw[a].hostPort - raw[b].hostPort);
184
+ };
185
+ const services = [];
186
+ for (const rule of rules.entries) {
187
+ const needle = rule.match.toLowerCase();
188
+ const candidates = free();
189
+ const matched = candidates.filter((i) => raw[i].cmdline?.toLowerCase().includes(needle));
190
+ if (rule.key === DEFAULT_STORYBOOK.key) {
191
+ const statics = await Promise.all(candidates
192
+ .filter((i) => !matched.includes(i) &&
193
+ (raw[i].hostAddress === '127.0.0.1' ||
194
+ raw[i].hostAddress === 'localhost'))
195
+ .map(async (i) => (await isStaticStorybook(raw[i], ctx.probe)) ? i : undefined));
196
+ for (const i of statics)
197
+ if (i !== undefined)
198
+ matched.push(i);
199
+ }
200
+ // Explicit entries claim without a probe answer, like Storybook always did.
201
+ const ok = await take(matched, (r) => r !== 'inspector');
202
+ ok.forEach((i, idx) => {
203
+ remaining.delete(i);
204
+ const p = raw[i];
205
+ services.push(processClaim(idx === 0 ? rule.key : `${rule.key}-${p.hostPort}`, 'process', p));
206
+ });
207
+ }
208
+ const allNode = async () => {
209
+ if (!rules.allNode)
210
+ return [];
211
+ const node = free().filter((i) => {
212
+ const exe = (raw[i].cmdline ?? '').split(/\s+/)[0].split('/').pop();
213
+ return exe === 'node' || exe === 'nodejs';
214
+ });
215
+ // Fail closed: no HTTP answer at all is not claimed by the broad rule.
216
+ const ok = await take(node, (r) => r === 'http');
217
+ return ok.map((i) => {
218
+ remaining.delete(i);
219
+ const p = raw[i];
220
+ return processClaim(`node-${p.hostPort}`, 'node', p);
221
+ });
222
+ };
223
+ return { services, allNode };
224
+ }
225
+ /** Run claimers in precedence order over one raw discovery pass, specific
226
+ * before broad: configured process entries (with the default `storybook`),
227
+ * DDEV (generic `web`/`web-<host>` naming), then the opt-in all-Node rule.
228
+ * DDEV's keys never change: a process claim on a key DDEV produced becomes
229
+ * `<key>-<port>`. */
230
+ export async function claimServices(raw, ctx, rules) {
231
+ const remaining = new Set(raw.ports.map((_, i) => i));
232
+ const processes = await claimProcesses(raw.ports, remaining, ctx, rules);
233
+ const ddev = claimDdev(raw.ports, remaining, ctx);
234
+ const nodes = await processes.allNode();
235
+ const ddevKeys = new Set(ddev.map((s) => s.key));
236
+ const renamed = [...processes.services, ...nodes].map((s) => {
237
+ if (!ddevKeys.has(s.key))
238
+ return s;
239
+ const key = `${s.key}-${s.rawPorts[0].hostPort}`;
240
+ return { ...s, key, rules: s.rules.map((r) => ({ ...r, key })) };
241
+ });
242
+ const unclaimed = [...remaining]
243
+ .sort((a, b) => a - b)
244
+ .map((i) => raw.ports[i]);
245
+ return {
246
+ services: [...renamed, ...ddev],
247
+ diagnostics: raw.diagnostics,
248
+ unclaimed,
249
+ };
250
+ }
251
+ /** Compose raw lookup and claims — the `list`/`up` entry point.
252
+ * Host-listener discovery (process-rule candidates) stays local; DDEV's own
253
+ * container discovery AND effective-host discovery (`ddev utility
254
+ * configyaml`) are delegated to `@bakers-build/addon-ddev`, never read/called
255
+ * directly here. */
256
+ export async function discoverClaimedServices(worktree, deps, opts) {
257
+ const [host, ddev, hostsResult, ddevConfigYaml] = await Promise.all([
258
+ discoverHostPorts(worktree, deps),
259
+ discoverDdevPorts(worktree, deps),
260
+ discoverDdevHosts(worktree, deps),
261
+ deps.readFile(`${worktree.replace(/\/+$/, '')}/.ddev/config.yaml`),
262
+ ]);
263
+ // BB-681 review I1: a failed `ddev utility configyaml` read must leave a
264
+ // diagnostic, not silently drop every `web-<host>` alias claim — without
265
+ // one, `shouldKeepTunnelRecord` sees no evidence at all for a vanished
266
+ // alias record and `reap` deletes a route that may still be perfectly
267
+ // live, only unobserved this pass.
268
+ const raw = {
269
+ ports: [...host.ports, ...ddev.ports],
270
+ diagnostics: [
271
+ ...host.diagnostics,
272
+ ...ddev.diagnostics,
273
+ ...(hostsResult.ok
274
+ ? []
275
+ : [{ source: 'ddev-hosts', message: hostsResult.reason }]),
276
+ ],
277
+ };
278
+ return claimServices(raw, {
279
+ worktree,
280
+ ticket: opts.ticket,
281
+ zone: opts.zone,
282
+ probe: opts.probe,
283
+ webExtraExposedPortNames: parseWebExtraExposedPorts(ddevConfigYaml),
284
+ ...(hostsResult.ok ? { hosts: hostsResult.hosts } : {}),
285
+ }, opts.rules);
286
+ }
@@ -0,0 +1,40 @@
1
+ import { type PortsTransport } from './transport.js';
2
+ /** The ports addon entry's `with`, transport-agnostic: `transport.use`
3
+ * names the package to load; `transport.with` is whatever settings that
4
+ * package's own factory understands. This loader validates only this
5
+ * generic shape — never a specific transport's own field names. */
6
+ export interface PortsMachineConfig {
7
+ readonly transport: {
8
+ readonly use: string;
9
+ readonly with?: unknown;
10
+ };
11
+ }
12
+ /** No ports addon entry (or one without a transport) in either scope. */
13
+ export declare const PORTS_NEEDS_ENTRY = "bakers ports needs '@bakers-build/addon-ports' in addons[] of ~/.bakers/machine.config.js or .bakers/conductor.config.js, with with.transport.use naming a transport package; see docs/addons/ports";
14
+ /** The retired top-level machine `ports` key. */
15
+ export declare const PORTS_RETIRED = "machine ports is retired: move it to addons: [{ use: '@bakers-build/addon-ports', with: { transport: \u2026 } }]";
16
+ /** Throw the remediation when the machine config still carries `ports`. */
17
+ export declare function assertNoMachinePorts(): Promise<void>;
18
+ /** `addons[@bakers-build/addon-ports].with.<key> <message>` per finding. */
19
+ export declare function withProblems(value: unknown): string[];
20
+ /** `portsSettings` plus the retired machine `ports` check — the transport
21
+ * path; discovery (`list`) reads settings without it. */
22
+ export declare function portsSettings(worktree: string): Promise<unknown>;
23
+ /**
24
+ * The effective `@bakers-build/addon-ports` entry's `with` for `worktree`, merged
25
+ * by the preset's `mergeWith` (the machine wins). Undefined when neither
26
+ * scope lists the addon. Throws for a malformed `with` in either scope.
27
+ */
28
+ export declare function readPortsSettings(worktree: string): Promise<unknown>;
29
+ /**
30
+ * Resolve, load and construct the configured ports transport from the ports
31
+ * addon entry's `with` (`settings`).
32
+ * Resolution bases, in order: `home` (a project-local transport package),
33
+ * the running `bakers` host's own install (realpath'd, so the three built-in
34
+ * provider packages resolve through a symlinked global install), then
35
+ * `process.cwd()`.
36
+ * Every failure — missing config, unresolvable package, no default export,
37
+ * a factory that rejects its settings, or a malformed `PortsTransport`
38
+ * result — throws a safe diagnostic, never a raw crash.
39
+ */
40
+ export declare function loadPortsTransport(settings: unknown, home: string): Promise<PortsTransport>;
@@ -0,0 +1,158 @@
1
+ // BB-681 Task 2 — resolve the machine's configured ports transport package
2
+ // by name, reusing the engine's own ESLint-style module resolver
3
+ // (`resolveModuleEslintStyle`, hoisted from the conductor's plugin loader)
4
+ // rather than inventing a second one.
5
+ import { realpathSync } from 'node:fs';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { machineUiConfigPath, projectConductorConfigPath, readConfigAddons, readEffectiveAddon, readMachineContext, resolveModuleEslintStyle, } from '@bakers-build/core';
8
+ import { checkWith } from './service-config.js';
9
+ import { assertPortsTransport, InvalidTransportModuleError, } from './transport.js';
10
+ /**
11
+ * Resolution bases rooted at the running `bakers` HOST install, usable from
12
+ * both the CLI (which gets a `BbCommandHost`) and the inbox handler (which
13
+ * does not: `oven start`/`once` dispatches inbox messages from inside
14
+ * the SAME long-running `bakers` process, never a subprocess). The three
15
+ * built-in transport packages are declared only on `bakers-cli/host/package.json`
16
+ * (never on `@bakers-build/addon-ports`, which would cycle back through
17
+ * `addon-ports/transport` — see the spec's "no core dependency on provider
18
+ * implementations"), so they resolve from the host's own `node_modules`, not
19
+ * from wherever this file happens to be installed. `process.argv[1]` is the
20
+ * entry script for every invocation shape (`bakers ports …` and the conductor
21
+ * process alike); a test harness with no such argv falls back to this
22
+ * module's own URL. The global install is a symlink
23
+ * (`~/.nvm/.../bin/bakers` → `lib/node_modules/@bakers-build/bakers/bin/bakers`):
24
+ * `createRequire`'s `node_modules` walk follows the real path, not the
25
+ * symlink's directory, so the base must be realpath'd first (BB-681 review
26
+ * C1). The repo's own dev/test-workspace launcher (`bakers-cli/bin/bakers`) is a
27
+ * SEPARATE package from the host and imports `../host/dist/src/host.js`
28
+ * directly, so `entry`'s own realpath'd directory has no `node_modules` with
29
+ * the transport packages either — mirror that launcher's own relative import
30
+ * as a second candidate (BB-681 review N6).
31
+ */
32
+ function hostInstallBases() {
33
+ const entry = process.argv[1];
34
+ if (!entry)
35
+ return [import.meta.url];
36
+ let real;
37
+ try {
38
+ real = realpathSync(entry);
39
+ }
40
+ catch {
41
+ real = entry;
42
+ }
43
+ const entryUrl = pathToFileURL(real);
44
+ return [entryUrl.href, new URL('../host/dist/src/host.js', entryUrl).href];
45
+ }
46
+ /** No ports addon entry (or one without a transport) in either scope. */
47
+ export const PORTS_NEEDS_ENTRY = "bakers ports needs '@bakers-build/addon-ports' in addons[] of ~/.bakers/machine.config.js or .bakers/conductor.config.js, with with.transport.use naming a transport package; see docs/addons/ports";
48
+ /** The retired top-level machine `ports` key. */
49
+ export const PORTS_RETIRED = "machine ports is retired: move it to addons: [{ use: '@bakers-build/addon-ports', with: { transport: … } }]";
50
+ /** Throw the remediation when the machine config still carries `ports`. */
51
+ export async function assertNoMachinePorts() {
52
+ const machine = await readMachineContext();
53
+ if (machine.ports !== undefined)
54
+ throw new Error(PORTS_RETIRED);
55
+ }
56
+ const PORTS_SPEC = '@bakers-build/addon-ports';
57
+ /** `addons[@bakers-build/addon-ports].with.<key> <message>` per finding. */
58
+ export function withProblems(value) {
59
+ return checkWith(value).map((f) => `addons[${PORTS_SPEC}].with${f.key ? `.${f.key}` : ''} ${f.message}`);
60
+ }
61
+ /** Throw the first malformed ports `with` in the machine or project config,
62
+ * naming the file and key — a bad setting is never silently ignored. */
63
+ async function assertValidScopes(worktree) {
64
+ const project = projectConductorConfigPath(worktree);
65
+ for (const path of [machineUiConfigPath(), project]) {
66
+ if (path === undefined)
67
+ continue;
68
+ for (const entry of await readConfigAddons(path)) {
69
+ if (typeof entry === 'string' ||
70
+ (entry?.use ?? entry?.name) !== PORTS_SPEC)
71
+ continue;
72
+ const [problem] = withProblems(entry.with ?? entry.options);
73
+ if (problem)
74
+ throw new Error(`${path}: ${problem}`);
75
+ }
76
+ }
77
+ }
78
+ /** `portsSettings` plus the retired machine `ports` check — the transport
79
+ * path; discovery (`list`) reads settings without it. */
80
+ export async function portsSettings(worktree) {
81
+ await assertNoMachinePorts();
82
+ return readPortsSettings(worktree);
83
+ }
84
+ /**
85
+ * The effective `@bakers-build/addon-ports` entry's `with` for `worktree`, merged
86
+ * by the preset's `mergeWith` (the machine wins). Undefined when neither
87
+ * scope lists the addon. Throws for a malformed `with` in either scope.
88
+ */
89
+ export async function readPortsSettings(worktree) {
90
+ await assertValidScopes(worktree);
91
+ const found = await readEffectiveAddon('@bakers-build/addon-ports', {
92
+ cwd: worktree,
93
+ bases: [...hostInstallBases(), import.meta.url],
94
+ });
95
+ if (found === undefined || typeof found.entry === 'string')
96
+ return undefined;
97
+ return found.entry.with ?? found.entry.options;
98
+ }
99
+ /**
100
+ * Resolve, load and construct the configured ports transport from the ports
101
+ * addon entry's `with` (`settings`).
102
+ * Resolution bases, in order: `home` (a project-local transport package),
103
+ * the running `bakers` host's own install (realpath'd, so the three built-in
104
+ * provider packages resolve through a symlinked global install), then
105
+ * `process.cwd()`.
106
+ * Every failure — missing config, unresolvable package, no default export,
107
+ * a factory that rejects its settings, or a malformed `PortsTransport`
108
+ * result — throws a safe diagnostic, never a raw crash.
109
+ */
110
+ export async function loadPortsTransport(settings, home) {
111
+ const ports = settings;
112
+ if (!ports ||
113
+ typeof ports !== 'object' ||
114
+ typeof ports.transport !== 'object' ||
115
+ ports.transport === null ||
116
+ typeof ports.transport.use !== 'string' ||
117
+ ports.transport.use === '') {
118
+ throw new Error(PORTS_NEEDS_ENTRY);
119
+ }
120
+ const spec = ports.transport.use;
121
+ // `resolveModuleEslintStyle`'s bases are node module PARENTS — a bare
122
+ // directory path (no trailing slash) is otherwise treated by
123
+ // `createRequire` as a FILE, which starts `node_modules` lookup one level
124
+ // too high. Same `pathToFileURL(dir + '/')` shape `load-bakers-config.ts`
125
+ // already uses for its own bases.
126
+ const resolved = resolveModuleEslintStyle(spec, [
127
+ pathToFileURL(`${home}/`).href,
128
+ ...hostInstallBases(),
129
+ pathToFileURL(`${process.cwd()}/`).href,
130
+ ]);
131
+ if (resolved === undefined) {
132
+ throw new Error(`bakers ports cannot resolve transport package '${spec}'`);
133
+ }
134
+ let mod;
135
+ try {
136
+ mod = (await import(pathToFileURL(resolved).href));
137
+ }
138
+ catch (err) {
139
+ throw new InvalidTransportModuleError(spec, `failed to load: ${err instanceof Error ? err.message : String(err)}`);
140
+ }
141
+ const factory = mod.default;
142
+ if (typeof factory !== 'function') {
143
+ throw new InvalidTransportModuleError(spec, 'module has no default export factory');
144
+ }
145
+ let transport;
146
+ try {
147
+ transport = await factory(ports.transport.with, {
148
+ home,
149
+ });
150
+ }
151
+ catch (err) {
152
+ if (err instanceof InvalidTransportModuleError)
153
+ throw err;
154
+ throw new Error(`ports transport '${spec}' rejected its settings: ${err instanceof Error ? err.message : String(err)}`);
155
+ }
156
+ assertPortsTransport(transport, spec);
157
+ return transport;
158
+ }
@@ -0,0 +1,10 @@
1
+ export type { RawDiagnostic, RawDiscovery, RawPort, Tri, } from '@bakers-build/addon-ddev';
2
+ export { type ClaimContext, type ClaimedDiscovery, type ClaimedService, type ClaimStatus, claimServices, discoverClaimedServices, type HttpProbe, publicHostname, type TunnelRule, } from './claims.js';
3
+ export { assertNoMachinePorts, loadPortsTransport, PORTS_NEEDS_ENTRY, PORTS_RETIRED, type PortsMachineConfig, portsSettings, readPortsSettings, withProblems, } from './config.js';
4
+ export { discoverHostPorts, type HostDiscoveryDeps, parseSocketListeners, type SocketListener, } from './discovery.js';
5
+ export { type LockDeps, LockTimeoutError, nodeLockDeps, tunnelLockPath, withTunnelLock, } from './lock.js';
6
+ export { readTunnelState, type StateDeps } from './state.js';
7
+ export { assertPortsTransport, InvalidTransportModuleError, type PortsTransport, type PortsTransportFactory, } from './transport.js';
8
+ export { bringUp as bringUpTunnel, type OperationContext, type OpResult as TunnelOpResult, type ReapResult as TunnelReapResult, readTunnelState as readMachineTunnelRecords, reap as reapTunnels, reconcileTunnels, type TunnelOpsDeps, takeDown as takeDownTunnel, } from './tunnel-ops.js';
9
+ export { assertIdentity, emptyState, type MachineTunnelState, nodeTunnelStateDeps, type ReadResult, readMachineTunnelState, readValidatedMachineState, type TunnelDesiredRecord, type TunnelIdentity, TunnelIdentityMismatchError, type TunnelStateDeps, tunnelStatePath, upsertRecord, writeMachineTunnelState, } from './tunnel-state.js';
10
+ export type { TunnelRecord, TunnelState } from './types.js';
@@ -0,0 +1,15 @@
1
+ // BB-475 — the ports package's pure surface, free of the command wiring.
2
+ //
3
+ // Exported as `@bakers-build/addon-ports/core` so the cockpit package (BB-475 stage
4
+ // 6) consumes the discovery, transport and state functions and their types
5
+ // WITHOUT pulling in commander or the CLI entry. Discovery and transport are the
6
+ // same code the commands run; the cockpit renders their result and drives their
7
+ // actions.
8
+ export { claimServices, discoverClaimedServices, publicHostname, } from './claims.js';
9
+ export { assertNoMachinePorts, loadPortsTransport, PORTS_NEEDS_ENTRY, PORTS_RETIRED, portsSettings, readPortsSettings, withProblems, } from './config.js';
10
+ export { discoverHostPorts, parseSocketListeners, } from './discovery.js';
11
+ export { LockTimeoutError, nodeLockDeps, tunnelLockPath, withTunnelLock, } from './lock.js';
12
+ export { readTunnelState } from './state.js';
13
+ export { assertPortsTransport, InvalidTransportModuleError, } from './transport.js';
14
+ export { bringUp as bringUpTunnel, readTunnelState as readMachineTunnelRecords, reap as reapTunnels, reconcileTunnels, takeDown as takeDownTunnel, } from './tunnel-ops.js';
15
+ export { assertIdentity, emptyState, nodeTunnelStateDeps, readMachineTunnelState, readValidatedMachineState, TunnelIdentityMismatchError, tunnelStatePath, upsertRecord, writeMachineTunnelState, } from './tunnel-state.js';