@bakers-build/addon-ddev 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-ddev
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
+ DDEV-specific service discovery and claiming: HTTP(S)_EXPOSE/VIRTUAL_HOST interpretation over @bakers-build/addon-docker-compose's generic container data, plus effective-host discovery via `ddev utility configyaml`. No CMS-specific policy lives here.
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,9 @@
1
+ import type { ClaimContext, ClaimedService, RawPort } from './types.js';
2
+ /**
3
+ * DDEV claimer — every exposed HTTP(S) route of this worktree's containers,
4
+ * coalesced (prefers HTTPS), named from service-type/daemon/VIRTUAL_HOST
5
+ * evidence. The primary web-container router port names itself `web`, Host
6
+ * rewritten through Traefik like any other DDEV service — no CMS-specific
7
+ * policy lives here.
8
+ */
9
+ export declare function claimDdev(raw: readonly RawPort[], remaining: Set<number>, ctx: ClaimContext): ClaimedService[];
@@ -0,0 +1,347 @@
1
+ // BB-676 Task 2 (spec v3 split) — claim raw observations into named,
2
+ // tunnel-ready DDEV services. A claimer consumes raw IDENTITIES (array
3
+ // indices into one `RawDiscovery` pass), never a global port number: two
4
+ // containers or two site rules can share a port, so "port already seen"
5
+ // would silently merge them.
6
+ //
7
+ // This package knows nothing CMS-specific: the web container's own router
8
+ // port names itself `web` (or `web-<host>` for an additional alias), exactly
9
+ // like every other DDEV service names from its compose/service-type/daemon
10
+ // evidence — a project's own application owns multisite/host-header policy,
11
+ // not this claimer (BB-681: no CMS-specific addon layered on top any more).
12
+ //
13
+ // Only a `healthy` claim produces `TunnelRule`s: an orphan, dead, stopped,
14
+ // unknown or not-exposed service is still reported — so `list` can show it —
15
+ // but never shareable.
16
+ import { normalizeHost } from './hosts.js';
17
+ /** Name a claimed service from the best evidence its raw row carries.
18
+ * `serviceType` (`com.ddev.service-type`) is the more specific name when a
19
+ * project happens to set it; `composeService` (`com.docker.compose.service`)
20
+ * is the label DDEV/compose actually sets on every container. A daemon name
21
+ * (a webextradaemon inside the shared web container) distinguishes
22
+ * mailpit/phpmyadmin/etc from the web container's own router port. */
23
+ function nameFromEvidence(p, webExtraExposedPortNames) {
24
+ if (p.daemon) {
25
+ const name = p.daemon.includes(':') ? p.daemon.split(':')[1] : p.daemon;
26
+ return name.toLowerCase();
27
+ }
28
+ // B: a dead/live row for a `web_extra_exposed_ports`-declared service
29
+ // (vite, xhgui, …) carries no `daemon` evidence once it stops listening —
30
+ // its `composeService`/`serviceType` is still just `web` (the shared
31
+ // container), which would otherwise collide with the primary web group's
32
+ // own `web` key and get suffixed into an opaque `web-<hash>-<port>` row.
33
+ // The declared name from `.ddev/config.yaml` is more specific evidence
34
+ // than that shared container label, so it is checked first.
35
+ if (p.containerPort !== undefined) {
36
+ const declared = webExtraExposedPortNames?.get(p.containerPort);
37
+ if (declared)
38
+ return declared.toLowerCase();
39
+ }
40
+ if (p.serviceType)
41
+ return p.serviceType.toLowerCase();
42
+ if (p.composeService)
43
+ return p.composeService.toLowerCase();
44
+ if (p.virtualHost) {
45
+ return p.virtualHost.split(',')[0].split('.')[0].toLowerCase();
46
+ }
47
+ return `service-${(p.containerId ?? 'unknown').slice(0, 8)}`;
48
+ }
49
+ /** The Traefik-routed target for a DDEV service: the ROUTER port DDEV's
50
+ * router/Traefik listens on at the host loopback — never the
51
+ * container-internal port, which only the rule's `direct` origin (dialled
52
+ * by a transport that reaches container IPs) uses. */
53
+ function routedServiceOf(p) {
54
+ const port = p.routerPort ?? p.hostBoundPort ?? p.containerPort ?? 443;
55
+ // B: an alias that only exists via HTTP_EXPOSE has no HTTPS listener behind
56
+ // it at all — dialling `https://` there just fails. Default to `https`
57
+ // only when we have no scheme evidence (e.g. a bare listening socket with
58
+ // no expose rule attached).
59
+ const scheme = p.exposeScheme === 'http' ? 'http' : 'https';
60
+ return `${scheme}://127.0.0.1:${port}`;
61
+ }
62
+ function resolveStatus(p) {
63
+ if (p.containerState === 'stopped')
64
+ return 'stopped';
65
+ if (p.daemonStatus === 'STOPPED' || p.daemonStatus === 'EXITED')
66
+ return 'stopped';
67
+ if (p.exposed === 'unknown' || p.listening === 'unknown')
68
+ return 'unknown';
69
+ if (p.exposed === true && p.listening === false)
70
+ return 'dead';
71
+ if (p.exposed === true && p.listening === true)
72
+ return 'healthy';
73
+ if (p.exposed === false && p.listening === true)
74
+ return 'not-exposed';
75
+ return 'unknown';
76
+ }
77
+ /** The sibling row carrying the SAME derived name that IS actually listening —
78
+ * diagnostic evidence for a dead claim (AC4: "vite is dead, 3002 is not"),
79
+ * never asserted as a verified substitute. */
80
+ function listeningSibling(raw, containerId, excludeIdx) {
81
+ if (!containerId)
82
+ return undefined;
83
+ // B: real DDEV topology never repeats a dead group's derived name on the
84
+ // sibling that actually listens (e.g. vite is dead on 5173, the real
85
+ // listener is a daemon like `pynq-members-dev` on 3002) — matching by name
86
+ // never fires. Match by CONTAINER identity instead: any other row on the
87
+ // same container that is genuinely listening is diagnostic evidence worth
88
+ // surfacing.
89
+ //
90
+ // Critical finding 1: a container can have several genuinely-listening
91
+ // rows (the web-container's internal nginx, Docker's own embedded DNS
92
+ // stub, an actual webextradaemon). `127.0.0.11` (Docker's embedded DNS) is
93
+ // already filtered out upstream, but a daemon-attached row is still the
94
+ // most useful diagnostic evidence — name the thing an operator can
95
+ // actually act on, not whichever row happens to come first.
96
+ // A: a daemon that ALSO owns an exposed, healthy route elsewhere on the
97
+ // same container (mailpit on its router-mapped port) is not the
98
+ // diagnostic candidate an operator can "go use instead" — it already has
99
+ // its own working tunnel. Only an unexposed row names the thing that is
100
+ // actually, unexpectedly listening where the dead claim expected to find
101
+ // nothing else. `ss`'s row order must not decide which daemon wins.
102
+ const exposedDaemonNames = new Set(raw
103
+ .filter((p) => p.containerId === containerId &&
104
+ p.exposed === true &&
105
+ p.daemon !== undefined)
106
+ .map((p) => p.daemon));
107
+ const candidates = raw.filter((p, i) => i !== excludeIdx &&
108
+ p.containerId === containerId &&
109
+ p.listening === true &&
110
+ p.exposed === false &&
111
+ p.address !== '127.0.0.11' &&
112
+ !(p.daemon !== undefined && exposedDaemonNames.has(p.daemon)));
113
+ const webExtraDaemon = candidates.find((p) => p.daemon?.startsWith('webextradaemons:'));
114
+ const anyDaemon = candidates.find((p) => p.daemon !== undefined);
115
+ return webExtraDaemon ?? anyDaemon ?? candidates[0];
116
+ }
117
+ /**
118
+ * DDEV claimer — every exposed HTTP(S) route of this worktree's containers,
119
+ * coalesced (prefers HTTPS), named from service-type/daemon/VIRTUAL_HOST
120
+ * evidence. The primary web-container router port names itself `web`, Host
121
+ * rewritten through Traefik like any other DDEV service — no CMS-specific
122
+ * policy lives here.
123
+ */
124
+ export function claimDdev(raw, remaining, ctx) {
125
+ const exposedIdxs = [...remaining].filter((i) => raw[i].containerId !== undefined && raw[i].exposed !== undefined);
126
+ // Group by (containerId, containerPort) — NOT by routerPort, and NOT by
127
+ // VIRTUAL_HOST: a service can be reachable through an HTTP_EXPOSE and an
128
+ // HTTPS_EXPOSE mapping that target the SAME container port (any non-web
129
+ // alias pair — mailpit, vite, … — follows this pattern); keying on router
130
+ // port treats those as two distinct services.
131
+ //
132
+ // Live pynq-43 topology: DDEV's nginx ALSO listens on container port 443
133
+ // internally even though HTTP_EXPOSE/HTTPS_EXPOSE both route to container
134
+ // port 80 (Traefik terminates TLS itself and only ever forwards there) —
135
+ // that stray, genuinely unexposed 443 socket is NOT part of the public web
136
+ // service, so it must never be swept into the same group as the real
137
+ // HTTP/HTTPS alias pair just because 80/443 are both traditionally
138
+ // "the" web ports. The plain containerPort key already does the right
139
+ // thing for both real shapes: the HTTP/HTTPS alias pair (same containerPort,
140
+ // different routerPort) coalesces naturally; the unrelated 443-internal
141
+ // socket gets its own group, surfacing separately as diagnostic evidence
142
+ // (R2: a bare, unexposed sibling socket on the SAME containerPort as an
143
+ // exposed row still joins that row's group — any other containerPort does not).
144
+ const groups = new Map();
145
+ for (const i of exposedIdxs) {
146
+ const p = raw[i];
147
+ // N2: a port-less `unattachedDaemons` row (STOPPED/EXITED, no socket to
148
+ // attach to) carries neither `containerPort` nor `routerPort`, so every
149
+ // such row on the same container used to collapse into one `:unknown`
150
+ // group — a RUNNING, port-less sibling (nginx/php-fpm) then won the
151
+ // group and a STOPPED sibling (xhgui) silently vanished instead of
152
+ // listing. Key port-less rows by daemon name so each keeps its own group.
153
+ const groupKey = (p.containerPort ?? p.routerPort)
154
+ ? `${p.containerId}:${p.containerPort ?? p.routerPort}`
155
+ : `${p.containerId}:unknown:${p.daemon ?? 'unknown'}`;
156
+ const entry = groups.get(groupKey) ?? [];
157
+ entry.push(i);
158
+ groups.set(groupKey, entry);
159
+ }
160
+ // B/duplicate-row fix: a group with no `containerPort` at all (an
161
+ // `unattachedDaemons` row — e.g. the web container's internal `nginx`, or a
162
+ // supervisor-listed daemon `ss`/the socket scan could not correlate to its
163
+ // own already-discovered socket) is DIAGNOSTIC-ONLY evidence about a
164
+ // container that already has a real, port-specific claim elsewhere. Once
165
+ // that more specific claim exists, the containerPort-less group is a pure
166
+ // duplicate of the same container and must not surface as its own row. A
167
+ // container with NO other port-bearing group at all (e.g. `db`, which is
168
+ // genuinely unclaimed) keeps its single generic row — this only suppresses
169
+ // the redundant generic twin, never a genuinely unclaimed container.
170
+ const containerIdsWithPort = new Set();
171
+ for (const i of exposedIdxs) {
172
+ const p = raw[i];
173
+ if (p.containerPort !== undefined && p.containerId !== undefined) {
174
+ containerIdsWithPort.add(p.containerId);
175
+ }
176
+ }
177
+ // Order groups so that the group whose name assignment MATTERS most (an
178
+ // actually `healthy` route) claims the clean, unsuffixed name — never
179
+ // whichever group happens to iterate first. Two sockets on the SAME
180
+ // container can legitimately derive the identical baseName (phpMyAdmin's
181
+ // extra loopback-only status socket next to its real port-80 listener;
182
+ // OpenSearch's internal transport port 9300 next to its real 9200 API) —
183
+ // a Map's insertion order is otherwise arbitrary, so the bare, operator-
184
+ // facing key (`phpmyadmin`, not `phpmyadmin-<hash>-80`) could land on the
185
+ // not-exposed diagnostic row instead of the one `ports up phpmyadmin`
186
+ // actually needs to resolve to.
187
+ const statusPriority = {
188
+ healthy: 0,
189
+ dead: 1,
190
+ stopped: 2,
191
+ unknown: 3,
192
+ 'not-exposed': 4,
193
+ };
194
+ const orderedGroups = [...groups.values()]
195
+ .map((idxs, order) => {
196
+ const sorted = [...idxs].sort((a, b) => {
197
+ const score = (i) => (raw[i].exposeScheme === 'http' ? 1 : 0);
198
+ return score(a) - score(b);
199
+ });
200
+ const best = raw[sorted[0]];
201
+ return { idxs: sorted, best, status: resolveStatus(best), order };
202
+ })
203
+ .sort((a, b) => {
204
+ const byStatus = statusPriority[a.status] - statusPriority[b.status];
205
+ return byStatus !== 0 ? byStatus : a.order - b.order;
206
+ });
207
+ const out = [];
208
+ const seenKeys = new Set();
209
+ for (const { idxs, best, status } of orderedGroups) {
210
+ for (const i of idxs)
211
+ remaining.delete(i);
212
+ if (best.containerPort === undefined &&
213
+ best.containerId !== undefined &&
214
+ containerIdsWithPort.has(best.containerId) &&
215
+ // Finding 5: only suppress a RUNNING twin (nginx's own internal
216
+ // socket, or a daemon the socket scan already attached to a
217
+ // port-bearing group elsewhere on this container) — a STOPPED/EXITED
218
+ // `webextradaemons` entry (e.g. `xhgui`) has NO other representation
219
+ // anywhere else and must still list, or it silently vanishes from
220
+ // `list` instead of showing as `[stopped]`.
221
+ (best.daemon === undefined || best.daemonStatus === 'RUNNING')) {
222
+ // Diagnostic-only twin of a container that already has a real claim
223
+ // (nginx inside the web container, a daemon the socket scan already
224
+ // matched elsewhere on this same container) — drop it, never render it
225
+ // as its own row.
226
+ continue;
227
+ }
228
+ const baseName = nameFromEvidence(best, ctx.webExtraExposedPortNames);
229
+ // B: group identity is (containerId, containerPort) — never collapsed
230
+ // across distinct groups just because two unrelated containers happen to
231
+ // derive the same name. Only suffix when this exact key was already
232
+ // produced by an EARLIER distinct group.
233
+ const key = seenKeys.has(baseName)
234
+ ? `${baseName}-${(best.containerId ?? '').slice(0, 8)}-${best.containerPort ?? 'na'}`
235
+ : baseName;
236
+ seenKeys.add(baseName);
237
+ const rawPorts = idxs.map((i) => raw[i]);
238
+ let limitation;
239
+ const rules = [];
240
+ let healthyService;
241
+ if (status === 'dead') {
242
+ const sibling = listeningSibling(raw, best.containerId, idxs[0]);
243
+ limitation = sibling
244
+ ? `configured port ${best.routerPort ?? best.containerPort} is dead; ` +
245
+ `${nameFromEvidence(sibling, ctx.webExtraExposedPortNames)} is listening on ` +
246
+ `${sibling.containerPort} instead`
247
+ : `configured port ${best.routerPort ?? best.containerPort} is dead`;
248
+ }
249
+ else if (status === 'healthy') {
250
+ const service = routedServiceOf(best);
251
+ healthyService = service;
252
+ rules.push({
253
+ key,
254
+ service,
255
+ ...(best.virtualHost
256
+ ? { hostHeader: best.virtualHost.split(',')[0] }
257
+ : {}),
258
+ // N7: Traefik presents every DDEV-routed destination over HTTPS
259
+ // with its own (mkcert/self-signed) certificate — `insecureTls`
260
+ // belongs to the resolved TARGET's scheme, not to "is the router
261
+ // port literally 443". `routedServiceOf` always resolves to
262
+ // `https://`, so Mailpit's router 8026 needs this exactly as much
263
+ // as Drupal's router 443 did; the old `routerPort === 443` check
264
+ // silently skipped it for every other router port.
265
+ ...(service.startsWith('https://') ? { insecureTls: true } : {}),
266
+ // Transports that reach container IPs skip Traefik and keep the
267
+ // public Host; the `web-<host>` aliases below need Traefik's
268
+ // host-based routing, so they never get this.
269
+ ...(best.containerIp && best.containerPort
270
+ ? { direct: `http://${best.containerIp}:${best.containerPort}` }
271
+ : {}),
272
+ });
273
+ }
274
+ // Finding 6: name the actual router port an operator would dial —
275
+ // `https://<vhost>` alone silently implies 443, which is wrong for every
276
+ // non-default router port (Mailpit 8026, phpMyAdmin 8037, …).
277
+ const localScheme = best.exposeScheme === 'http' ? 'http' : 'https';
278
+ const localPort = best.routerPort;
279
+ const localDefaultPort = localScheme === 'http' ? 80 : 443;
280
+ const localSuffix = localPort && localPort !== localDefaultPort ? `:${localPort}` : '';
281
+ out.push({
282
+ key,
283
+ claim: 'ddev',
284
+ status,
285
+ local: best.virtualHost
286
+ ? `${localScheme}://${best.virtualHost.split(',')[0]}${localSuffix}`
287
+ : `container:${best.containerId}`,
288
+ ...(limitation ? { limitation } : {}),
289
+ rawPorts,
290
+ rules,
291
+ });
292
+ // BB-681 Task 1 — split the PRIMARY web group (the bare `web` key —
293
+ // never a duplicate-suffixed or ancillary-service group) into one
294
+ // additional claim per configured host, each named `web-<host-with-
295
+ // hyphens-for-dots>`. An alias is only eligible when it is BOTH
296
+ // configured (`ctx.hosts.additional`) AND observed in this group's own
297
+ // VIRTUAL_HOST evidence — a configured-but-unobserved alias never
298
+ // produces a confident claim.
299
+ if (key === 'web' && status === 'healthy' && ctx.hosts && healthyService) {
300
+ const observed = new Set((best.virtualHost ?? '')
301
+ .split(',')
302
+ .map((h) => normalizeHost(h))
303
+ .filter(Boolean));
304
+ const eligibleAliases = ctx.hosts.additional.filter((a) => observed.has(a));
305
+ // BB-681 review M4: two DIFFERENT hosts can produce the SAME
306
+ // `web-<key>` after replacing dots with hyphens (`admin.ddev-site` and
307
+ // `admin-ddev.site` both become `web-admin-ddev-site`). Spec: "reject
308
+ // collisions instead of selecting by list order" — detect every
309
+ // colliding aliasKey across this one call BEFORE emitting any of
310
+ // them, so neither alias is silently favored by iteration order.
311
+ const keyCounts = new Map();
312
+ for (const alias of eligibleAliases) {
313
+ const aliasKey = `web-${alias.replace(/\./g, '-')}`;
314
+ keyCounts.set(aliasKey, (keyCounts.get(aliasKey) ?? 0) + 1);
315
+ }
316
+ for (const alias of eligibleAliases) {
317
+ const aliasKey = `web-${alias.replace(/\./g, '-')}`;
318
+ const colliding = (keyCounts.get(aliasKey) ?? 0) > 1;
319
+ out.push({
320
+ key: aliasKey,
321
+ claim: 'ddev',
322
+ status: 'healthy',
323
+ local: `https://${alias}`,
324
+ rawPorts,
325
+ ...(colliding
326
+ ? {
327
+ limitation: `collides with another alias's normalized key '${aliasKey}' — neither is shareable`,
328
+ rules: [],
329
+ }
330
+ : {
331
+ rules: [
332
+ {
333
+ key: aliasKey,
334
+ service: healthyService,
335
+ hostHeader: alias,
336
+ ...(healthyService.startsWith('https://')
337
+ ? { insecureTls: true }
338
+ : {}),
339
+ },
340
+ ],
341
+ }),
342
+ });
343
+ }
344
+ }
345
+ }
346
+ return out;
347
+ }
@@ -0,0 +1,39 @@
1
+ import { type DockerComposeDeps } from '@bakers-build/addon-docker-compose';
2
+ import type { RawDiscovery } from './types.js';
3
+ /**
4
+ * DDEV's `HTTP_EXPOSE`/`HTTPS_EXPOSE` format: comma-separated
5
+ * `routerPort:containerPort` pairs (a bare port means router===container).
6
+ */
7
+ export declare function parseExposeMapping(value: string): Array<{
8
+ routerPort: number;
9
+ containerPort: number;
10
+ }>;
11
+ /** `.ddev/config.yaml`'s `web_extra_exposed_ports:` — a block sequence of
12
+ * `{name, container_port, http_port, https_port}` entries (real shape,
13
+ * captured live from a pynq-43 worktree):
14
+ * ```
15
+ * web_extra_exposed_ports:
16
+ * -
17
+ * name: vite
18
+ * container_port: 5173
19
+ * http_port: 5172
20
+ * https_port: 5173
21
+ * ```
22
+ * A targeted line-parser is enough — no need for a YAML dependency for one
23
+ * array. Keyed by `container_port` since that is what a claimed row's `containerPort`
24
+ * matches against (B: name a non-primary-web group from this before falling
25
+ * back to the web container's generic compose-service label).
26
+ *
27
+ * DDEV accepts both block-sequence shapes — a lone `-` starting the entry
28
+ * (above) AND the inline form `- name: vite` with the first field on the
29
+ * same line as the dash (DDEV's own documented style). A line starting with
30
+ * `-` always begins a new entry either way; its remainder (if any) is the
31
+ * first field of that entry, not a lone separator to discard. */
32
+ export declare function parseWebExtraExposedPorts(configYaml: string | null): ReadonlyMap<number, string>;
33
+ /**
34
+ * Discover this worktree's DDEV-exposed ports: every container the
35
+ * `com.ddev.approot` label (unioned with the generic compose working-dir
36
+ * label, `@bakers-build/addon-docker-compose`'s own filter) matches, interpreted
37
+ * through `HTTP(S)_EXPOSE`/`VIRTUAL_HOST`.
38
+ */
39
+ export declare function discoverDdevPorts(worktree: string, deps: DockerComposeDeps): Promise<RawDiscovery>;
@@ -0,0 +1,247 @@
1
+ // BB-676 spec v2 — DDEV-specific interpretation of generic Docker Compose
2
+ // observations: `HTTP_EXPOSE`/`HTTPS_EXPOSE` router-port parsing and the
3
+ // `com.ddev.approot` worktree match. This is the only place in the ports
4
+ // pipeline that knows those conventions exist.
5
+ import { discoverCompose, } from '@bakers-build/addon-docker-compose';
6
+ /**
7
+ * DDEV's `HTTP_EXPOSE`/`HTTPS_EXPOSE` format: comma-separated
8
+ * `routerPort:containerPort` pairs (a bare port means router===container).
9
+ */
10
+ export function parseExposeMapping(value) {
11
+ const out = [];
12
+ for (const raw of value.split(',')) {
13
+ const part = raw.trim();
14
+ if (!part)
15
+ continue;
16
+ const [a, b] = part.split(':');
17
+ const routerPort = Number(a);
18
+ const containerPort = Number(b ?? a);
19
+ if (!Number.isInteger(routerPort) || !Number.isInteger(containerPort)) {
20
+ continue;
21
+ }
22
+ out.push({ routerPort, containerPort });
23
+ }
24
+ return out;
25
+ }
26
+ /** Tag each parsed mapping with which `*_EXPOSE` var produced it, so later
27
+ * stages know the real scheme instead of guessing from the router port. */
28
+ function parseExposeMappingWithScheme(value, scheme) {
29
+ return parseExposeMapping(value).map((m) => ({ ...m, scheme }));
30
+ }
31
+ /** `.ddev/config.yaml`'s `web_extra_exposed_ports:` — a block sequence of
32
+ * `{name, container_port, http_port, https_port}` entries (real shape,
33
+ * captured live from a pynq-43 worktree):
34
+ * ```
35
+ * web_extra_exposed_ports:
36
+ * -
37
+ * name: vite
38
+ * container_port: 5173
39
+ * http_port: 5172
40
+ * https_port: 5173
41
+ * ```
42
+ * A targeted line-parser is enough — no need for a YAML dependency for one
43
+ * array. Keyed by `container_port` since that is what a claimed row's `containerPort`
44
+ * matches against (B: name a non-primary-web group from this before falling
45
+ * back to the web container's generic compose-service label).
46
+ *
47
+ * DDEV accepts both block-sequence shapes — a lone `-` starting the entry
48
+ * (above) AND the inline form `- name: vite` with the first field on the
49
+ * same line as the dash (DDEV's own documented style). A line starting with
50
+ * `-` always begins a new entry either way; its remainder (if any) is the
51
+ * first field of that entry, not a lone separator to discard. */
52
+ export function parseWebExtraExposedPorts(configYaml) {
53
+ const out = new Map();
54
+ if (!configYaml)
55
+ return out;
56
+ const listMatch = /^web_extra_exposed_ports:\s*$/m.exec(configYaml);
57
+ if (!listMatch)
58
+ return out;
59
+ const rest = configYaml.slice(listMatch.index + listMatch[0].length);
60
+ const lines = rest.split('\n');
61
+ let name;
62
+ let containerPort;
63
+ const flush = () => {
64
+ if (name !== undefined && containerPort !== undefined) {
65
+ out.set(containerPort, name);
66
+ }
67
+ name = undefined;
68
+ containerPort = undefined;
69
+ };
70
+ const applyField = (field) => {
71
+ const nameM = /^\s*name:\s*['"]?([^'"#\r\n]*?)['"]?\s*(?:#.*)?$/m.exec(field);
72
+ if (nameM?.[1]) {
73
+ name = nameM[1].trim();
74
+ return;
75
+ }
76
+ const portM = /^\s*container_port:\s*(\d+)\s*$/m.exec(field);
77
+ if (portM?.[1]) {
78
+ containerPort = Number(portM[1]);
79
+ }
80
+ };
81
+ for (const line of lines) {
82
+ if (line.trim() === '')
83
+ continue;
84
+ // A line with no leading indentation ends this top-level key's block.
85
+ if (!/^\s/.test(line))
86
+ break;
87
+ const dashM = /^(\s*)-(\s*)(.*)$/.exec(line);
88
+ if (dashM) {
89
+ flush();
90
+ const remainder = dashM[3] ?? '';
91
+ if (remainder.trim() !== '')
92
+ applyField(remainder);
93
+ continue;
94
+ }
95
+ applyField(line);
96
+ }
97
+ flush();
98
+ return out;
99
+ }
100
+ function rawPortsForContainer(worktree, container) {
101
+ const containerState = container.running
102
+ ? 'running'
103
+ : 'stopped';
104
+ const virtualHost = container.env.VIRTUAL_HOST;
105
+ const serviceType = container.labels['com.ddev.service-type'];
106
+ const composeService = container.labels['com.docker.compose.service'];
107
+ // N8: DDEV allows `HTTP_EXPOSE` and `HTTPS_EXPOSE` on the SAME container at
108
+ // once — a project can expose one router port over plain HTTP and a
109
+ // different one over HTTPS. `??` read only one var, so the other's router
110
+ // mappings were silently dropped whenever both were set; merge both sets
111
+ // instead. A router port that appears in both wins by insertion order
112
+ // (HTTPS mapping taken first, matching the single-source scheme's old
113
+ // "HTTPS preferred" intent) rather than overwriting silently either way.
114
+ const mappings = [
115
+ ...(container.env.HTTPS_EXPOSE
116
+ ? parseExposeMappingWithScheme(container.env.HTTPS_EXPOSE, 'https')
117
+ : []),
118
+ ...(container.env.HTTP_EXPOSE
119
+ ? parseExposeMappingWithScheme(container.env.HTTP_EXPOSE, 'http')
120
+ : []),
121
+ ];
122
+ // N8: a `Map<containerPort, RawPort>` can only ever hold ONE row per
123
+ // container port, but with both expose vars set it is legitimate for the
124
+ // SAME container port to be reachable through two different router ports
125
+ // (e.g. `HTTP_EXPOSE=80:443` plus `HTTPS_EXPOSE=443:443`) — an array per
126
+ // port preserves both instead of the second mapping silently clobbering
127
+ // the first.
128
+ const byPort = new Map();
129
+ for (const mapping of mappings) {
130
+ const arr = byPort.get(mapping.containerPort) ?? [];
131
+ arr.push({
132
+ worktree,
133
+ containerId: container.id,
134
+ containerPort: mapping.containerPort,
135
+ routerPort: mapping.routerPort,
136
+ exposeScheme: mapping.scheme,
137
+ ...(virtualHost ? { virtualHost } : {}),
138
+ ...(serviceType ? { serviceType } : {}),
139
+ ...(composeService ? { composeService } : {}),
140
+ ...(container.boundPorts.has(mapping.containerPort)
141
+ ? { hostBoundPort: container.boundPorts.get(mapping.containerPort) }
142
+ : {}),
143
+ containerState,
144
+ exposed: true,
145
+ // Not yet probed: stopped containers cannot listen; running ones are
146
+ // resolved below, once (or left `unknown` if the probe itself failed
147
+ // upstream in `@bakers-build/addon-docker-compose`).
148
+ listening: containerState === 'running' ? 'unknown' : false,
149
+ });
150
+ byPort.set(mapping.containerPort, arr);
151
+ }
152
+ const ports = [];
153
+ const matchedPorts = new Set();
154
+ for (const socket of container.sockets) {
155
+ matchedPorts.add(socket.containerPort);
156
+ const existing = byPort.get(socket.containerPort);
157
+ if (existing && existing.length > 0) {
158
+ byPort.set(socket.containerPort, existing.map((row) => ({
159
+ ...row,
160
+ listening: true,
161
+ ...(socket.pid !== undefined ? { pid: socket.pid } : {}),
162
+ ...(socket.address !== undefined ? { address: socket.address } : {}),
163
+ ...(socket.daemon !== undefined
164
+ ? { daemon: socket.daemon, daemonStatus: socket.daemonStatus }
165
+ : {}),
166
+ })));
167
+ }
168
+ else {
169
+ // Listening but no exposure rule claims it — e.g. a daemon bound on
170
+ // the wrong port, or an internal-only service (AC1). This is also
171
+ // where the web container's own `nginx` 80/443 socket shows up when
172
+ // `ss` cannot resolve its (root-owned) pid — attach its bound host
173
+ // port here too (R2), so the claimer can still find a BB-500
174
+ // passthrough target for it.
175
+ ports.push({
176
+ worktree,
177
+ containerId: container.id,
178
+ containerPort: socket.containerPort,
179
+ ...(socket.pid !== undefined ? { pid: socket.pid } : {}),
180
+ ...(socket.address !== undefined ? { address: socket.address } : {}),
181
+ ...(serviceType ? { serviceType } : {}),
182
+ ...(composeService ? { composeService } : {}),
183
+ ...(socket.daemon !== undefined
184
+ ? { daemon: socket.daemon, daemonStatus: socket.daemonStatus }
185
+ : {}),
186
+ ...(container.boundPorts.has(socket.containerPort)
187
+ ? { hostBoundPort: container.boundPorts.get(socket.containerPort) }
188
+ : {}),
189
+ containerState,
190
+ exposed: false,
191
+ listening: true,
192
+ });
193
+ }
194
+ }
195
+ // Exposure rows the socket scan did NOT confirm are genuinely dead — but
196
+ // only when the probe itself succeeded; a failed probe means "unknown",
197
+ // never "dead", so a missing probe binary cannot revoke a live route.
198
+ //
199
+ // C: `@bakers-build/addon-docker-compose`'s `discoverCompose` now falls back to
200
+ // reading `/proc/net/tcp`(6) directly whenever `ss` is missing/fails
201
+ // outright (images like phpMyAdmin/OpenSearch ship neither `ss` nor
202
+ // `netstat`), so `container.socketsProbed` is already `true` whenever
203
+ // EITHER probe succeeded, and matched ports from that fallback already
204
+ // flow through `matchedPorts` above. A row that reaches here with
205
+ // `socketsProbed` still `false` means both probes genuinely failed — that
206
+ // is `unknown`, never a guess from a published host port binding (Docker
207
+ // binds a host port whether or not anything inside actually listens, so
208
+ // trusting it as proof of health reads a dead port as healthy just as
209
+ // often as it rescues a truly-unprobable one).
210
+ for (const [port, rows] of byPort) {
211
+ byPort.set(port, rows.map((row) => matchedPorts.has(port)
212
+ ? row
213
+ : container.socketsProbed
214
+ ? { ...row, listening: false }
215
+ : row));
216
+ }
217
+ for (const rows of byPort.values())
218
+ ports.push(...rows);
219
+ for (const daemon of container.unattachedDaemons) {
220
+ ports.push({
221
+ worktree,
222
+ containerId: container.id,
223
+ daemon: daemon.daemon,
224
+ daemonStatus: daemon.status,
225
+ containerState,
226
+ exposed: 'unknown',
227
+ listening: false,
228
+ });
229
+ }
230
+ const containerIp = container.networks.get('ddev_default');
231
+ return containerIp ? ports.map((row) => ({ ...row, containerIp })) : ports;
232
+ }
233
+ /**
234
+ * Discover this worktree's DDEV-exposed ports: every container the
235
+ * `com.ddev.approot` label (unioned with the generic compose working-dir
236
+ * label, `@bakers-build/addon-docker-compose`'s own filter) matches, interpreted
237
+ * through `HTTP(S)_EXPOSE`/`VIRTUAL_HOST`.
238
+ */
239
+ export async function discoverDdevPorts(worktree, deps) {
240
+ const { containers, diagnostics: composeDiagnostics } = await discoverCompose(worktree, deps, { extraLabelFilters: ['com.ddev.approot'] });
241
+ const diagnostics = [...composeDiagnostics];
242
+ const ports = [];
243
+ for (const container of containers) {
244
+ ports.push(...rawPortsForContainer(worktree, container));
245
+ }
246
+ return { ports, diagnostics };
247
+ }
@@ -0,0 +1,48 @@
1
+ export interface DdevHosts {
2
+ readonly primary: string;
3
+ readonly additional: readonly string[];
4
+ }
5
+ /** Thrown by `parseDdevHosts` on any config shape it cannot safely derive
6
+ * hosts from — a caller converts this to an `unknown` discovery result
7
+ * rather than ever inventing a concrete host. */
8
+ export declare class DdevHostsError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ /** Lowercase/trailing-dot normalization, shared with anything that compares
12
+ * an observed `VIRTUAL_HOST` entry against a configured host (`claims.ts`'s
13
+ * alias-eligibility check). */
14
+ export declare function normalizeHost(host: string): string;
15
+ /**
16
+ * Derive `{ primary, additional }` from DDEV's effective config (the parsed
17
+ * `ddev utility configyaml` object, or a pre-parsed equivalent). Throws
18
+ * `DdevHostsError` on missing/invalid required fields, a wildcard host, or a
19
+ * collision between two inputs that normalize to the same host — never
20
+ * silently drops or invents one.
21
+ */
22
+ export declare function parseDdevHosts(config: unknown): DdevHosts;
23
+ /** The injected exec seam — the same bounded-exec pattern
24
+ * `@bakers-build/addon-docker-compose`'s `discoverCompose` uses (a `run(cmd,
25
+ * args, cwd?)` returning `{ stdout, ok, exitCode? }`), reused rather than
26
+ * inventing a second one. */
27
+ export interface DdevHostsDeps {
28
+ run(cmd: string, args: string[], cwd?: string): Promise<{
29
+ readonly stdout: string;
30
+ readonly ok: boolean;
31
+ }>;
32
+ }
33
+ export type DdevHostsResult = {
34
+ readonly ok: true;
35
+ readonly hosts: DdevHosts;
36
+ } | {
37
+ readonly ok: false;
38
+ readonly reason: string;
39
+ };
40
+ /**
41
+ * Discover this worktree's effective DDEV hosts via `ddev utility configyaml`
42
+ * (DDEV's own merged config, YAML on stdout) — parsed with the project's
43
+ * existing `yaml` dependency, never a hand-written parser. A failed command,
44
+ * unparseable YAML, or any `parseDdevHosts` rejection all become `{ ok:
45
+ * false }` — never a thrown exception, so a caller converts this directly to
46
+ * an `unknown` claim instead of crashing discovery.
47
+ */
48
+ export declare function discoverDdevHosts(worktree: string, deps: DdevHostsDeps): Promise<DdevHostsResult>;
@@ -0,0 +1,133 @@
1
+ // BB-681 Task 1 — the project's effective DDEV hosts, read once from
2
+ // `ddev utility configyaml` (DDEV's own merged/expanded config, not the raw
3
+ // `.ddev/config.yaml` file, which can omit defaults DDEV fills in at
4
+ // runtime). DDEV alone owns calling/parsing this — no other addon reads
5
+ // `.ddev/config.yaml` for hostnames.
6
+ import { parse as parseYaml } from 'yaml';
7
+ /** Thrown by `parseDdevHosts` on any config shape it cannot safely derive
8
+ * hosts from — a caller converts this to an `unknown` discovery result
9
+ * rather than ever inventing a concrete host. */
10
+ export class DdevHostsError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'DdevHostsError';
14
+ }
15
+ }
16
+ /** Lowercase/trailing-dot normalization, shared with anything that compares
17
+ * an observed `VIRTUAL_HOST` entry against a configured host (`claims.ts`'s
18
+ * alias-eligibility check). */
19
+ export function normalizeHost(host) {
20
+ return host.trim().toLowerCase().replace(/\.$/, '');
21
+ }
22
+ /** A bare `*` or a `*.`-prefixed entry is a wildcard — DDEV accepts these in
23
+ * `additional_hostnames`/`additional_fqdns`, but a wildcard is unsupported
24
+ * for individual host sharing: there is no single concrete hostname to
25
+ * derive from it, and inventing one would be a guess this module must never
26
+ * make. */
27
+ function isWildcard(host) {
28
+ return host.includes('*');
29
+ }
30
+ /**
31
+ * Derive `{ primary, additional }` from DDEV's effective config (the parsed
32
+ * `ddev utility configyaml` object, or a pre-parsed equivalent). Throws
33
+ * `DdevHostsError` on missing/invalid required fields, a wildcard host, or a
34
+ * collision between two inputs that normalize to the same host — never
35
+ * silently drops or invents one.
36
+ */
37
+ export function parseDdevHosts(config) {
38
+ if (typeof config !== 'object' || config === null) {
39
+ throw new DdevHostsError('ddev config is not an object');
40
+ }
41
+ const c = config;
42
+ const name = c.name;
43
+ const projectTld = c.project_tld;
44
+ if (typeof name !== 'string' || !name) {
45
+ throw new DdevHostsError('ddev config is missing `name`');
46
+ }
47
+ if (typeof projectTld !== 'string' || !projectTld) {
48
+ throw new DdevHostsError('ddev config is missing `project_tld`');
49
+ }
50
+ const additionalHostnames = Array.isArray(c.additional_hostnames)
51
+ ? c.additional_hostnames.filter((h) => typeof h === 'string' && h.length > 0)
52
+ : [];
53
+ const additionalFqdns = Array.isArray(c.additional_fqdns)
54
+ ? c.additional_fqdns.filter((h) => typeof h === 'string' && h.length > 0)
55
+ : [];
56
+ const primaryRaw = `${name}.${projectTld}`;
57
+ if (isWildcard(primaryRaw)) {
58
+ throw new DdevHostsError('the primary host resolves to a wildcard — unsupported for individual host sharing');
59
+ }
60
+ const primary = normalizeHost(primaryRaw);
61
+ // Order of derivation: additional_hostnames first (expanded with
62
+ // project_tld), then additional_fqdns (literal) — a stable, input-order-
63
+ // independent derivation (reordering either source list must not change
64
+ // the resulting set), checked below by normalized-key collision, not by
65
+ // list position.
66
+ const rawAdditional = [
67
+ ...additionalHostnames.map((h) => `${h}.${projectTld}`),
68
+ ...additionalFqdns,
69
+ ];
70
+ for (const host of rawAdditional) {
71
+ if (isWildcard(host)) {
72
+ throw new DdevHostsError(`additional host '${host}' is a wildcard — unsupported for individual host sharing`);
73
+ }
74
+ }
75
+ const seen = new Map();
76
+ const additional = [];
77
+ for (const raw of rawAdditional) {
78
+ const normalized = normalizeHost(raw);
79
+ if (normalized === primary)
80
+ continue; // already covered by the primary claim
81
+ const prior = seen.get(normalized);
82
+ if (prior !== undefined && prior !== raw) {
83
+ throw new DdevHostsError(`two hosts normalize to the same value '${normalized}': '${prior}' and '${raw}'`);
84
+ }
85
+ if (prior === undefined) {
86
+ seen.set(normalized, raw);
87
+ additional.push(normalized);
88
+ }
89
+ }
90
+ return { primary, additional };
91
+ }
92
+ /**
93
+ * Discover this worktree's effective DDEV hosts via `ddev utility configyaml`
94
+ * (DDEV's own merged config, YAML on stdout) — parsed with the project's
95
+ * existing `yaml` dependency, never a hand-written parser. A failed command,
96
+ * unparseable YAML, or any `parseDdevHosts` rejection all become `{ ok:
97
+ * false }` — never a thrown exception, so a caller converts this directly to
98
+ * an `unknown` claim instead of crashing discovery.
99
+ */
100
+ export async function discoverDdevHosts(worktree, deps) {
101
+ let result;
102
+ try {
103
+ result = await deps.run('ddev', ['utility', 'configyaml'], worktree);
104
+ }
105
+ catch (err) {
106
+ return {
107
+ ok: false,
108
+ reason: `ddev utility configyaml failed: ${err instanceof Error ? err.message : String(err)}`,
109
+ };
110
+ }
111
+ if (!result.ok) {
112
+ return { ok: false, reason: 'ddev utility configyaml failed' };
113
+ }
114
+ let parsed;
115
+ try {
116
+ parsed = parseYaml(result.stdout);
117
+ }
118
+ catch (err) {
119
+ return {
120
+ ok: false,
121
+ reason: `ddev utility configyaml output is not valid YAML: ${err instanceof Error ? err.message : String(err)}`,
122
+ };
123
+ }
124
+ try {
125
+ return { ok: true, hosts: parseDdevHosts(parsed) };
126
+ }
127
+ catch (err) {
128
+ return {
129
+ ok: false,
130
+ reason: err instanceof Error ? err.message : String(err),
131
+ };
132
+ }
133
+ }
@@ -0,0 +1,6 @@
1
+ export { parseSocketListeners, type SocketListener, } from '@bakers-build/addon-docker-compose';
2
+ export { claimDdev } from './claims.js';
3
+ export { discoverDdevPorts, parseExposeMapping, parseWebExtraExposedPorts, } from './discovery.js';
4
+ export type { DdevHosts, DdevHostsDeps, DdevHostsResult } from './hosts.js';
5
+ export { DdevHostsError, discoverDdevHosts, normalizeHost, parseDdevHosts, } from './hosts.js';
6
+ export type { ClaimContext, ClaimedDiscovery, ClaimedService, ClaimStatus, HttpProbe, RawDiagnostic, RawDiscovery, RawPort, Tri, TunnelRule, } from './types.js';
@@ -0,0 +1,8 @@
1
+ // BB-676 pass-6 review cleanup — `parseSocketListeners`/`SocketListener`
2
+ // live in `@bakers-build/addon-docker-compose` (the lower-level package); routed
3
+ // through here because `@bakers-build/addon-ports` may not edge to
4
+ // `@bakers-build/addon-docker-compose` directly (see `scripts/assert-acyclic.mjs`).
5
+ export { parseSocketListeners, } from '@bakers-build/addon-docker-compose';
6
+ export { claimDdev } from './claims.js';
7
+ export { discoverDdevPorts, parseExposeMapping, parseWebExtraExposedPorts, } from './discovery.js';
8
+ export { DdevHostsError, discoverDdevHosts, normalizeHost, parseDdevHosts, } from './hosts.js';
@@ -0,0 +1,133 @@
1
+ import type { DdevHosts } from './hosts.js';
2
+ export type { DdevHosts } from './hosts.js';
3
+ /** One OBSERVATION, not yet a claimed service. A port alone is not an
4
+ * identity (two containers, two site rules can share one). `RawPort`
5
+ * carries every piece of evidence a claimer needs, and a missing probe is
6
+ * `unknown` — never collapsed into `false` — so a permission-denied Docker
7
+ * query cannot read as "this is dead" and revoke a route that is actually
8
+ * still live. */
9
+ export type Tri = boolean | 'unknown';
10
+ export interface RawPort {
11
+ readonly worktree: string;
12
+ readonly containerId?: string;
13
+ readonly containerPort?: number;
14
+ readonly protocol?: 'tcp' | 'udp';
15
+ readonly hostAddress?: string;
16
+ readonly hostPort?: number;
17
+ /** The in-container bind address a listening socket reported, when known —
18
+ * used to tell Docker's embedded DNS resolver (`127.0.0.11`) apart from a
19
+ * real service when picking a dead claim's "what IS listening" sibling. */
20
+ readonly address?: string;
21
+ readonly pid?: number;
22
+ readonly cmdline?: string;
23
+ readonly cwd?: string;
24
+ readonly cwdDeleted?: boolean;
25
+ /** The container's IP on the `ddev_default` network, when it has one. */
26
+ readonly containerIp?: string;
27
+ /** The router-side port of an `HTTP_EXPOSE`/`HTTPS_EXPOSE` mapping. */
28
+ readonly routerPort?: number;
29
+ /** Which `*_EXPOSE` var produced this row's router mapping — the only
30
+ * reliable signal for the target scheme (B): a port that only appears in
31
+ * `HTTP_EXPOSE` must be dialled over plain HTTP, never HTTPS. */
32
+ readonly exposeScheme?: 'http' | 'https';
33
+ readonly virtualHost?: string;
34
+ /** The `com.ddev.service-type` label, when a project sets one. Real DDEV
35
+ * containers do not carry this by default — `composeService` below is the
36
+ * label that is actually present — but a project may still add it, so it
37
+ * is kept as the more specific naming source when it exists. */
38
+ readonly serviceType?: string;
39
+ /** The `com.docker.compose.service` label (`web`, `mailpit`, `phpmyadmin`,
40
+ * `opensearch`, …) — the naming evidence DDEV/compose actually sets on
41
+ * every service container; naming evidence for the claimer. */
42
+ readonly composeService?: string;
43
+ /** `supervisorctl`'s `group:name`, e.g. `webextradaemons:xhgui`. */
44
+ readonly daemon?: string;
45
+ readonly daemonStatus?: 'RUNNING' | 'STOPPED' | 'EXITED' | 'UNKNOWN';
46
+ readonly containerState?: 'running' | 'stopped' | 'unknown';
47
+ /** A real socket was observed bound to this port. */
48
+ readonly listening: Tri;
49
+ /** A router/DDEV rule claims this port, regardless of whether it listens. */
50
+ readonly exposed: Tri;
51
+ /** The host-bound port `docker inspect`'s `NetworkSettings.Ports` maps this
52
+ * `containerPort` to (BB-500 passthrough target), when published. */
53
+ readonly hostBoundPort?: number;
54
+ }
55
+ export interface RawDiagnostic {
56
+ readonly source: 'host' | 'docker' | 'web-sockets' | 'supervisor' | 'ddev-hosts';
57
+ readonly message: string;
58
+ /** D: the container this diagnostic is about, when it is about one in
59
+ * particular — see `ComposeDiagnostic.containerId` (`@bakers-build/addon-docker-compose`)
60
+ * for why this narrows reap's blast radius instead of widening it. */
61
+ readonly containerId?: string;
62
+ /** C: true when this `source: 'host'` diagnostic means the WHOLE host
63
+ * probe (`ss -ltnpH`) failed, blinding every host-backed service at once
64
+ * — the host-side equivalent of a `containerId`-less Docker diagnostic.
65
+ * A per-listener host diagnostic (e.g. "no visible owner") is about one
66
+ * socket, not the whole probe, and must not carry this flag. */
67
+ readonly wholePass?: boolean;
68
+ }
69
+ export interface RawDiscovery {
70
+ readonly ports: readonly RawPort[];
71
+ readonly diagnostics: readonly RawDiagnostic[];
72
+ }
73
+ export interface TunnelRule {
74
+ readonly key: string;
75
+ readonly service: string;
76
+ readonly hostHeader?: string;
77
+ readonly insecureTls?: boolean;
78
+ /** Plain-HTTP origin of the service container itself
79
+ * (`http://<ddev_default IP>:<containerPort>`), reachable with the public
80
+ * Host kept; only transports that can dial container IPs (frp) use it. */
81
+ readonly direct?: string;
82
+ }
83
+ export type ClaimStatus = 'healthy' | 'dead' | 'stopped' | 'not-exposed' | 'unknown';
84
+ export interface ClaimedService {
85
+ readonly key: string;
86
+ /** The claimer that owns this service — `'process'` (a configured ports
87
+ * rule), `'node'` (the all-Node rule) or `'ddev'`. */
88
+ readonly claim: string;
89
+ readonly status: ClaimStatus;
90
+ /** A local address worth opening on this machine. */
91
+ readonly local: string;
92
+ /** Set when the service is shareable but with a known gap (e.g. no login). */
93
+ readonly limitation?: string;
94
+ /** The raw evidence this claim rests on, for `list` and diagnostics. */
95
+ readonly rawPorts: readonly RawPort[];
96
+ /** Empty unless `status === 'healthy'`. */
97
+ readonly rules: readonly TunnelRule[];
98
+ }
99
+ export interface ClaimedDiscovery {
100
+ readonly services: readonly ClaimedService[];
101
+ /** Surfaced raw-discovery diagnostics — a caller (reap) must be able to
102
+ * tell "nothing is here" from "the probe itself could not see", so a
103
+ * denied Docker query cannot read as every service having vanished. */
104
+ readonly diagnostics: readonly RawDiagnostic[];
105
+ /** Every raw identity no claimer picked up — `list` shows these dimmed,
106
+ * literally "no addon" (spec): visible, but never shareable. */
107
+ readonly unclaimed: readonly RawPort[];
108
+ }
109
+ /** A single bounded, non-redirecting HTTP probe — injected, never a real fetch here. */
110
+ export type HttpProbe = (url: string, opts: {
111
+ readonly timeoutMs: number;
112
+ }) => Promise<{
113
+ readonly ok: boolean;
114
+ readonly status: number;
115
+ readonly body: string;
116
+ } | null>;
117
+ export interface ClaimContext {
118
+ readonly worktree: string;
119
+ readonly ticket: string;
120
+ readonly zone: string;
121
+ readonly probe: HttpProbe;
122
+ /** `.ddev/config.yaml`'s `web_extra_exposed_ports[].name`, keyed by
123
+ * `container_port` — the declared name for a non-primary-web service
124
+ * (vite, xhgui, …) sharing the web container, so a dead/live claim on that
125
+ * port surfaces under its configured name instead of the web container's
126
+ * generic `web` compose-service label. */
127
+ readonly webExtraExposedPortNames?: ReadonlyMap<number, string>;
128
+ /** BB-681 Task 1 — the project's effective DDEV hosts (primary +
129
+ * additional), from `ddev utility configyaml`. Absent/unparseable config
130
+ * means no individual-host splitting is attempted — the primary `web`
131
+ * claim alone still works, never invented from a guess. */
132
+ readonly hosts?: DdevHosts;
133
+ }
@@ -0,0 +1,8 @@
1
+ // BB-676 spec v2 — the claim contract, owned here since DDEV is the shape's
2
+ // primary producer (routerPort/virtualHost/daemon/daemonStatus/containerState
3
+ // all come from DDEV conventions). `@bakers-build/addon-ports` imports these types
4
+ // (its Storybook claimer also produces `RawPort`-shaped values, for host
5
+ // processes) rather than redeclaring them, so the two packages share one
6
+ // contract; the dependency edge runs addon-ports -> addon-ddev only (the
7
+ // acyclic package graph permits no edge back).
8
+ export {};
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-ddev",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.42",
4
+ "description": "DDEV-specific service discovery and claiming: HTTP(S)_EXPOSE/VIRTUAL_HOST interpretation over @bakers-build/addon-docker-compose's generic container data, plus effective-host discovery via `ddev utility configyaml`. No CMS-specific policy lives here.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/index.js"
9
+ },
10
+ "files": [
11
+ "dist/src"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
19
+ "directory": "bakers-cli/addons/ddev"
20
+ },
21
+ "dependencies": {
22
+ "@bakers-build/addon-docker-compose": "^0.1.0-next.42",
23
+ "yaml": "^2.7.0"
24
+ },
25
+ "peerDependencies": {
26
+ "@bakers-build/core": "^0.1.0-next.42"
27
+ }
28
+ }