@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.
- package/dist/__tests__/bridge-env-names.test.d.ts +1 -0
- package/dist/__tests__/bridge-env-names.test.js +243 -0
- package/dist/__tests__/bridge-preview-checkout.test.d.ts +1 -0
- package/dist/__tests__/bridge-preview-checkout.test.js +294 -0
- package/dist/__tests__/bridge-preview-forwarder.test.d.ts +1 -0
- package/dist/__tests__/bridge-preview-forwarder.test.js +465 -0
- package/dist/__tests__/bridge-preview-frames.test.d.ts +1 -0
- package/dist/__tests__/bridge-preview-frames.test.js +165 -0
- package/dist/__tests__/bridge-session-anchor-no-identity.test.d.ts +1 -0
- package/dist/__tests__/bridge-session-anchor-no-identity.test.js +74 -0
- package/dist/__tests__/bridge-session-anchor.test.js +10 -2
- package/dist/bridge/envNames.d.ts +26 -0
- package/dist/bridge/envNames.js +114 -0
- package/dist/bridge/previewCheckout.d.ts +136 -0
- package/dist/bridge/previewCheckout.js +677 -0
- package/dist/bridge/previewForwarder.d.ts +116 -0
- package/dist/bridge/previewForwarder.js +323 -0
- package/dist/bridge/previewFrames.d.ts +63 -0
- package/dist/bridge/previewFrames.js +59 -0
- package/dist/bridge/relayClient.d.ts +15 -0
- package/dist/bridge/relayClient.js +36 -0
- package/dist/bridge/server.d.ts +7 -0
- package/dist/bridge/server.js +40 -1
- package/dist/bridge/sessionAnchor.d.ts +9 -0
- package/dist/bridge/sessionAnchor.js +16 -1
- package/dist/commands/bridge.js +31 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|