@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 +21 -0
- package/README.md +4 -2
- package/dist/src/claim-share.d.ts +33 -0
- package/dist/src/claim-share.js +38 -0
- package/dist/src/claims.d.ts +39 -0
- package/dist/src/claims.js +286 -0
- package/dist/src/config.d.ts +40 -0
- package/dist/src/config.js +158 -0
- package/dist/src/core.d.ts +10 -0
- package/dist/src/core.js +15 -0
- package/dist/src/deps-node.d.ts +91 -0
- package/dist/src/deps-node.js +234 -0
- package/dist/src/discovery.d.ts +28 -0
- package/dist/src/discovery.js +100 -0
- package/dist/src/inbox.d.ts +35 -0
- package/dist/src/inbox.js +91 -0
- package/dist/src/index.d.ts +92 -0
- package/dist/src/index.js +525 -0
- package/dist/src/lock.d.ts +21 -0
- package/dist/src/lock.js +148 -0
- package/dist/src/preset.d.ts +6 -0
- package/dist/src/preset.js +30 -0
- package/dist/src/service-config.d.ts +27 -0
- package/dist/src/service-config.js +111 -0
- package/dist/src/state.d.ts +15 -0
- package/dist/src/state.js +39 -0
- package/dist/src/transport-contract.d.ts +2 -0
- package/dist/src/transport-contract.js +9 -0
- package/dist/src/transport.d.ts +46 -0
- package/dist/src/transport.js +37 -0
- package/dist/src/tunnel-ops.d.ts +76 -0
- package/dist/src/tunnel-ops.js +302 -0
- package/dist/src/tunnel-result.d.ts +6 -0
- package/dist/src/tunnel-result.js +41 -0
- package/dist/src/tunnel-state.d.ts +94 -0
- package/dist/src/tunnel-state.js +218 -0
- package/dist/src/types.d.ts +18 -0
- package/dist/src/types.js +11 -0
- package/package.json +31 -4
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
|
-
#
|
|
1
|
+
# @bakers-build/addon-ports
|
|
2
2
|
|
|
3
|
-
|
|
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';
|
package/dist/src/core.js
ADDED
|
@@ -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';
|