@wenrwa/marketplace-cli 0.8.7 → 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,677 @@
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.PreviewRunner = exports.PREVIEW_PASSTHROUGH_ENV = exports.PREVIEW_BIND_HOST = void 0;
37
+ exports.buildPreviewEnv = buildPreviewEnv;
38
+ exports.loopbackArgs = loopbackArgs;
39
+ exports.machineExternalAddresses = machineExternalAddresses;
40
+ exports.listensBeyondLoopback = listensBeyondLoopback;
41
+ exports.parseSymrefHead = parseSymrefHead;
42
+ exports.installCommand = installCommand;
43
+ exports.probeOnce = probeOnce;
44
+ /**
45
+ * tp-05 — Team preview checkout on the owner's HOSTED machine
46
+ * (docs/prd/team-preview.md module 5, ADR-0014).
47
+ *
48
+ * The owner's box runs the workspace's DEFAULT branch as a preview, with the
49
+ * owner's `.env`, apart from the owner's own working copy.
50
+ *
51
+ * ── What runs ───────────────────────────────────────────────────────────────
52
+ * Only the default branch, and never anything else. No frame carries a branch
53
+ * (ADR-0014's "hard truth": whoever picks the code picks what the keys do, so
54
+ * the checkout is pinned to what the remote calls HEAD, which only owners and
55
+ * managers can move by merging). The branch is asked of the REMOTE
56
+ * (`ls-remote --symref origin HEAD`), never of a caller.
57
+ *
58
+ * ── Where it runs ───────────────────────────────────────────────────────────
59
+ * `<previewsBaseDir>/<workspaceId>/{a,b}` — two separate clones, never the
60
+ * owner's `/data/repos/<workspaceId>` working copy, so an uncommitted edit in
61
+ * the working copy can never appear in the preview. Two SLOTS so an update
62
+ * builds beside the version that is serving: a failed build (or a new build
63
+ * that won't come up) leaves the last good version running.
64
+ *
65
+ * ── The env ────────────────────────────────────────────────────────────────
66
+ * The owner's `.env` is read from the WORKING COPY with `loadRepoEnv`, the
67
+ * same loader (and the same reserved-name filter) terminals use. The preview
68
+ * process does NOT inherit the bridge's env: that env holds HiveOp's own
69
+ * credentials (relay token, connect token), and the preview runs merged member
70
+ * code that can print `process.env`. Only a short allowlist of plain process
71
+ * names (PATH, HOME, locale) is passed through, then the repo `.env`, then the
72
+ * fixed PORT. The git credential reaches `git` only, never install/build/start.
73
+ *
74
+ * ── Processes ───────────────────────────────────────────────────────────────
75
+ * Every step is spawned `detached` so it leads its own process group; it is
76
+ * stopped by signalling exactly that group (`-pid` of the PID this module
77
+ * spawned). Never pkill/killall, never a name match: restarting the preview
78
+ * must not touch the owner's terminals. The serving PID is also written to a
79
+ * pidfile so a bridge restart can stop the one it left behind (again by that
80
+ * exact PID) instead of finding the port taken.
81
+ *
82
+ * ── One preview per box ─────────────────────────────────────────────────────
83
+ * There is one fixed preview port (the tp-06 forwarder forwards to it and to
84
+ * nothing else). A second workspace asking to start while another is serving
85
+ * is REFUSED (`busy_other_workspace`), never swapped in: the gateway routes a
86
+ * workspace's members to this port, and silently serving a different
87
+ * workspace's app (with that workspace's keys) to them is the one outcome that
88
+ * must not happen.
89
+ *
90
+ * ── Logs ────────────────────────────────────────────────────────────────────
91
+ * Install/build/start output goes to `<previewsBaseDir>/<workspaceId>/
92
+ * preview.log`, on the machine. `logs()` returns a masked tail; the backend
93
+ * only ever asks for it on an owner-only route.
94
+ *
95
+ * ── Loopback only (SECURITY) ───────────────────────────────────────────────
96
+ * Fly's 6PN is organisation-wide: an app listening on all interfaces is
97
+ * reachable by every other tenant's box, skipping the gateway's member check
98
+ * entirely. So the app is told to bind 127.0.0.1 (HOST / HOSTNAME env, plus
99
+ * `-H 127.0.0.1` for Next and `--host 127.0.0.1` for Vite/Astro), and once it
100
+ * answers, the runner tries the port on every NON-loopback address this
101
+ * machine has. If any of them connects, the new version is killed and the
102
+ * request is refused `unsupported_bind` — never served exposed.
103
+ *
104
+ * Local lane: not here at all — see previewFrames.ts (ADR-0014 scopes it out).
105
+ */
106
+ const child_process_1 = require("child_process");
107
+ const fs = __importStar(require("fs"));
108
+ const http = __importStar(require("http"));
109
+ const net = __importStar(require("net"));
110
+ const os = __importStar(require("os"));
111
+ const path = __importStar(require("path"));
112
+ const repoEnv_1 = require("./repoEnv");
113
+ const repoCheckout_1 = require("./repoCheckout");
114
+ const redact_1 = require("../secrets/redact");
115
+ /** The only names a preview process takes from the bridge's own env. */
116
+ /** The only address a preview app may listen on. */
117
+ exports.PREVIEW_BIND_HOST = '127.0.0.1';
118
+ exports.PREVIEW_PASSTHROUGH_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'TZ', 'TMPDIR', 'SHELL'];
119
+ const DEFAULT_READY_TIMEOUT = { built: 120_000, dev: 300_000 };
120
+ const DEFAULT_STEP_TIMEOUT_MS = 15 * 60_000;
121
+ const LOG_TAIL_LINES = 200;
122
+ const LOG_TAIL_MAX_CHARS = 16_000;
123
+ const LOG_TAIL_READ_BYTES = 256 * 1024;
124
+ const BRANCH_RE = /^[A-Za-z0-9._/-]{1,200}$/;
125
+ const WORKSPACE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
126
+ /**
127
+ * The preview env: allowlisted process names < the owner's repo `.env`
128
+ * (reserved names already removed by loadRepoEnv) < the fixed PORT.
129
+ * Pure, so the "bridge secrets never reach the preview" rule is testable.
130
+ */
131
+ function buildPreviewEnv(baseEnv, repoEnv, port) {
132
+ const out = {};
133
+ for (const name of exports.PREVIEW_PASSTHROUGH_ENV) {
134
+ const v = baseEnv[name];
135
+ if (typeof v === 'string')
136
+ out[name] = v;
137
+ }
138
+ // Forced last: the repo .env can't move the port or widen the bind.
139
+ return { ...out, ...repoEnv, PORT: String(port), HOST: exports.PREVIEW_BIND_HOST, HOSTNAME: exports.PREVIEW_BIND_HOST };
140
+ }
141
+ /**
142
+ * Extra args that pin a known framework's server to loopback, appended after
143
+ * `--` to `npm run <script>`. Only for scripts whose command IS that
144
+ * framework's server — appending flags to an arbitrary script could break it.
145
+ * Anything else relies on HOST/HOSTNAME and the runtime bind check.
146
+ */
147
+ function loopbackArgs(scriptCommand) {
148
+ const cmd = scriptCommand.trim();
149
+ if (/^next\s+(start|dev)(\s|$)/.test(cmd))
150
+ return ['-H', exports.PREVIEW_BIND_HOST];
151
+ if (/^(vite|astro)(\s+(preview|dev|serve))?(\s|$)/.test(cmd))
152
+ return ['--host', exports.PREVIEW_BIND_HOST];
153
+ return [];
154
+ }
155
+ /** Non-loopback addresses of this machine (IPv4 + IPv6, link-local scoped). */
156
+ function machineExternalAddresses() {
157
+ const out = [];
158
+ for (const list of Object.values(os.networkInterfaces())) {
159
+ for (const a of list ?? []) {
160
+ if (a.internal)
161
+ continue;
162
+ out.push(a.family === 'IPv6' && a.scopeid ? `${a.address}%${a.scopeid}` : a.address);
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+ /** True when ANY non-loopback address answers on the port, i.e. the app is exposed. */
168
+ async function listensBeyondLoopback(port, addresses, timeoutMs = 800) {
169
+ const results = await Promise.all(addresses.map((host) => new Promise((resolve) => {
170
+ const sock = net.connect({ host, port });
171
+ const done = (v) => { sock.destroy(); resolve(v); };
172
+ sock.setTimeout(timeoutMs, () => done(false));
173
+ sock.once('connect', () => done(true));
174
+ sock.once('error', () => done(false));
175
+ })));
176
+ return results.some(Boolean);
177
+ }
178
+ /** `ref: refs/heads/main\tHEAD` → `main`; null when the remote names no HEAD. */
179
+ function parseSymrefHead(lsRemoteOutput) {
180
+ for (const line of lsRemoteOutput.split(/\r?\n/)) {
181
+ const m = line.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD$/);
182
+ if (m && BRANCH_RE.test(m[1]) && !m[1].includes('..'))
183
+ return m[1];
184
+ }
185
+ return null;
186
+ }
187
+ /** Which install command a checkout wants, by lockfile. */
188
+ function installCommand(files) {
189
+ if (files('pnpm-lock.yaml'))
190
+ return ['pnpm', ['install', '--frozen-lockfile']];
191
+ if (files('yarn.lock'))
192
+ return ['yarn', ['install', '--frozen-lockfile']];
193
+ if (files('package-lock.json'))
194
+ return ['npm', ['ci']];
195
+ return ['npm', ['install']];
196
+ }
197
+ function runnerFor(files) {
198
+ if (files('pnpm-lock.yaml'))
199
+ return 'pnpm';
200
+ if (files('yarn.lock'))
201
+ return 'yarn';
202
+ return 'npm';
203
+ }
204
+ class PreviewRunner {
205
+ opts;
206
+ states = new Map();
207
+ /** One port, so one operation at a time across the whole box. */
208
+ chain = Promise.resolve();
209
+ orphanSwept = false;
210
+ constructor(opts) {
211
+ this.opts = {
212
+ previewsBaseDir: opts.previewsBaseDir,
213
+ workingCopyDir: opts.workingCopyDir,
214
+ port: opts.port,
215
+ baseEnv: opts.baseEnv ?? process.env,
216
+ readyTimeoutMs: opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT,
217
+ probeIntervalMs: opts.probeIntervalMs ?? 500,
218
+ stepTimeoutMs: opts.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS,
219
+ killGraceMs: opts.killGraceMs ?? 5_000,
220
+ externalAddresses: opts.externalAddresses ?? machineExternalAddresses,
221
+ };
222
+ }
223
+ /** Bring the preview up. Already serving this workspace in this mode → answered as-is. */
224
+ start(req) {
225
+ return this.serialize(async () => {
226
+ const mode = req.mode ?? 'built';
227
+ const st = this.states.get(req.workspaceId);
228
+ if (st && st.child && st.status !== 'stopped' && st.mode === mode && st.runningSha) {
229
+ return this.snapshot(req.workspaceId, { status: st.status });
230
+ }
231
+ return this.deploy(req, mode);
232
+ });
233
+ }
234
+ /** Fetch the default branch again and, when it moved, rebuild and swap. */
235
+ update(req) {
236
+ return this.serialize(() => this.deploy(req, req.mode ?? this.states.get(req.workspaceId)?.mode ?? 'built'));
237
+ }
238
+ stop(req) {
239
+ return this.serialize(async () => {
240
+ const st = this.states.get(req.workspaceId);
241
+ if (st?.child)
242
+ await this.killChild(st.child);
243
+ this.clearPidfile(req.workspaceId);
244
+ const mode = st?.mode ?? 'built';
245
+ this.states.set(req.workspaceId, { status: 'stopped', mode, builtSha: st?.builtSha });
246
+ return { workspaceId: req.workspaceId, status: 'stopped', mode, ...(st?.builtSha ? { builtSha: st.builtSha } : {}) };
247
+ });
248
+ }
249
+ /** The masked tail of this workspace's preview log. Owner-scoped by the caller. */
250
+ logs(req) {
251
+ if (!WORKSPACE_ID_RE.test(req.workspaceId))
252
+ return { log: '', truncated: false };
253
+ const file = this.logPath(req.workspaceId);
254
+ let text = '';
255
+ let truncated = false;
256
+ try {
257
+ const size = fs.statSync(file).size;
258
+ const start = Math.max(0, size - LOG_TAIL_READ_BYTES);
259
+ truncated = start > 0;
260
+ const fd = fs.openSync(file, 'r');
261
+ try {
262
+ const buf = Buffer.alloc(size - start);
263
+ fs.readSync(fd, buf, 0, buf.length, start);
264
+ text = buf.toString('utf8');
265
+ }
266
+ finally {
267
+ fs.closeSync(fd);
268
+ }
269
+ }
270
+ catch {
271
+ return { log: '', truncated: false };
272
+ }
273
+ let lines = text.split('\n');
274
+ if (lines.length > LOG_TAIL_LINES) {
275
+ lines = lines.slice(-LOG_TAIL_LINES);
276
+ truncated = true;
277
+ }
278
+ let out = lines.join('\n');
279
+ if (out.length > LOG_TAIL_MAX_CHARS) {
280
+ out = out.slice(-LOG_TAIL_MAX_CHARS);
281
+ truncated = true;
282
+ }
283
+ return { log: (0, redact_1.redact)(out), truncated };
284
+ }
285
+ status(workspaceId) {
286
+ return this.snapshot(workspaceId, {});
287
+ }
288
+ /** Stop every preview this runner started (bridge shutdown). Exact PIDs only. */
289
+ async shutdown() {
290
+ for (const [ws, st] of this.states) {
291
+ if (st.child)
292
+ await this.killChild(st.child);
293
+ this.clearPidfile(ws);
294
+ }
295
+ }
296
+ // ── internals ────────────────────────────────────────────────────────────
297
+ serialize(fn) {
298
+ const next = this.chain.then(fn, fn);
299
+ this.chain = next.catch(() => undefined);
300
+ return next;
301
+ }
302
+ snapshot(workspaceId, extra) {
303
+ const st = this.states.get(workspaceId);
304
+ return {
305
+ workspaceId,
306
+ status: st?.status ?? 'stopped',
307
+ mode: st?.mode ?? 'built',
308
+ ...(st?.runningSha ? { runningSha: st.runningSha } : {}),
309
+ ...(st?.builtSha ? { builtSha: st.builtSha } : {}),
310
+ ...extra,
311
+ };
312
+ }
313
+ wsDir(workspaceId) {
314
+ return path.join(this.opts.previewsBaseDir, workspaceId);
315
+ }
316
+ logPath(workspaceId) {
317
+ return path.join(this.wsDir(workspaceId), 'preview.log');
318
+ }
319
+ pidfilePath(workspaceId) {
320
+ return path.join(this.wsDir(workspaceId), 'preview.pid');
321
+ }
322
+ clearPidfile(workspaceId) {
323
+ try {
324
+ fs.rmSync(this.pidfilePath(workspaceId), { force: true });
325
+ }
326
+ catch { /* best effort */ }
327
+ }
328
+ /**
329
+ * A bridge restart loses `states` but not the detached server it started,
330
+ * which would keep the port. Stop it by the exact PID this module recorded —
331
+ * only if that PID still leads a process group we would have created.
332
+ */
333
+ async sweepOrphans() {
334
+ if (this.orphanSwept)
335
+ return;
336
+ this.orphanSwept = true;
337
+ let entries = [];
338
+ try {
339
+ entries = fs.readdirSync(this.opts.previewsBaseDir);
340
+ }
341
+ catch {
342
+ return;
343
+ }
344
+ for (const ws of entries) {
345
+ if (this.states.get(ws)?.child)
346
+ continue;
347
+ const pf = this.pidfilePath(ws);
348
+ let pid = NaN;
349
+ try {
350
+ pid = Number(JSON.parse(fs.readFileSync(pf, 'utf8')).pid);
351
+ }
352
+ catch {
353
+ continue;
354
+ }
355
+ if (Number.isInteger(pid) && pid > 1) {
356
+ try {
357
+ process.kill(-pid, 'SIGTERM');
358
+ }
359
+ catch { /* already gone */ }
360
+ await delay(Math.min(this.opts.killGraceMs, 2_000));
361
+ try {
362
+ process.kill(-pid, 'SIGKILL');
363
+ }
364
+ catch { /* already gone */ }
365
+ }
366
+ this.clearPidfile(ws);
367
+ }
368
+ }
369
+ otherServingWorkspace(workspaceId) {
370
+ for (const [ws, st] of this.states) {
371
+ if (ws !== workspaceId && st.child && st.child.exitCode === null && st.child.signalCode === null)
372
+ return ws;
373
+ }
374
+ return null;
375
+ }
376
+ async deploy(req, mode) {
377
+ const { workspaceId } = req;
378
+ if (!WORKSPACE_ID_RE.test(workspaceId)) {
379
+ return { workspaceId, status: 'failed', mode, reason: 'git_failed', message: 'Invalid workspace.' };
380
+ }
381
+ await this.sweepOrphans();
382
+ if (this.otherServingWorkspace(workspaceId)) {
383
+ return {
384
+ workspaceId,
385
+ status: 'failed',
386
+ mode,
387
+ reason: 'busy_other_workspace',
388
+ message: 'Another workspace preview is running.',
389
+ };
390
+ }
391
+ if (!req.repoUrl) {
392
+ return this.fail(workspaceId, mode, 'no_repo_url', 'No repo to preview.');
393
+ }
394
+ const prev = this.states.get(workspaceId) ?? { status: 'stopped', mode };
395
+ const serving = !!(prev.child && prev.child.exitCode === null && prev.child.signalCode === null);
396
+ const targetSlot = prev.activeSlot === 'a' ? 'b' : 'a';
397
+ const slotDir = path.join(this.wsDir(workspaceId), targetSlot);
398
+ fs.mkdirSync(this.wsDir(workspaceId), { recursive: true });
399
+ const logFile = this.logPath(workspaceId);
400
+ fs.writeFileSync(logFile, `--- preview ${new Date().toISOString()} (${mode}) ---\n`);
401
+ this.states.set(workspaceId, { ...prev, status: serving ? prev.status : 'building', mode: prev.mode ?? mode });
402
+ // 1. Sync the slot to the remote's default branch. Never the working copy.
403
+ const sha = await this.syncSlot(slotDir, req.repoUrl, req.gitCredential, logFile);
404
+ if (!sha) {
405
+ this.states.set(workspaceId, { ...prev, status: serving ? 'running' : 'failed' });
406
+ return this.withServing(workspaceId, { status: 'failed', reason: 'git_failed', mode, message: 'Could not fetch the default branch.' });
407
+ }
408
+ // Nothing new to show: same sha, same mode, still serving.
409
+ if (serving && prev.runningSha === sha && prev.mode === mode) {
410
+ this.states.set(workspaceId, { ...prev, status: 'running' });
411
+ return this.snapshot(workspaceId, { sha });
412
+ }
413
+ const has = (name) => fs.existsSync(path.join(slotDir, name));
414
+ if (!has('package.json')) {
415
+ this.states.set(workspaceId, { ...prev, status: serving ? 'running' : 'failed' });
416
+ return this.withServing(workspaceId, { status: 'failed', reason: 'no_package_json', mode, sha, message: 'No package.json on the default branch.' });
417
+ }
418
+ const scripts = readScripts(path.join(slotDir, 'package.json'));
419
+ const runScript = mode === 'dev' ? 'dev' : 'start';
420
+ if (!scripts[runScript]) {
421
+ this.states.set(workspaceId, { ...prev, status: serving ? 'running' : 'failed' });
422
+ return this.withServing(workspaceId, {
423
+ status: 'failed', reason: 'no_start_script', mode, sha,
424
+ message: `No "${runScript}" script in package.json.`,
425
+ });
426
+ }
427
+ const env = buildPreviewEnv(this.opts.baseEnv, (0, repoEnv_1.loadRepoEnv)(this.opts.workingCopyDir(workspaceId)), this.opts.port);
428
+ // 2. Install, then (built) build — beside whatever is serving.
429
+ const [installCmd, installArgs] = installCommand(has);
430
+ const installed = await this.runStep(installCmd, installArgs, slotDir, env, logFile);
431
+ let built = installed.ok;
432
+ if (built && mode === 'built' && scripts.build) {
433
+ built = (await this.runStep(runnerFor(has), ['run', 'build'], slotDir, env, logFile)).ok;
434
+ }
435
+ if (!built) {
436
+ this.states.set(workspaceId, { ...prev, status: serving ? 'running' : 'build_failed' });
437
+ return this.withServing(workspaceId, { status: 'build_failed', mode, sha, message: 'Build failed. See the preview log.' });
438
+ }
439
+ const builtSha = sha;
440
+ // 3. Swap: stop the old server (exact PID), start the new one, probe.
441
+ if (prev.child)
442
+ await this.killChild(prev.child);
443
+ this.clearPidfile(workspaceId);
444
+ if (await probeOnce(this.opts.port)) {
445
+ // Something we did not start holds the port; a probe would lie.
446
+ const restored = await this.restoreSlot(workspaceId, prev, env, logFile);
447
+ this.states.set(workspaceId, restored ?? { status: 'failed', mode, builtSha });
448
+ return this.withServing(workspaceId, { status: 'failed', reason: 'port_in_use', mode, sha, builtSha, message: 'The preview port is in use.' });
449
+ }
450
+ const child = this.spawnServer(runnerFor(has), runArgs(runScript, scripts[runScript]), slotDir, env, logFile, workspaceId, targetSlot, sha);
451
+ const ready = await this.waitReady(child, mode);
452
+ if (ready && (await listensBeyondLoopback(this.opts.port, this.opts.externalAddresses()))) {
453
+ // SECURITY: serving this would hand the owner's app (and keys) to every
454
+ // tenant on the private network. Kill it and refuse, typed.
455
+ fs.appendFileSync(logFile, `refused: the app listens beyond ${exports.PREVIEW_BIND_HOST}\n`);
456
+ await this.killChild(child);
457
+ this.clearPidfile(workspaceId);
458
+ const restored = await this.restoreSlot(workspaceId, prev, env, logFile);
459
+ this.states.set(workspaceId, restored ? { ...restored, builtSha } : { status: 'failed', mode, builtSha });
460
+ return this.withServing(workspaceId, {
461
+ status: 'failed', reason: 'unsupported_bind', mode, sha, builtSha,
462
+ message: `The app must listen on ${exports.PREVIEW_BIND_HOST} only.`,
463
+ });
464
+ }
465
+ if (ready) {
466
+ this.states.set(workspaceId, { status: 'running', mode, activeSlot: targetSlot, runningSha: sha, builtSha, child });
467
+ return this.snapshot(workspaceId, { sha });
468
+ }
469
+ await this.killChild(child);
470
+ this.clearPidfile(workspaceId);
471
+ const restored = await this.restoreSlot(workspaceId, prev, env, logFile);
472
+ this.states.set(workspaceId, restored ? { ...restored, builtSha } : { status: 'start_failed', mode, builtSha });
473
+ return this.withServing(workspaceId, { status: 'start_failed', mode, sha, builtSha, message: 'The app did not start. See the preview log.' });
474
+ }
475
+ /** Bring the previous slot back after a failed swap, if there was one. */
476
+ async restoreSlot(workspaceId, prev, env, logFile) {
477
+ if (!prev.activeSlot || !prev.runningSha)
478
+ return null;
479
+ const dir = path.join(this.wsDir(workspaceId), prev.activeSlot);
480
+ const has = (name) => fs.existsSync(path.join(dir, name));
481
+ const script = prev.mode === 'dev' ? 'dev' : 'start';
482
+ fs.appendFileSync(logFile, `--- restoring ${prev.runningSha.slice(0, 7)} ---\n`);
483
+ const child = this.spawnServer(runnerFor(has), runArgs(script, readScripts(path.join(dir, 'package.json'))[script] ?? ''), dir, env, logFile, workspaceId, prev.activeSlot, prev.runningSha);
484
+ if (await this.waitReady(child, prev.mode) && !(await listensBeyondLoopback(this.opts.port, this.opts.externalAddresses()))) {
485
+ return { status: 'running', mode: prev.mode, activeSlot: prev.activeSlot, runningSha: prev.runningSha, builtSha: prev.builtSha, child };
486
+ }
487
+ await this.killChild(child);
488
+ this.clearPidfile(workspaceId);
489
+ return null;
490
+ }
491
+ /** The request's own verdict, plus what is actually serving now. */
492
+ withServing(workspaceId, r) {
493
+ const st = this.states.get(workspaceId);
494
+ const builtSha = r.builtSha ?? st?.builtSha;
495
+ return {
496
+ workspaceId,
497
+ ...r,
498
+ ...(st?.runningSha && st.child ? { runningSha: st.runningSha } : {}),
499
+ ...(builtSha ? { builtSha } : {}),
500
+ };
501
+ }
502
+ fail(workspaceId, mode, reason, message) {
503
+ return this.withServing(workspaceId, { status: 'failed', mode, reason, message });
504
+ }
505
+ async syncSlot(slotDir, repoUrl, cred, logFile) {
506
+ const gitEnv = { ...process.env, ...(0, repoCheckout_1.cloneCredentialEnv)(cred?.token ? { token: cred.token, repoFullName: cred.repoFullName } : undefined), GIT_TERMINAL_PROMPT: '0' };
507
+ const git = (args) => execGit(args, gitEnv);
508
+ try {
509
+ if (!fs.existsSync(path.join(slotDir, '.git'))) {
510
+ fs.rmSync(slotDir, { recursive: true, force: true });
511
+ await git(['clone', '--filter=blob:none', '--no-checkout', repoUrl, slotDir]);
512
+ }
513
+ else {
514
+ await git(['-C', slotDir, 'remote', 'set-url', 'origin', repoUrl]);
515
+ }
516
+ const branch = parseSymrefHead(await git(['-C', slotDir, 'ls-remote', '--symref', 'origin', 'HEAD']));
517
+ if (!branch)
518
+ throw new Error('remote has no default branch');
519
+ await git(['-C', slotDir, 'fetch', '--prune', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
520
+ await git(['-C', slotDir, 'checkout', '--force', '-B', branch, `refs/remotes/origin/${branch}`]);
521
+ await git(['-C', slotDir, 'reset', '--hard', `refs/remotes/origin/${branch}`]);
522
+ // Untracked leftovers go; ignored ones (node_modules, build output) stay
523
+ // so an unchanged dependency set doesn't reinstall from scratch.
524
+ await git(['-C', slotDir, 'clean', '-ffd']);
525
+ const sha = (await git(['-C', slotDir, 'rev-parse', 'HEAD'])).trim();
526
+ fs.appendFileSync(logFile, `default branch ${branch} @ ${sha.slice(0, 12)}\n`);
527
+ return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
528
+ }
529
+ catch (err) {
530
+ fs.appendFileSync(logFile, `git: ${(0, redact_1.redact)(err instanceof Error ? err.message : String(err))}\n`);
531
+ return null;
532
+ }
533
+ }
534
+ runStep(cmd, args, cwd, env, logFile) {
535
+ return new Promise((resolve) => {
536
+ fs.appendFileSync(logFile, `$ ${cmd} ${args.join(' ')}\n`);
537
+ const fd = fs.openSync(logFile, 'a');
538
+ let child;
539
+ try {
540
+ child = (0, child_process_1.spawn)(cmd, args, { cwd, env, detached: true, stdio: ['ignore', fd, fd] });
541
+ }
542
+ catch {
543
+ fs.closeSync(fd);
544
+ resolve({ ok: false });
545
+ return;
546
+ }
547
+ fs.closeSync(fd);
548
+ let timedOut = false;
549
+ const timer = setTimeout(() => {
550
+ timedOut = true;
551
+ void this.killChild(child);
552
+ }, this.opts.stepTimeoutMs);
553
+ child.on('error', () => {
554
+ clearTimeout(timer);
555
+ resolve({ ok: false });
556
+ });
557
+ child.on('exit', (code) => {
558
+ clearTimeout(timer);
559
+ resolve({ ok: code === 0 && !timedOut, timedOut });
560
+ });
561
+ });
562
+ }
563
+ spawnServer(cmd, args, cwd, env, logFile, workspaceId, slot, sha) {
564
+ fs.appendFileSync(logFile, `$ ${cmd} ${args.join(' ')} (port ${this.opts.port})\n`);
565
+ const fd = fs.openSync(logFile, 'a');
566
+ const child = (0, child_process_1.spawn)(cmd, args, { cwd, env, detached: true, stdio: ['ignore', fd, fd] });
567
+ fs.closeSync(fd);
568
+ child.on('error', () => { });
569
+ if (child.pid) {
570
+ try {
571
+ fs.writeFileSync(this.pidfilePath(workspaceId), JSON.stringify({ pid: child.pid, slot, sha }));
572
+ }
573
+ catch { /* best effort */ }
574
+ }
575
+ child.on('exit', () => {
576
+ const st = this.states.get(workspaceId);
577
+ if (st?.child === child) {
578
+ this.states.set(workspaceId, { ...st, child: undefined, status: 'stopped', runningSha: undefined });
579
+ this.clearPidfile(workspaceId);
580
+ }
581
+ });
582
+ return child;
583
+ }
584
+ async waitReady(child, mode) {
585
+ const deadline = Date.now() + this.opts.readyTimeoutMs[mode];
586
+ while (Date.now() < deadline) {
587
+ if (child.exitCode !== null || child.signalCode !== null || !child.pid)
588
+ return false;
589
+ if (await probeOnce(this.opts.port))
590
+ return child.exitCode === null && child.signalCode === null;
591
+ await delay(this.opts.probeIntervalMs);
592
+ }
593
+ return false;
594
+ }
595
+ /** Stop the process GROUP this module spawned — by its exact leader PID. */
596
+ killChild(child) {
597
+ const pid = child.pid;
598
+ if (!pid || child.exitCode !== null || child.signalCode !== null) {
599
+ // The leader is gone; its group may not be (npm → node). Same exact PGID.
600
+ if (pid) {
601
+ try {
602
+ process.kill(-pid, 'SIGKILL');
603
+ }
604
+ catch { /* none left */ }
605
+ }
606
+ return Promise.resolve();
607
+ }
608
+ return new Promise((resolve) => {
609
+ let done = false;
610
+ const finish = () => {
611
+ if (done)
612
+ return;
613
+ done = true;
614
+ clearTimeout(timer);
615
+ // npm may exit before its own child does — clear the rest of the group.
616
+ try {
617
+ process.kill(-pid, 'SIGKILL');
618
+ }
619
+ catch { /* none left */ }
620
+ resolve();
621
+ };
622
+ child.once('exit', finish);
623
+ try {
624
+ process.kill(-pid, 'SIGTERM');
625
+ }
626
+ catch {
627
+ finish();
628
+ return;
629
+ }
630
+ const timer = setTimeout(() => {
631
+ try {
632
+ process.kill(-pid, 'SIGKILL');
633
+ }
634
+ catch { /* gone */ }
635
+ setTimeout(finish, 200);
636
+ }, this.opts.killGraceMs);
637
+ });
638
+ }
639
+ }
640
+ exports.PreviewRunner = PreviewRunner;
641
+ function runArgs(script, scriptCommand) {
642
+ const extra = loopbackArgs(scriptCommand);
643
+ return extra.length ? ['run', script, '--', ...extra] : ['run', script];
644
+ }
645
+ function readScripts(pkgPath) {
646
+ try {
647
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
648
+ return pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
649
+ }
650
+ catch {
651
+ return {};
652
+ }
653
+ }
654
+ function execGit(args, env) {
655
+ return new Promise((resolve, reject) => {
656
+ (0, child_process_1.execFile)('git', args, { env, maxBuffer: 4 * 1024 * 1024, timeout: 5 * 60_000 }, (err, stdout, stderr) => {
657
+ if (err)
658
+ reject(new Error(`git ${args.filter((a) => !a.includes('://')).slice(0, 4).join(' ')}: ${String(stderr || err.message).trim()}`));
659
+ else
660
+ resolve(String(stdout));
661
+ });
662
+ });
663
+ }
664
+ /** Any HTTP answer on 127.0.0.1:port counts as "up". */
665
+ function probeOnce(port, timeoutMs = 1_500) {
666
+ return new Promise((resolve) => {
667
+ const req = http.get({ host: '127.0.0.1', port, path: '/', timeout: timeoutMs }, (res) => {
668
+ res.resume();
669
+ resolve(true);
670
+ });
671
+ req.on('timeout', () => { req.destroy(); resolve(false); });
672
+ req.on('error', () => resolve(false));
673
+ });
674
+ }
675
+ function delay(ms) {
676
+ return new Promise((r) => setTimeout(r, ms));
677
+ }