@wenrwa/marketplace-cli 0.8.8 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * tp-06 (docs/prd/team-preview.md module 4, ADR-0014) — the Team preview
3
+ * forwarder: the tenant boundary between the shared preview gateway and the
4
+ * owner's machine.
5
+ *
6
+ * Members reach the owner's running `main` only through the preview gateway
7
+ * (a shared Fly app). The gateway dials this machine over Fly's private
8
+ * network (6PN), which is organisation-wide: every user's machine can reach
9
+ * every other user's machine over it. So this module:
10
+ *
11
+ * - listens on ONE port, on the machine's own 6PN address (never a
12
+ * wildcard), and
13
+ * - forwards a connection ONLY when it comes from the gateway's address
14
+ * (configured as an address or a `.internal` name, resolved and pinned),
15
+ * - and ONLY to `127.0.0.1:<preview port>` — the port the tp-05 preview
16
+ * checkout serves on. The target is fixed at construction; nothing a
17
+ * peer sends can choose another port.
18
+ *
19
+ * Every other connection is destroyed before a byte is read from it. HTTP and
20
+ * WebSocket upgrades both pass, because this is a plain TCP pipe.
21
+ *
22
+ * Modelled on bws-08's companionForwarder.ts, with the same crash discipline:
23
+ * nothing here may throw synchronously, emit an unhandled 'error', or reject
24
+ * unhandled into the bridge's crash handlers (installBridgeCrashHandlers,
25
+ * twr-05).
26
+ *
27
+ * TWO-LANE (ADR-0014): hosted only. The local bridge must never open this
28
+ * listener — a laptop has no 6PN address and opening it to outside traffic
29
+ * would expose the laptop. buildPreviewForwarder returns a no-op whenever
30
+ * there is no `fly-local-6pn` entry, and it is only ever called from the
31
+ * container (`--relay`) branch of commands/bridge.ts.
32
+ */
33
+ import * as net from 'net';
34
+ /** The fixed port the preview app serves on, on 127.0.0.1 only (tp-05 starts
35
+ * the preview's `start` script with this PORT). Distinct from the usual dev
36
+ * ports so it never collides with the owner's own dev server. */
37
+ export declare const PREVIEW_PORT_DEFAULT = 4800;
38
+ /** The port the forwarder listens on, on the machine's 6PN address. The
39
+ * gateway (tp-08 `target`, tp-10) dials `[<6pn>]:<this>`. */
40
+ export declare const PREVIEW_FORWARDER_PORT_DEFAULT = 4801;
41
+ /** How often a `.internal` gateway name is re-resolved. Between refreshes the
42
+ * allowlist is pinned: no DNS lookup ever happens per connection. */
43
+ export declare const PREVIEW_GATEWAY_REFRESH_MS_DEFAULT: number;
44
+ export interface PreviewPorts {
45
+ previewPort: number;
46
+ forwarderPort: number;
47
+ }
48
+ /** Env knobs `HT_PREVIEW_PORT` / `HT_PREVIEW_FORWARDER_PORT`. The forwarder
49
+ * never shares the preview port: a preview app that (wrongly) binds a
50
+ * wildcard address would otherwise make the bind fail. */
51
+ export declare function resolvePreviewPorts(env?: Record<string, string | undefined>): PreviewPorts;
52
+ /**
53
+ * One canonical spelling per IP so the allowlist compare can't be dodged by
54
+ * formatting: IPv4 as dotted quad, IPv6 fully expanded to eight lower-case
55
+ * 4-digit groups. An IPv4-mapped IPv6 peer (`::ffff:1.2.3.4`) is unwrapped to
56
+ * its IPv4 form, and a zone id (`%eth0`) is dropped. Anything that isn't an
57
+ * IP (a host name, garbage) → null, which never matches.
58
+ */
59
+ export declare function canonicalAddress(addr: string | null | undefined): string | null;
60
+ export type GatewayLookup = (name: string) => Promise<string[]>;
61
+ export interface PreviewForwarderDeps {
62
+ /** The machine's own 6PN address in production (`fly-local-6pn`); `::1`
63
+ * or `::` in tests. Never a wildcard in production. */
64
+ listenHost: string;
65
+ /** 0 picks an ephemeral port (tests only). */
66
+ listenPort: number;
67
+ /** The ONE port anything is ever forwarded to, always on 127.0.0.1. */
68
+ targetPort: number;
69
+ /** The gateway: IP addresses and/or names (a Fly `.internal` name). Names
70
+ * are resolved at start() and pinned; see refreshMs. */
71
+ allowed: string[];
72
+ lookup?: GatewayLookup;
73
+ /** Re-resolve names this often (a gateway machine replaced by a deploy gets
74
+ * a new 6PN address). A failed refresh keeps the pinned set. */
75
+ refreshMs?: number;
76
+ setIntervalFn?: (cb: () => void, ms: number) => unknown;
77
+ clearIntervalFn?: (handle: unknown) => void;
78
+ net?: Pick<typeof net, 'createServer' | 'createConnection'>;
79
+ /** Reads the peer address off an accepted socket. Injectable so loopback
80
+ * tests can play a 6PN peer. Default: `socket.remoteAddress`. */
81
+ remoteAddressOf?: (socket: net.Socket) => string | undefined;
82
+ onBindError?: (err: Error) => void;
83
+ onResolveError?: (name: string, err: Error) => void;
84
+ }
85
+ export interface PreviewForwarder {
86
+ /** Resolves the allowlist, then binds. Resolves once listening (or once a
87
+ * bind error was reported). Never rejects. */
88
+ start(): Promise<void>;
89
+ /** Closes the listener and stops refreshing. Safe to call more than once. */
90
+ stop(): void;
91
+ /** The bound port, or null when not listening. */
92
+ port(): number | null;
93
+ /** The pinned allowlist, canonical spellings. */
94
+ allowedAddresses(): string[];
95
+ }
96
+ export declare function createPreviewForwarder(deps: PreviewForwarderDeps): PreviewForwarder;
97
+ /** Same interface, zero behaviour — the local lane and unconfigured boxes. */
98
+ export declare function createNoopPreviewForwarder(): PreviewForwarder;
99
+ export interface BuildPreviewForwarderOpts {
100
+ env?: Record<string, string | undefined>;
101
+ /** Reads /etc/hosts. Throwing (no such file) means "not a Fly machine". */
102
+ readHosts: () => string;
103
+ net?: Pick<typeof net, 'createServer' | 'createConnection'>;
104
+ lookup?: GatewayLookup;
105
+ log?: (...args: unknown[]) => void;
106
+ }
107
+ /**
108
+ * The one place the lane decision is made. A real forwarder only when BOTH:
109
+ * - this is a Fly machine (`fly-local-6pn` in /etc/hosts) — the local
110
+ * bridge never has one, so it never binds (ADR-0014: Team preview is
111
+ * hosted-only; a laptop must not be opened to outside traffic), and
112
+ * - `HT_PREVIEW_GATEWAY` names the gateway (comma-separated addresses or
113
+ * `.internal` names). Unset = off: the feature stays inert until the
114
+ * gateway exists (tp-11) and the image is released (tp-12).
115
+ */
116
+ export declare function buildPreviewForwarder(opts: BuildPreviewForwarderOpts): PreviewForwarder;
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.PREVIEW_GATEWAY_REFRESH_MS_DEFAULT = exports.PREVIEW_FORWARDER_PORT_DEFAULT = exports.PREVIEW_PORT_DEFAULT = void 0;
37
+ exports.resolvePreviewPorts = resolvePreviewPorts;
38
+ exports.canonicalAddress = canonicalAddress;
39
+ exports.createPreviewForwarder = createPreviewForwarder;
40
+ exports.createNoopPreviewForwarder = createNoopPreviewForwarder;
41
+ exports.buildPreviewForwarder = buildPreviewForwarder;
42
+ /**
43
+ * tp-06 (docs/prd/team-preview.md module 4, ADR-0014) — the Team preview
44
+ * forwarder: the tenant boundary between the shared preview gateway and the
45
+ * owner's machine.
46
+ *
47
+ * Members reach the owner's running `main` only through the preview gateway
48
+ * (a shared Fly app). The gateway dials this machine over Fly's private
49
+ * network (6PN), which is organisation-wide: every user's machine can reach
50
+ * every other user's machine over it. So this module:
51
+ *
52
+ * - listens on ONE port, on the machine's own 6PN address (never a
53
+ * wildcard), and
54
+ * - forwards a connection ONLY when it comes from the gateway's address
55
+ * (configured as an address or a `.internal` name, resolved and pinned),
56
+ * - and ONLY to `127.0.0.1:<preview port>` — the port the tp-05 preview
57
+ * checkout serves on. The target is fixed at construction; nothing a
58
+ * peer sends can choose another port.
59
+ *
60
+ * Every other connection is destroyed before a byte is read from it. HTTP and
61
+ * WebSocket upgrades both pass, because this is a plain TCP pipe.
62
+ *
63
+ * Modelled on bws-08's companionForwarder.ts, with the same crash discipline:
64
+ * nothing here may throw synchronously, emit an unhandled 'error', or reject
65
+ * unhandled into the bridge's crash handlers (installBridgeCrashHandlers,
66
+ * twr-05).
67
+ *
68
+ * TWO-LANE (ADR-0014): hosted only. The local bridge must never open this
69
+ * listener — a laptop has no 6PN address and opening it to outside traffic
70
+ * would expose the laptop. buildPreviewForwarder returns a no-op whenever
71
+ * there is no `fly-local-6pn` entry, and it is only ever called from the
72
+ * container (`--relay`) branch of commands/bridge.ts.
73
+ */
74
+ const net = __importStar(require("net"));
75
+ const dns = __importStar(require("dns"));
76
+ const companionForwarder_1 = require("./companionForwarder");
77
+ /** The fixed port the preview app serves on, on 127.0.0.1 only (tp-05 starts
78
+ * the preview's `start` script with this PORT). Distinct from the usual dev
79
+ * ports so it never collides with the owner's own dev server. */
80
+ exports.PREVIEW_PORT_DEFAULT = 4800;
81
+ /** The port the forwarder listens on, on the machine's 6PN address. The
82
+ * gateway (tp-08 `target`, tp-10) dials `[<6pn>]:<this>`. */
83
+ exports.PREVIEW_FORWARDER_PORT_DEFAULT = 4801;
84
+ /** How often a `.internal` gateway name is re-resolved. Between refreshes the
85
+ * allowlist is pinned: no DNS lookup ever happens per connection. */
86
+ exports.PREVIEW_GATEWAY_REFRESH_MS_DEFAULT = 5 * 60_000;
87
+ function parsePort(raw) {
88
+ if (!raw)
89
+ return null;
90
+ const n = Number(raw.trim());
91
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
92
+ }
93
+ /** Env knobs `HT_PREVIEW_PORT` / `HT_PREVIEW_FORWARDER_PORT`. The forwarder
94
+ * never shares the preview port: a preview app that (wrongly) binds a
95
+ * wildcard address would otherwise make the bind fail. */
96
+ function resolvePreviewPorts(env = process.env) {
97
+ const previewPort = parsePort(env.HT_PREVIEW_PORT) ?? exports.PREVIEW_PORT_DEFAULT;
98
+ let forwarderPort = parsePort(env.HT_PREVIEW_FORWARDER_PORT) ?? exports.PREVIEW_FORWARDER_PORT_DEFAULT;
99
+ if (forwarderPort === previewPort)
100
+ forwarderPort = exports.PREVIEW_FORWARDER_PORT_DEFAULT;
101
+ return { previewPort, forwarderPort };
102
+ }
103
+ /**
104
+ * One canonical spelling per IP so the allowlist compare can't be dodged by
105
+ * formatting: IPv4 as dotted quad, IPv6 fully expanded to eight lower-case
106
+ * 4-digit groups. An IPv4-mapped IPv6 peer (`::ffff:1.2.3.4`) is unwrapped to
107
+ * its IPv4 form, and a zone id (`%eth0`) is dropped. Anything that isn't an
108
+ * IP (a host name, garbage) → null, which never matches.
109
+ */
110
+ function canonicalAddress(addr) {
111
+ if (!addr)
112
+ return null;
113
+ let a = addr.trim().toLowerCase();
114
+ const zone = a.indexOf('%');
115
+ if (zone >= 0)
116
+ a = a.slice(0, zone);
117
+ if (a.startsWith('::ffff:') && net.isIPv4(a.slice(7)))
118
+ a = a.slice(7);
119
+ if (net.isIPv4(a))
120
+ return a;
121
+ if (!net.isIPv6(a))
122
+ return null;
123
+ // Turn an embedded IPv4 tail (e.g. `64:ff9b::1.2.3.4`) into two hex groups.
124
+ const lastColon = a.lastIndexOf(':');
125
+ const tail = a.slice(lastColon + 1);
126
+ if (net.isIPv4(tail)) {
127
+ const [b0, b1, b2, b3] = tail.split('.').map(Number);
128
+ a = `${a.slice(0, lastColon + 1)}${((b0 << 8) | b1).toString(16)}:${((b2 << 8) | b3).toString(16)}`;
129
+ }
130
+ const [head, rest] = a.includes('::') ? a.split('::') : [a, null];
131
+ const headGroups = head ? head.split(':') : [];
132
+ const restGroups = rest ? rest.split(':') : [];
133
+ const fill = rest === null ? [] : new Array(8 - headGroups.length - restGroups.length).fill('0');
134
+ const groups = [...headGroups, ...fill, ...restGroups];
135
+ if (groups.length !== 8)
136
+ return null;
137
+ return groups.map((g) => g.padStart(4, '0')).join(':');
138
+ }
139
+ const defaultLookup = async (name) => {
140
+ const results = await dns.promises.lookup(name, { all: true });
141
+ return results.map((r) => r.address);
142
+ };
143
+ function createPreviewForwarder(deps) {
144
+ const netImpl = deps.net ?? net;
145
+ const lookup = deps.lookup ?? defaultLookup;
146
+ const scheduleInterval = deps.setIntervalFn ?? ((cb, ms) => setInterval(cb, ms));
147
+ const cancelInterval = deps.clearIntervalFn ?? ((h) => clearInterval(h));
148
+ const remoteAddressOf = deps.remoteAddressOf ?? ((s) => s.remoteAddress);
149
+ // Fixed here, once: the only destination this module can ever dial.
150
+ const TARGET = Object.freeze({ host: '127.0.0.1', port: deps.targetPort });
151
+ const literal = new Set();
152
+ const names = [];
153
+ for (const entry of deps.allowed) {
154
+ const trimmed = entry.trim();
155
+ if (!trimmed)
156
+ continue;
157
+ const c = canonicalAddress(trimmed);
158
+ if (c)
159
+ literal.add(c);
160
+ else
161
+ names.push(trimmed);
162
+ }
163
+ // The pinned per-name resolutions. Empty until resolved: fail closed.
164
+ const resolved = new Map();
165
+ let allowSet = new Set(literal);
166
+ const rebuild = () => {
167
+ const next = new Set(literal);
168
+ for (const set of resolved.values())
169
+ for (const a of set)
170
+ next.add(a);
171
+ allowSet = next;
172
+ };
173
+ let server = null;
174
+ let refreshHandle = null;
175
+ let stopped = false;
176
+ const resolveNames = async () => {
177
+ await Promise.all(names.map(async (name) => {
178
+ try {
179
+ const addrs = (await lookup(name)).map(canonicalAddress).filter((a) => !!a);
180
+ if (addrs.length > 0)
181
+ resolved.set(name, new Set(addrs));
182
+ }
183
+ catch (err) {
184
+ // Keep whatever was pinned before (nothing, on the first try).
185
+ deps.onResolveError?.(name, err instanceof Error ? err : new Error(String(err)));
186
+ }
187
+ }));
188
+ rebuild();
189
+ };
190
+ const onConnection = (socket) => {
191
+ const remote = canonicalAddress(remoteAddressOf(socket));
192
+ if (!remote || !allowSet.has(remote)) {
193
+ // THE tenant-boundary line. An accepted socket is paused, so destroying
194
+ // it here means a refused peer's bytes are never read.
195
+ socket.destroy();
196
+ return;
197
+ }
198
+ const upstream = netImpl.createConnection({ host: TARGET.host, port: TARGET.port });
199
+ const teardown = () => {
200
+ socket.destroy();
201
+ upstream.destroy();
202
+ };
203
+ // Both legs need an 'error' listener: an unhandled one would throw into
204
+ // the bridge's crash handlers (a preview that's mid-restart is an
205
+ // ordinary ECONNREFUSED, not a crash).
206
+ socket.on('error', teardown);
207
+ upstream.on('error', teardown);
208
+ socket.pipe(upstream);
209
+ upstream.pipe(socket);
210
+ };
211
+ const bind = () => new Promise((resolve) => {
212
+ let s;
213
+ try {
214
+ s = netImpl.createServer(onConnection);
215
+ }
216
+ catch (err) {
217
+ deps.onBindError?.(err instanceof Error ? err : new Error(String(err)));
218
+ resolve();
219
+ return;
220
+ }
221
+ server = s;
222
+ s.on('error', (err) => {
223
+ // Bind failures (EADDRINUSE, EADDRNOTAVAIL) arrive here, async.
224
+ if (server === s)
225
+ server = null;
226
+ deps.onBindError?.(err);
227
+ resolve();
228
+ });
229
+ s.listen(deps.listenPort, deps.listenHost, () => resolve());
230
+ });
231
+ return {
232
+ async start() {
233
+ if (stopped || server)
234
+ return;
235
+ await resolveNames();
236
+ if (stopped)
237
+ return;
238
+ if (names.length > 0 && refreshHandle === null) {
239
+ refreshHandle = scheduleInterval(() => {
240
+ void resolveNames();
241
+ }, deps.refreshMs ?? exports.PREVIEW_GATEWAY_REFRESH_MS_DEFAULT);
242
+ refreshHandle?.unref?.();
243
+ }
244
+ await bind();
245
+ },
246
+ stop() {
247
+ stopped = true;
248
+ if (refreshHandle !== null) {
249
+ cancelInterval(refreshHandle);
250
+ refreshHandle = null;
251
+ }
252
+ const s = server;
253
+ server = null;
254
+ if (s) {
255
+ try {
256
+ s.close();
257
+ }
258
+ catch {
259
+ // already closed
260
+ }
261
+ }
262
+ },
263
+ port() {
264
+ const addr = server?.address();
265
+ return addr && typeof addr === 'object' ? addr.port : null;
266
+ },
267
+ allowedAddresses() {
268
+ return Array.from(allowSet);
269
+ },
270
+ };
271
+ }
272
+ /** Same interface, zero behaviour — the local lane and unconfigured boxes. */
273
+ function createNoopPreviewForwarder() {
274
+ return {
275
+ start: async () => { },
276
+ stop: () => { },
277
+ port: () => null,
278
+ allowedAddresses: () => [],
279
+ };
280
+ }
281
+ /**
282
+ * The one place the lane decision is made. A real forwarder only when BOTH:
283
+ * - this is a Fly machine (`fly-local-6pn` in /etc/hosts) — the local
284
+ * bridge never has one, so it never binds (ADR-0014: Team preview is
285
+ * hosted-only; a laptop must not be opened to outside traffic), and
286
+ * - `HT_PREVIEW_GATEWAY` names the gateway (comma-separated addresses or
287
+ * `.internal` names). Unset = off: the feature stays inert until the
288
+ * gateway exists (tp-11) and the image is released (tp-12).
289
+ */
290
+ function buildPreviewForwarder(opts) {
291
+ const env = opts.env ?? process.env;
292
+ const log = opts.log ?? console.log;
293
+ let listenHost = null;
294
+ try {
295
+ listenHost = (0, companionForwarder_1.resolveFlyLocal6pnHost)(opts.readHosts());
296
+ }
297
+ catch {
298
+ listenHost = null;
299
+ }
300
+ if (!listenHost) {
301
+ log('[bridge] preview forwarder off: not a hosted machine (ADR-0014)');
302
+ return createNoopPreviewForwarder();
303
+ }
304
+ const allowed = (env.HT_PREVIEW_GATEWAY || '')
305
+ .split(',')
306
+ .map((s) => s.trim())
307
+ .filter(Boolean);
308
+ if (allowed.length === 0) {
309
+ log('[bridge] preview forwarder off: HT_PREVIEW_GATEWAY not set');
310
+ return createNoopPreviewForwarder();
311
+ }
312
+ const { previewPort, forwarderPort } = resolvePreviewPorts(env);
313
+ return createPreviewForwarder({
314
+ listenHost,
315
+ listenPort: forwarderPort,
316
+ targetPort: previewPort,
317
+ allowed,
318
+ lookup: opts.lookup,
319
+ net: opts.net,
320
+ onBindError: (err) => console.error(`[bridge] preview forwarder: failed to bind ${forwarderPort} on ${listenHost}:`, err.message),
321
+ onResolveError: (name, err) => console.error(`[bridge] preview forwarder: cannot resolve ${name}:`, err.message),
322
+ });
323
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * tp-05 — the Team preview relay frames (docs/prd/team-preview.md module 5).
3
+ *
4
+ * Server → bridge, each correlated by `requestId` (the `ensure-checkout`
5
+ * pattern). Workspace-addressed, open NO PTY, so — like `ensure-checkout` —
6
+ * they are absent from SESSION_ADDRESSED_FRAMES. None carries a branch: the
7
+ * preview is pinned to the remote's default branch (ADR-0014).
8
+ *
9
+ * preview-start { requestId, workspaceId, repoUrl?, gitCredential?, mode? }
10
+ * preview-update { requestId, workspaceId, repoUrl?, gitCredential?, mode? }
11
+ * preview-stop { requestId, workspaceId }
12
+ * preview-logs { requestId, workspaceId }
13
+ *
14
+ * Bridge → server:
15
+ * preview-result { requestId, workspaceId, phase, ok, status, … }
16
+ * start/update answer `phase: 'accepted'` at once (a build takes minutes;
17
+ * the backend must not hold a request open for it), then `phase: 'done'`
18
+ * with the outcome. stop answers `done` only.
19
+ * preview-logs-result { requestId, workspaceId, ok, log, truncated }
20
+ *
21
+ * LOCAL LANE — scoped out in writing (ADR-0014, two-lane rule). A laptop's
22
+ * bridge listens only on the laptop, laptops sleep, and reaching one from the
23
+ * gateway would mean opening it to the internet. So every preview frame that
24
+ * reaches a local bridge (its `--crew-relay` dial) gets the typed answer
25
+ * `{ error: 'unsupported', reason: 'local_lane' }` — never a silent drop the
26
+ * backend would have to time out on.
27
+ */
28
+ import type { PreviewMode, PreviewRunner } from './previewCheckout';
29
+ export declare const PREVIEW_FRAME_TYPES: ReadonlySet<string>;
30
+ export type PreviewFrame = {
31
+ t: 'preview-start' | 'preview-update';
32
+ requestId: string;
33
+ workspaceId: string;
34
+ repoUrl?: string;
35
+ gitCredential?: {
36
+ token: string;
37
+ repoFullName?: string;
38
+ expiresAt?: string;
39
+ };
40
+ mode?: PreviewMode;
41
+ } | {
42
+ t: 'preview-stop' | 'preview-logs';
43
+ requestId: string;
44
+ workspaceId: string;
45
+ };
46
+ export type PreviewLane =
47
+ /** The hosted container's bridge with a runner wired. */
48
+ {
49
+ kind: 'hosted';
50
+ runner: PreviewRunner;
51
+ }
52
+ /** A laptop bridge (ADR-0014: out of scope). */
53
+ | {
54
+ kind: 'local';
55
+ }
56
+ /** A hosted bridge that has no runner (should not happen once wired). */
57
+ | {
58
+ kind: 'none';
59
+ };
60
+ type Send = (frame: Record<string, unknown>) => void;
61
+ /** Answer one preview frame. Never throws; every path sends a typed reply. */
62
+ export declare function handlePreviewFrame(frame: PreviewFrame, lane: PreviewLane, send: Send): void;
63
+ export {};
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PREVIEW_FRAME_TYPES = void 0;
4
+ exports.handlePreviewFrame = handlePreviewFrame;
5
+ exports.PREVIEW_FRAME_TYPES = new Set([
6
+ 'preview-start',
7
+ 'preview-update',
8
+ 'preview-stop',
9
+ 'preview-logs',
10
+ ]);
11
+ function replyType(t) {
12
+ return t === 'preview-logs' ? 'preview-logs-result' : 'preview-result';
13
+ }
14
+ function resultFrame(requestId, phase, r) {
15
+ const ok = r.status === 'running' || r.status === 'stopped' || r.status === 'building';
16
+ return { t: 'preview-result', requestId, phase, ok, ...r };
17
+ }
18
+ /** Answer one preview frame. Never throws; every path sends a typed reply. */
19
+ function handlePreviewFrame(frame, lane, send) {
20
+ const { requestId, workspaceId } = frame;
21
+ const t = replyType(frame.t);
22
+ if (lane.kind === 'local') {
23
+ // ADR-0014 — the local lane is out of scope; say so, typed.
24
+ send({ t, requestId, workspaceId, ok: false, ...(t === 'preview-result' ? { phase: 'done' } : {}), error: 'unsupported', reason: 'local_lane' });
25
+ return;
26
+ }
27
+ if (lane.kind === 'none') {
28
+ send({ t, requestId, workspaceId, ok: false, ...(t === 'preview-result' ? { phase: 'done' } : {}), error: 'unsupported', reason: 'bridge-cannot-preview' });
29
+ return;
30
+ }
31
+ const { runner } = lane;
32
+ const failed = (err) => send({
33
+ t: 'preview-result', requestId, workspaceId, phase: 'done', ok: false, status: 'failed',
34
+ message: err instanceof Error ? err.message.slice(0, 200) : 'Preview failed.',
35
+ });
36
+ switch (frame.t) {
37
+ case 'preview-start':
38
+ case 'preview-update': {
39
+ const req = {
40
+ workspaceId,
41
+ repoUrl: frame.repoUrl,
42
+ gitCredential: frame.gitCredential,
43
+ mode: frame.mode === 'dev' ? 'dev' : frame.mode === 'built' ? 'built' : undefined,
44
+ };
45
+ send(resultFrame(requestId, 'accepted', { ...runner.status(workspaceId), status: 'building' }));
46
+ const op = frame.t === 'preview-start' ? runner.start(req) : runner.update(req);
47
+ op.then((r) => send(resultFrame(requestId, 'done', r)), failed);
48
+ return;
49
+ }
50
+ case 'preview-stop':
51
+ runner.stop({ workspaceId }).then((r) => send(resultFrame(requestId, 'done', r)), failed);
52
+ return;
53
+ case 'preview-logs': {
54
+ const { log, truncated } = runner.logs({ workspaceId });
55
+ send({ t: 'preview-logs-result', requestId, workspaceId, ok: true, log, truncated });
56
+ return;
57
+ }
58
+ }
59
+ }
@@ -10,6 +10,7 @@ import { GitRun } from './changesReporter';
10
10
  import { buildChangesReport as buildChangesReportReal } from './changesReport';
11
11
  import { CheckoutInventoryDeps } from './checkoutInventory';
12
12
  import type { CheckoutResult } from './repoCheckout';
13
+ import type { PreviewRunner } from './previewCheckout';
13
14
  /**
14
15
  * fcct-27 — typed refusal when `resolveLaunch` names a binary this machine does
15
16
  * not have. Typed (like CONTAINER_MEMORY_EXHAUSTED) so the conductor renders a
@@ -144,6 +145,14 @@ export interface RelayClientOpts {
144
145
  repoUrl?: string;
145
146
  gitCredential?: InjectedGitCredential;
146
147
  }) => Promise<CheckoutResult>;
148
+ /**
149
+ * tp-05 — the Team preview runner (previewCheckout.ts). Wired on the HOSTED
150
+ * container bridge only. When `onlyRelayOwnedSessions` is set (a laptop's
151
+ * crew-relay dial) every preview frame is answered `unsupported /
152
+ * local_lane` regardless (ADR-0014 scopes the local lane out); absent on a
153
+ * hosted bridge → `unsupported / bridge-cannot-preview`.
154
+ */
155
+ preview?: PreviewRunner;
147
156
  /**
148
157
  * fcct-27 — what a relay-opened session actually SPAWNS.
149
158
  *
@@ -330,6 +339,12 @@ export interface RelayClientOpts {
330
339
  ok: false;
331
340
  reason: 'not-found' | 'invalid-agent-id' | 'too-large' | 'transcript-unavailable';
332
341
  };
342
+ /**
343
+ * tp-02 — resolve a workspace id to the checkout root whose `.env` names
344
+ * the Keys list shows (bridge.ts: `<reposBaseDir>/<workspaceId>`). Absent →
345
+ * an `env-names` frame gets a typed refusal, never a hang.
346
+ */
347
+ resolveEnvNamesRoot?: (workspaceId: string) => string | null | undefined;
333
348
  /** Injectable for tests: defaults to `new WebSocket(url)` from `ws`. */
334
349
  wsFactory?: (url: string) => WebSocketLike;
335
350
  backoff?: BackoffOpts;
@@ -114,6 +114,9 @@ const diskPreflight_1 = require("./diskPreflight");
114
114
  const checkoutParkAndRemove_1 = require("./checkoutParkAndRemove");
115
115
  const attachmentWriter_1 = require("./attachmentWriter");
116
116
  const envSaver_1 = require("./envSaver");
117
+ const envNames_1 = require("./envNames");
118
+ // tp-05 — Team preview frames (ADR-0014). See previewFrames.ts.
119
+ const previewFrames_1 = require("./previewFrames");
117
120
  const PROTOCOL_VERSION = 1;
118
121
  /**
119
122
  * fcct-27 — typed refusal when `resolveLaunch` names a binary this machine does
@@ -1624,6 +1627,23 @@ function startRelayClient(opts) {
1624
1627
  .catch(() => send(socket, { t: 'transcript-content', requestId, workspaceId, ok: false, reason: 'transcript-unavailable' }));
1625
1628
  break;
1626
1629
  }
1630
+ case 'env-names': {
1631
+ // tp-02: the NAMES in this workspace's `.env`, never a value —
1632
+ // envNames.ts parses on this machine and replies names only.
1633
+ // Never logged. requestId echoed so the backend can correlate.
1634
+ if (!opts.resolveEnvNamesRoot) {
1635
+ send(socket, {
1636
+ t: 'env-names-result',
1637
+ requestId: frame.requestId,
1638
+ workspaceId: frame.workspaceId,
1639
+ ok: false,
1640
+ reason: 'bridge-cannot-read-env-names',
1641
+ });
1642
+ break;
1643
+ }
1644
+ send(socket, (0, envNames_1.handleEnvNamesFrame)(frame, opts.resolveEnvNamesRoot));
1645
+ break;
1646
+ }
1627
1647
  case 'browser-open': {
1628
1648
  // cbs-02: a headless Chromium session, driven over CDP. This
1629
1649
  // branch NEVER touches opts.sessions / SessionManager — a
@@ -1827,6 +1847,22 @@ function startRelayClient(opts) {
1827
1847
  browserDrivers.get(frame.sessionId)?.resume().catch(() => { });
1828
1848
  browserDrivers.get(frame.sessionId)?.reload().catch(() => { });
1829
1849
  break;
1850
+ case 'preview-start':
1851
+ case 'preview-update':
1852
+ case 'preview-stop':
1853
+ case 'preview-logs':
1854
+ // tp-05 — ADR-0014: a laptop bridge (the crew-relay dial, the only
1855
+ // relay client with onlyRelayOwnedSessions) is the local lane, and
1856
+ // the local lane is out of scope — typed `unsupported`, never a drop.
1857
+ (0, previewFrames_1.handlePreviewFrame)(frame, opts.onlyRelayOwnedSessions
1858
+ ? { kind: 'local' }
1859
+ : opts.preview
1860
+ ? { kind: 'hosted', runner: opts.preview }
1861
+ : { kind: 'none' },
1862
+ // A build outlives a relay reconnect: answer on the LIVE socket,
1863
+ // not the one the request arrived on.
1864
+ (reply) => send(ws ?? socket, reply));
1865
+ break;
1830
1866
  case 'companion-allow': {
1831
1867
  // bws-08 — session-less tenant-boundary control frame (see
1832
1868
  // companionFrames.ts's doc comment). Validated with the runtime
@@ -74,6 +74,13 @@ export interface BridgeServerOpts {
74
74
  ok: false;
75
75
  reason: 'not-found' | 'outside-root' | 'too-large' | 'binary' | 'conflict';
76
76
  } | null>;
77
+ /**
78
+ * tp-02 — resolve a REGISTERED local workspace id to its project folder, for
79
+ * GET /local-env-names/:workspaceId (the Keys list: `.env` names, never
80
+ * values — envNames.ts). null → 404 (not registered on this device). Its
81
+ * presence is what /discovery advertises as the `env-names` capability.
82
+ */
83
+ resolveEnvNamesRoot?: (workspaceId: string) => string | null | undefined;
77
84
  /** lws-05: the device name to advertise on /discovery (arena renders "✓
78
85
  * Bridge connected — <device>"). Absent/null when the device is not yet
79
86
  * registered — the modal falls back to a generic connected label. */