openship 0.2.3 → 0.4.0

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.
Files changed (82) hide show
  1. package/README.md +104 -61
  2. package/dist/bare-ITM45QBX.js +18 -0
  3. package/dist/bowser-MFQA6OS4.js +2821 -0
  4. package/dist/chunk-3U4SPZD5.js +241 -0
  5. package/dist/chunk-46YJFP4S.js +1771 -0
  6. package/dist/chunk-4CQWT2WL.js +116 -0
  7. package/dist/chunk-4FABQGQP.js +23 -0
  8. package/dist/chunk-4LEL3H2Z.js +84 -0
  9. package/dist/chunk-4MG3BVHE.js +326 -0
  10. package/dist/chunk-4VNS5WPM.js +42 -0
  11. package/dist/chunk-5N7VUGL6.js +2094 -0
  12. package/dist/chunk-5ZCKQ2MI.js +1360 -0
  13. package/dist/chunk-6ERIMTBC.js +1412 -0
  14. package/dist/chunk-7UOB5GGQ.js +132 -0
  15. package/dist/chunk-BUBSSKO6.js +1384 -0
  16. package/dist/chunk-E6W2JMMU.js +341 -0
  17. package/dist/chunk-E77LJWJK.js +1535 -0
  18. package/dist/chunk-F4DFVG6Q.js +573 -0
  19. package/dist/chunk-HFYDYK4D.js +1092 -0
  20. package/dist/chunk-JR2OCMJA.js +46 -0
  21. package/dist/chunk-JUJBBF5U.js +746 -0
  22. package/dist/chunk-JZLNHGLW.js +19145 -0
  23. package/dist/chunk-KD7VO4LG.js +2617 -0
  24. package/dist/chunk-LBFH7THC.js +435 -0
  25. package/dist/chunk-NB2VEIWG.js +129 -0
  26. package/dist/chunk-OJE6YWPQ.js +2866 -0
  27. package/dist/chunk-QKFRZNKN.js +4941 -0
  28. package/dist/chunk-RBZPFTHV.js +13354 -0
  29. package/dist/chunk-S6DSRLNE.js +121 -0
  30. package/dist/chunk-TEYJYSMT.js +409 -0
  31. package/dist/chunk-U2DPZSXA.js +112 -0
  32. package/dist/chunk-WJPCBU7Z.js +3147 -0
  33. package/dist/chunk-XIFRMKYT.js +13 -0
  34. package/dist/chunk-XVSAUO2I.js +7946 -0
  35. package/dist/chunk-Y37KHU7L.js +31 -0
  36. package/dist/chunk-YL67EWYZ.js +416 -0
  37. package/dist/chunk-ZKJOFWBJ.js +1879 -0
  38. package/dist/cloud-JMPHO3CW.js +7 -0
  39. package/dist/cloud-W5G6YXAY.js +24 -0
  40. package/dist/dist-L3MPOWCA.js +65 -0
  41. package/dist/dist-es-4C322O5D.js +22 -0
  42. package/dist/dist-es-GTAMF674.js +87 -0
  43. package/dist/dist-es-KE56MWYO.js +169 -0
  44. package/dist/dist-es-NRZIQY5B.js +484 -0
  45. package/dist/dist-es-WZCR7DHQ.js +317 -0
  46. package/dist/dist-es-XQCOTGC7.js +376 -0
  47. package/dist/dist-es-ZW3HFQUM.js +69 -0
  48. package/dist/docker-3FVJMFC3.js +12 -0
  49. package/dist/docker-edge-executor-ZIWAZWPL.js +9 -0
  50. package/dist/edge-import-53M5DGWZ.js +39 -0
  51. package/dist/event-streams-7LGDL56D.js +42 -0
  52. package/dist/executor-J7BHW5HF.js +26 -0
  53. package/dist/index.js +2459 -3461
  54. package/dist/loadSso-N273C5I7.js +577 -0
  55. package/dist/nginx-YZWKYKQU.js +16 -0
  56. package/dist/noop-RRNCIUI5.js +7 -0
  57. package/dist/openresty-lua-HAO4EUDY.js +32 -0
  58. package/dist/server/index.js +217916 -205055
  59. package/dist/server/migrations/0057_complete_prism.sql +18 -0
  60. package/dist/server/migrations/0058_add_server_is_local.sql +1 -0
  61. package/dist/server/migrations/0059_legal_fixer.sql +18 -0
  62. package/dist/server/migrations/0060_add_route_strategy_host_port.sql +3 -0
  63. package/dist/server/migrations/0061_notification_default_multi_kind.sql +3 -0
  64. package/dist/server/migrations/0062_add_incoming_webhook.sql +21 -0
  65. package/dist/server/migrations/0063_add_migration_logs.sql +1 -0
  66. package/dist/server/migrations/0064_add_migration_partial.sql +2 -0
  67. package/dist/server/migrations/0065_custom_app_template.sql +14 -0
  68. package/dist/server/migrations/0066_ensure_migration_partial_columns.sql +2 -0
  69. package/dist/server/migrations/0067_webhook_delivery.sql +28 -0
  70. package/dist/server/migrations/0068_add_migration_target_volumes.sql +1 -0
  71. package/dist/server/migrations/0069_add_project_composite_routes.sql +1 -0
  72. package/dist/server/migrations/0070_forward_git_to_server.sql +1 -0
  73. package/dist/server/migrations/meta/0057_snapshot.json +9326 -0
  74. package/dist/server/migrations/meta/0058_snapshot.json +9333 -0
  75. package/dist/server/migrations/meta/0059_snapshot.json +9495 -0
  76. package/dist/server/migrations/meta/0060_snapshot.json +9515 -0
  77. package/dist/server/migrations/meta/_journal.json +99 -1
  78. package/dist/setup-5X3WEEHA.js +14 -0
  79. package/dist/signin-DR462ERO.js +663 -0
  80. package/dist/sso-oidc-VBZLJUK5.js +830 -0
  81. package/dist/sts-EJLZ4Z77.js +1422 -0
  82. package/package.json +6 -2
@@ -0,0 +1,1535 @@
1
+ import {
2
+ LocalExecutor
3
+ } from "./chunk-U2DPZSXA.js";
4
+ import {
5
+ connectSshClient,
6
+ getTarCreateEnv,
7
+ openSftp,
8
+ openSshUnixSocket,
9
+ prepareSourceTarArgs,
10
+ reconcileKnownHosts
11
+ } from "./chunk-4MG3BVHE.js";
12
+ import {
13
+ SshDisconnectedError
14
+ } from "./chunk-4LEL3H2Z.js";
15
+ import {
16
+ emitBufferedLines,
17
+ flushBufferedLines,
18
+ hasLocalCommand,
19
+ logEntry,
20
+ sq
21
+ } from "./chunk-4CQWT2WL.js";
22
+ import {
23
+ TRANSFER_EXCLUDES,
24
+ formatBytes,
25
+ safeErrorMessage
26
+ } from "./chunk-JZLNHGLW.js";
27
+
28
+ // ../../packages/adapters/src/system/executor.ts
29
+ import { readFileSync } from "fs";
30
+
31
+ // ../../packages/adapters/src/system/ssh-executor.ts
32
+ import { createReadStream } from "fs";
33
+ import { mkdtemp as mkdtemp2, rm as fsRm2, stat } from "fs/promises";
34
+ import { tmpdir as tmpdir2 } from "os";
35
+ import { dirname, join as join2 } from "path";
36
+
37
+ // ../../packages/adapters/src/system/remote-transfer.ts
38
+ import { spawn } from "child_process";
39
+ import { createWriteStream } from "fs";
40
+ import { chmod, mkdtemp, rm as fsRm, writeFile as fsWriteFile } from "fs/promises";
41
+ import { tmpdir } from "os";
42
+ import { join } from "path";
43
+ function packLocalArchive(tarArgs, outFile) {
44
+ return new Promise((resolve, reject) => {
45
+ const tar = spawn("tar", tarArgs, { stdio: ["ignore", "pipe", "pipe"], env: getTarCreateEnv() });
46
+ const out = createWriteStream(outFile);
47
+ let stderr = "";
48
+ let tarCode = null;
49
+ let tarClosed = false;
50
+ let outClosed = false;
51
+ const settle = () => {
52
+ if (!tarClosed || !outClosed) return;
53
+ if (tarCode === 0) resolve();
54
+ else reject(new Error(`tar failed (exit ${tarCode})${stderr.trim() ? `: ${stderr.trim().slice(-300)}` : ""}`));
55
+ };
56
+ tar.stderr.on("data", (d) => {
57
+ stderr += d.toString();
58
+ });
59
+ tar.on("error", reject);
60
+ out.on("error", reject);
61
+ tar.stdout.pipe(out);
62
+ tar.on("close", (code) => {
63
+ tarCode = code ?? 1;
64
+ tarClosed = true;
65
+ settle();
66
+ });
67
+ out.on("finish", () => {
68
+ outClosed = true;
69
+ settle();
70
+ });
71
+ });
72
+ }
73
+ async function extractRemoteArchive(exec, remoteArchive, remotePath, expectedBytes, onLog) {
74
+ const received = Number((await exec(`wc -c < ${sq(remoteArchive)}`)).trim());
75
+ if (received !== expectedBytes) {
76
+ await exec(`rm -f ${sq(remoteArchive)}`).catch(() => {
77
+ });
78
+ throw new Error(`upload truncated: sent ${expectedBytes} bytes, server received ${received}`);
79
+ }
80
+ await exec(
81
+ `mkdir -p ${sq(remotePath)} && tar xzf ${sq(remoteArchive)} -C ${sq(remotePath)}; rc=$?; rm -f ${sq(remoteArchive)}; exit $rc`
82
+ );
83
+ onLog?.(logEntry(`Transferred ${formatBytes(expectedBytes)} and extracted on the server.`));
84
+ }
85
+ async function canUseRemoteRsync(deps) {
86
+ if (deps.config.privateKey && deps.config.privateKeyPassphrase && !deps.config.sshAgent) {
87
+ return { ok: false, reason: "encrypted SSH keys without an agent are not supported by non-interactive rsync" };
88
+ }
89
+ const [localRsync, localSsh, localSshpass, remoteRsync] = await Promise.all([
90
+ hasLocalCommand("rsync"),
91
+ hasLocalCommand("ssh"),
92
+ deps.config.password ? hasLocalCommand("sshpass") : Promise.resolve(true),
93
+ deps.hasRemoteCommand("rsync")
94
+ ]);
95
+ if (!localRsync) return { ok: false, reason: "local rsync is not installed" };
96
+ if (!localSsh) return { ok: false, reason: "local ssh is not installed" };
97
+ if (!localSshpass) return { ok: false, reason: "local sshpass is not installed for password-based rsync" };
98
+ if (!remoteRsync) return { ok: false, reason: "remote rsync is not installed" };
99
+ return { ok: true };
100
+ }
101
+ function buildRsyncSshCommand(config, keyPath) {
102
+ const args = config.password ? [
103
+ "sshpass",
104
+ "-e",
105
+ "ssh",
106
+ "-p",
107
+ String(config.port ?? 22),
108
+ "-o",
109
+ "NumberOfPasswordPrompts=1",
110
+ "-o",
111
+ "PreferredAuthentications=password,keyboard-interactive",
112
+ "-o",
113
+ "PubkeyAuthentication=no",
114
+ "-o",
115
+ "StrictHostKeyChecking=accept-new"
116
+ ] : [
117
+ "ssh",
118
+ "-p",
119
+ String(config.port ?? 22),
120
+ "-o",
121
+ "BatchMode=yes",
122
+ "-o",
123
+ "StrictHostKeyChecking=accept-new"
124
+ ];
125
+ if (config.sshAgent) {
126
+ args.push("-A");
127
+ }
128
+ if (keyPath) {
129
+ args.push("-i", keyPath, "-o", "IdentitiesOnly=yes");
130
+ }
131
+ return args.map(sq).join(" ");
132
+ }
133
+ async function withTemporaryPrivateKey(config, fn) {
134
+ if (!config.privateKey || config.sshAgent) {
135
+ return fn();
136
+ }
137
+ const tempDir = await mkdtemp(join(tmpdir(), "openship-rsync-key-"));
138
+ const keyPath = join(tempDir, "id_rsa");
139
+ try {
140
+ await fsWriteFile(keyPath, config.privateKey);
141
+ await chmod(keyPath, 384);
142
+ return await fn(keyPath);
143
+ } finally {
144
+ await fsRm(tempDir, { recursive: true, force: true }).catch(() => {
145
+ });
146
+ }
147
+ }
148
+ async function runRsync(config, args, onLog, cwd) {
149
+ return new Promise((resolve, reject) => {
150
+ const proc = spawn("rsync", args, {
151
+ cwd,
152
+ env: {
153
+ ...getTarCreateEnv(),
154
+ ...config.password ? { SSHPASS: config.password } : {}
155
+ },
156
+ stdio: ["ignore", "pipe", "pipe"]
157
+ });
158
+ const stdoutState = { partial: "" };
159
+ const stderrState = { partial: "" };
160
+ proc.stdout.on("data", (chunk) => {
161
+ emitBufferedLines(chunk, stdoutState, (line) => onLog?.(logEntry(line)));
162
+ });
163
+ proc.stderr.on("data", (chunk) => {
164
+ emitBufferedLines(chunk, stderrState, (line) => onLog?.(logEntry(line)));
165
+ });
166
+ proc.on("error", (err) => {
167
+ reject(new Error(`rsync failed to start: ${err.message}`));
168
+ });
169
+ proc.on("close", (code) => {
170
+ flushBufferedLines(stdoutState, (line) => onLog?.(logEntry(line)));
171
+ flushBufferedLines(stderrState, (line) => onLog?.(logEntry(line)));
172
+ resolve({ code: code ?? 1 });
173
+ });
174
+ });
175
+ }
176
+ async function uploadFileWithRsync(localFile, remoteFile, deps, onLog, opts) {
177
+ await reconcileKnownHosts(deps.config);
178
+ const retries = Math.max(1, opts?.retries ?? 3);
179
+ const host = deps.config.host.includes(":") ? `[${deps.config.host}]` : deps.config.host;
180
+ const user = deps.config.username ?? "root";
181
+ const target = `${user}@${host}:${remoteFile}`;
182
+ await withTemporaryPrivateKey(deps.config, async (keyPath) => {
183
+ let lastCode = 1;
184
+ for (let attempt = 1; attempt <= retries; attempt++) {
185
+ if (attempt > 1) {
186
+ onLog?.(logEntry(`Connection dropped \u2014 resuming upload (attempt ${attempt}/${retries})...`, "warn"));
187
+ }
188
+ const args = [
189
+ "-a",
190
+ // already gzipped, so no `-z`
191
+ "--partial",
192
+ // keep the partial on interruption; delta-resume on re-run
193
+ "--progress",
194
+ "--timeout=60",
195
+ // abort if no I/O for 60s → surfaces a stall as a retryable exit
196
+ "-e",
197
+ buildRsyncSshCommand(deps.config, keyPath),
198
+ localFile,
199
+ target
200
+ ];
201
+ const { code } = await runRsync(deps.config, args, onLog);
202
+ if (code === 0) return;
203
+ lastCode = code;
204
+ }
205
+ throw new Error(`rsync upload failed after ${retries} attempt(s) (exit ${lastCode})`);
206
+ });
207
+ }
208
+
209
+ // ../../packages/adapters/src/system/ssh-executor.ts
210
+ function clampWindow(value, fallback, min, max) {
211
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
212
+ return Math.max(min, Math.min(max, Math.floor(value)));
213
+ }
214
+ var SshExecutor = class _SshExecutor {
215
+ client = null;
216
+ connecting = null;
217
+ /** One shared SFTP subsystem channel per client — see sftp(). */
218
+ sftpChannel = null;
219
+ config;
220
+ /** Subscribers notified when the transport drops (see onDisconnect). */
221
+ disconnectListeners = /* @__PURE__ */ new Set();
222
+ /** In-flight cancelable ops — each entry aborts ONE exec/stream on a drop,
223
+ * so a dead channel fails fast instead of hanging to its command timeout. */
224
+ inflight = /* @__PURE__ */ new Set();
225
+ /** Reverse-forward handlers keyed by the remote bound port (see reverseForward). */
226
+ reverseHandlers = /* @__PURE__ */ new Map();
227
+ /** The client the single 'tcp connection' dispatcher is attached to (re-attached on reconnect). */
228
+ reverseListenerClient = null;
229
+ constructor(config) {
230
+ if (!config.privateKey && !config.sshAgent && !config.password) {
231
+ throw new Error("SSH requires one of privateKey, sshAgent, or password.");
232
+ }
233
+ this.config = config;
234
+ }
235
+ async connect() {
236
+ if (this.client) return this.client;
237
+ if (this.connecting) return this.connecting;
238
+ this.connecting = (async () => {
239
+ const client = await connectSshClient(this.config);
240
+ const onTransportDown = (cause) => {
241
+ if (this.client !== client) return;
242
+ this.client = null;
243
+ this.sftpChannel = null;
244
+ this.handleDisconnect(cause);
245
+ };
246
+ client.on("close", () => onTransportDown());
247
+ client.on("end", () => onTransportDown());
248
+ client.on("error", (err) => onTransportDown(err));
249
+ this.client = client;
250
+ this.connecting = null;
251
+ return client;
252
+ })();
253
+ return this.connecting;
254
+ }
255
+ /**
256
+ * Subscribe to transport-level disconnects. Returns an unsubscribe fn.
257
+ */
258
+ onDisconnect(cb) {
259
+ this.disconnectListeners.add(cb);
260
+ return () => {
261
+ this.disconnectListeners.delete(cb);
262
+ };
263
+ }
264
+ /**
265
+ * The transport died. Reject every in-flight exec/stream with a typed
266
+ * SshDisconnectedError — so they fail in <1s instead of hanging to their
267
+ * per-command timeout on a dead channel — then notify subscribers so the
268
+ * manager can reconnect / re-drive journaled ops.
269
+ */
270
+ handleDisconnect(cause) {
271
+ const err = new SshDisconnectedError(
272
+ cause?.message ? `SSH connection lost: ${cause.message}` : "SSH connection lost"
273
+ );
274
+ const aborts = [...this.inflight];
275
+ this.inflight.clear();
276
+ for (const abort of aborts) {
277
+ try {
278
+ abort(err);
279
+ } catch {
280
+ }
281
+ }
282
+ for (const cb of [...this.disconnectListeners]) {
283
+ try {
284
+ cb(err);
285
+ } catch {
286
+ }
287
+ }
288
+ }
289
+ /**
290
+ * One SFTP subsystem channel per client, opened lazily and shared by every
291
+ * file op. ssh2's SFTPWrapper pipelines many concurrent requests over a
292
+ * single channel, so file operations cost exactly ONE session against the
293
+ * server's MaxSessions — not one channel per op, which leaks and exhausts
294
+ * the quota, then takes down the whole connection (incl. a live build) on
295
+ * the next channel-open failure (#34). Self-clears on the channel's own
296
+ * close/error and on resetConnection/dispose so the next op reopens clean.
297
+ */
298
+ async sftp() {
299
+ const client = await this.connect();
300
+ if (this.sftpChannel) return this.sftpChannel;
301
+ const opening = openSftp(client).then((wrapper) => {
302
+ const drop = () => {
303
+ if (this.sftpChannel === opening) this.sftpChannel = null;
304
+ };
305
+ wrapper.once("close", drop);
306
+ wrapper.once("error", drop);
307
+ return wrapper;
308
+ });
309
+ opening.catch(() => {
310
+ if (this.sftpChannel === opening) this.sftpChannel = null;
311
+ });
312
+ this.sftpChannel = opening;
313
+ return opening;
314
+ }
315
+ /** Close + forget the shared SFTP channel (frees its session) without
316
+ * touching the SSH client, so file ops can reopen on the same connection. */
317
+ dropSftp() {
318
+ const ch = this.sftpChannel;
319
+ this.sftpChannel = null;
320
+ if (ch) ch.then((w) => {
321
+ try {
322
+ w.end();
323
+ } catch {
324
+ }
325
+ }).catch(() => {
326
+ });
327
+ }
328
+ /**
329
+ * Force-close the current connection so the next call reconnects.
330
+ */
331
+ resetConnection() {
332
+ this.dropSftp();
333
+ if (this.client) {
334
+ try {
335
+ this.client.end();
336
+ } catch {
337
+ }
338
+ this.client = null;
339
+ }
340
+ this.connecting = null;
341
+ }
342
+ /**
343
+ * Recover from a channel-open failure without killing live work. If other
344
+ * ops are still streaming on this connection (a docker build, an exec), the
345
+ * failure is session pressure — NOT a dead socket — so ending the client
346
+ * would abort that in-flight command (#34). Free the SFTP channel and keep
347
+ * the connection. With nothing in flight the cached socket is stale (idle
348
+ * drop), so reset it fully.
349
+ */
350
+ recoverFromChannelError() {
351
+ if (this.inflight.size > 0) this.dropSftp();
352
+ else this.resetConnection();
353
+ }
354
+ /** Returns true if the error is an SSH channel-open failure. */
355
+ static isChannelError(err) {
356
+ if (!(err instanceof Error)) return false;
357
+ const msg = err.message.toLowerCase();
358
+ return msg.includes("channel open failure") || msg.includes("open failed");
359
+ }
360
+ /**
361
+ * Run an operation, and if it fails opening an SSH channel on a half-dead
362
+ * cached connection ("Channel open failure: open failed" — common after the
363
+ * idle timeout drops the socket), drop the connection and retry ONCE on a
364
+ * fresh one. This is why `exec` survives a stale connection; the SFTP-based
365
+ * ops (writeFile/readFile/exists) must go through it too, or a deploy's route
366
+ * write fails spuriously and only succeeds on a manual redeploy.
367
+ */
368
+ async withChannelRetry(fn) {
369
+ try {
370
+ return await fn();
371
+ } catch (err) {
372
+ if (_SshExecutor.isChannelError(err)) {
373
+ this.recoverFromChannelError();
374
+ return fn();
375
+ }
376
+ throw err;
377
+ }
378
+ }
379
+ async exec(command, opts) {
380
+ return this.withChannelRetry(() => this._exec(command, opts));
381
+ }
382
+ /** Prefix applied to every SSH command - keeps dpkg non-interactive. */
383
+ static ENV_PREFIX = "export DEBIAN_FRONTEND=noninteractive DPKG_FORCE=confnew && ";
384
+ async _exec(command, opts) {
385
+ const client = await this.connect();
386
+ const timeout = opts?.timeout ?? 3e4;
387
+ return new Promise((resolve, reject) => {
388
+ let settled = false;
389
+ let timer = null;
390
+ const abort = (err) => finish(() => reject(err));
391
+ const finish = (act) => {
392
+ if (settled) return;
393
+ settled = true;
394
+ if (timer) clearTimeout(timer);
395
+ this.inflight.delete(abort);
396
+ act();
397
+ };
398
+ this.inflight.add(abort);
399
+ timer = setTimeout(
400
+ () => finish(() => reject(new Error(`Command timed out after ${timeout}ms: ${command}`))),
401
+ timeout
402
+ );
403
+ client.exec(_SshExecutor.ENV_PREFIX + command, (err, stream) => {
404
+ if (err) return finish(() => reject(err));
405
+ let stdout = "";
406
+ let stderr = "";
407
+ stream.on("data", (data) => {
408
+ stdout += data.toString();
409
+ });
410
+ stream.stderr.on("data", (data) => {
411
+ stderr += data.toString();
412
+ });
413
+ stream.on("close", (code) => {
414
+ finish(() => {
415
+ if (code !== 0) {
416
+ const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
417
+ reject(new Error(detail || `Exit code ${code}`));
418
+ } else resolve(stdout.trim());
419
+ });
420
+ });
421
+ });
422
+ });
423
+ }
424
+ streamExec(command, onLog) {
425
+ return this._streamExec(command, onLog).catch((err) => {
426
+ if (_SshExecutor.isChannelError(err)) {
427
+ this.recoverFromChannelError();
428
+ return this._streamExec(command, onLog);
429
+ }
430
+ throw err;
431
+ });
432
+ }
433
+ async _streamExec(command, onLog) {
434
+ const client = await this.connect();
435
+ return new Promise((resolve, reject) => {
436
+ let settled = false;
437
+ const abort = (err) => finish(() => reject(err));
438
+ const finish = (act) => {
439
+ if (settled) return;
440
+ settled = true;
441
+ this.inflight.delete(abort);
442
+ act();
443
+ };
444
+ this.inflight.add(abort);
445
+ client.exec(_SshExecutor.ENV_PREFIX + command, (err, stream) => {
446
+ if (err) return finish(() => reject(err));
447
+ const chunks = [];
448
+ const onChunk = (data, level) => {
449
+ const text = data.toString();
450
+ if (!text) return;
451
+ chunks.push(text);
452
+ onLog(logEntry(text, level, data.toString("base64")));
453
+ };
454
+ stream.on("data", (data) => onChunk(data, "info"));
455
+ stream.stderr.on("data", (data) => onChunk(data, "warn"));
456
+ let exitCode = null;
457
+ stream.on("exit", (code) => {
458
+ exitCode = code;
459
+ });
460
+ stream.on("close", (code) => {
461
+ finish(() => {
462
+ const final = typeof code === "number" ? code : exitCode;
463
+ if (final == null) {
464
+ reject(
465
+ new Error(
466
+ "remote channel closed without an exit status \u2014 the SSH connection or channel was terminated mid-command"
467
+ )
468
+ );
469
+ } else {
470
+ resolve({ code: final, output: chunks.join("") });
471
+ }
472
+ });
473
+ });
474
+ });
475
+ });
476
+ }
477
+ async writeFile(path, content) {
478
+ const dir = dirname(path);
479
+ try {
480
+ await this.exec(`mkdir -p ${sq(dir)}`);
481
+ } catch {
482
+ }
483
+ return this.withChannelRetry(async () => {
484
+ const sftp = await this.sftp();
485
+ return new Promise((resolve, reject) => {
486
+ sftp.writeFile(path, content, { encoding: "utf-8" }, (err) => {
487
+ if (err) reject(err);
488
+ else resolve();
489
+ });
490
+ });
491
+ });
492
+ }
493
+ async readFile(path) {
494
+ return this.withChannelRetry(async () => {
495
+ const sftp = await this.sftp();
496
+ return new Promise((resolve, reject) => {
497
+ sftp.readFile(path, { encoding: "utf-8" }, (err, data) => {
498
+ if (err) reject(err);
499
+ else resolve(data.toString());
500
+ });
501
+ });
502
+ });
503
+ }
504
+ async exists(path) {
505
+ return this.withChannelRetry(async () => {
506
+ const sftp = await this.sftp();
507
+ return new Promise((resolve) => {
508
+ sftp.stat(path, (err) => {
509
+ resolve(!err);
510
+ });
511
+ });
512
+ });
513
+ }
514
+ async mkdir(path) {
515
+ await this.exec(`mkdir -p ${sq(path)}`);
516
+ }
517
+ async rm(path) {
518
+ try {
519
+ await this.exec(`rm -rf ${sq(path)}`);
520
+ } catch {
521
+ }
522
+ }
523
+ rawExec(command) {
524
+ return (async () => {
525
+ const client = await this.connect();
526
+ return new Promise((resolve, reject) => {
527
+ client.exec(command, (err, stream) => {
528
+ if (err) return reject(err);
529
+ const onClose = new Promise((res) => {
530
+ stream.on("close", (code) => res(code ?? 1));
531
+ });
532
+ resolve({
533
+ stdout: stream,
534
+ stderr: stream.stderr,
535
+ onClose,
536
+ kill: () => {
537
+ try {
538
+ stream.close();
539
+ } catch {
540
+ }
541
+ }
542
+ });
543
+ });
544
+ });
545
+ })();
546
+ }
547
+ /**
548
+ * Pipe `body` into a remote command's stdin over a raw ssh2 channel, half-
549
+ * closing stdin at EOF. The streaming inverse of rawExec: used to stream a
550
+ * `docker save` tar straight into `docker load` on another host without
551
+ * staging the (multi-GB) image to a temp file. Bounded stderr tail for
552
+ * diagnostics; registered for transport-drop abort like _exec/_streamExec.
553
+ */
554
+ execWithInput(command, body) {
555
+ return (async () => {
556
+ const client = await this.connect();
557
+ return new Promise((resolve, reject) => {
558
+ let settled = false;
559
+ const abort = (err) => finish(() => reject(err));
560
+ const finish = (act) => {
561
+ if (settled) return;
562
+ settled = true;
563
+ this.inflight.delete(abort);
564
+ act();
565
+ };
566
+ this.inflight.add(abort);
567
+ client.exec(command, (err, stream) => {
568
+ if (err) return finish(() => reject(err));
569
+ let stderr = "";
570
+ stream.stderr.on("data", (d) => {
571
+ stderr += d.toString();
572
+ if (stderr.length > 16 * 1024) stderr = stderr.slice(-16 * 1024);
573
+ });
574
+ let stdout = "";
575
+ stream.on("data", (d) => {
576
+ stdout += d.toString();
577
+ if (stdout.length > 16 * 1024) stdout = stdout.slice(-16 * 1024);
578
+ });
579
+ let exitCode = null;
580
+ stream.on("exit", (code) => {
581
+ exitCode = code;
582
+ });
583
+ stream.on("close", (code) => {
584
+ finish(() => {
585
+ const final = typeof code === "number" ? code : exitCode;
586
+ if (final == null) {
587
+ reject(
588
+ new Error(
589
+ "remote channel closed without an exit status \u2014 the SSH connection was terminated mid-command"
590
+ )
591
+ );
592
+ } else {
593
+ resolve({ code: final, stderr: stderr.trim(), stdout: stdout.trim() });
594
+ }
595
+ });
596
+ });
597
+ body.on("error", (e) => finish(() => {
598
+ try {
599
+ stream.close();
600
+ } catch {
601
+ }
602
+ reject(e);
603
+ }));
604
+ stream.on("error", (e) => finish(() => reject(e)));
605
+ body.pipe(stream);
606
+ });
607
+ });
608
+ })();
609
+ }
610
+ /**
611
+ * Open an interactive PTY shell on the remote host. The returned
612
+ * ShellSession wraps an ssh2 ClientChannel: writes go to stdin,
613
+ * stdout/stderr emit on the readable streams, setWindow forwards to
614
+ * channel.setWindow, close ends the channel. Lifetime is bound to the
615
+ * channel - the underlying ssh2.Client stays cached by sshManager, so
616
+ * callers must wrap with `sshManager.retain(serverId)` / `release()`
617
+ * to avoid the 5-minute idle drop on the parent connection.
618
+ */
619
+ async openShell(opts) {
620
+ const client = await this.connect();
621
+ const cols = clampWindow(opts?.cols, 80, 1, 1e3);
622
+ const rows = clampWindow(opts?.rows, 24, 1, 500);
623
+ const term = opts?.term || "xterm-256color";
624
+ const channel = await new Promise(
625
+ (resolve, reject) => {
626
+ client.shell(
627
+ { term, cols, rows, width: 0, height: 0, modes: {} },
628
+ (err, ch) => err ? reject(err) : resolve(ch)
629
+ );
630
+ }
631
+ );
632
+ const closeListeners = [];
633
+ let closed = false;
634
+ const fireClose = (code, signal) => {
635
+ if (closed) return;
636
+ closed = true;
637
+ for (const cb of closeListeners) {
638
+ try {
639
+ cb(code, signal);
640
+ } catch {
641
+ }
642
+ }
643
+ };
644
+ channel.on("exit", (code, signal) => {
645
+ fireClose(code, signal);
646
+ });
647
+ channel.on("close", () => fireClose(null));
648
+ channel.on("error", () => fireClose(null));
649
+ return {
650
+ stdin: channel,
651
+ stdout: channel,
652
+ stderr: channel.stderr,
653
+ setWindow: (c, r) => {
654
+ const sc = clampWindow(c, 80, 1, 1e3);
655
+ const sr = clampWindow(r, 24, 1, 500);
656
+ try {
657
+ channel.setWindow(sr, sc, 0, 0);
658
+ } catch {
659
+ }
660
+ },
661
+ close: (_signal) => {
662
+ try {
663
+ channel.end();
664
+ } catch {
665
+ }
666
+ try {
667
+ channel.close();
668
+ } catch {
669
+ }
670
+ },
671
+ onClose: (cb) => {
672
+ closeListeners.push(cb);
673
+ }
674
+ };
675
+ }
676
+ async forwardUnixSocket(socketPath) {
677
+ const client = await this.connect();
678
+ return openSshUnixSocket(client, socketPath);
679
+ }
680
+ /**
681
+ * Carry the Docker Engine API over a `docker system dial-stdio` exec channel
682
+ * on the pooled connection — the streamlocal-free transport used when the
683
+ * SSH server (or the Bun-compiled desktop runtime) can't do socket
684
+ * forwarding. Uses the SAME ENV_PREFIX as streamExec so `docker` resolves on
685
+ * PATH exactly as it does for the remote build (which is proven to work).
686
+ */
687
+ async openDockerDialStdio() {
688
+ const client = await this.connect();
689
+ return new Promise((resolve, reject) => {
690
+ client.exec(_SshExecutor.ENV_PREFIX + "docker system dial-stdio", (err, stream) => {
691
+ if (err) return reject(err);
692
+ stream.stderr?.on("data", (d) => {
693
+ const text = d.toString().trim();
694
+ if (text) console.warn(`[docker-ssh] dial-stdio stderr: ${text}`);
695
+ });
696
+ stream.on("exit", (code) => {
697
+ if (code) console.warn(`[docker-ssh] dial-stdio exited early (code=${code})`);
698
+ });
699
+ resolve(stream);
700
+ });
701
+ });
702
+ }
703
+ async forwardPort(remoteHost, remotePort) {
704
+ const client = await this.connect();
705
+ return new Promise((resolve, reject) => {
706
+ client.forwardOut(
707
+ "127.0.0.1",
708
+ 0,
709
+ remoteHost,
710
+ remotePort,
711
+ (err, stream) => {
712
+ if (err) return reject(err);
713
+ resolve(stream);
714
+ }
715
+ );
716
+ });
717
+ }
718
+ /**
719
+ * Open a reverse tunnel: the remote listens on an ephemeral 127.0.0.1 port
720
+ * and every connection to it is handed to `onConnection` as a duplex stream
721
+ * over this SSH connection. ssh2's 'tcp connection' event is client-wide, so
722
+ * a single dispatcher routes by the bound `destPort` to the right handler.
723
+ */
724
+ async reverseForward(onConnection) {
725
+ const client = await this.connect();
726
+ this.attachReverseListener(client);
727
+ const port = await new Promise((resolve, reject) => {
728
+ client.forwardIn("127.0.0.1", 0, (err, boundPort) => {
729
+ if (err) return reject(err);
730
+ resolve(boundPort);
731
+ });
732
+ });
733
+ this.reverseHandlers.set(port, onConnection);
734
+ return {
735
+ port,
736
+ close: async () => {
737
+ this.reverseHandlers.delete(port);
738
+ await new Promise((resolve) => {
739
+ try {
740
+ client.unforwardIn("127.0.0.1", port, () => resolve());
741
+ } catch {
742
+ resolve();
743
+ }
744
+ });
745
+ }
746
+ };
747
+ }
748
+ /** Attach the single client-wide 'tcp connection' dispatcher (idempotent per client). */
749
+ attachReverseListener(client) {
750
+ if (this.reverseListenerClient === client) return;
751
+ this.reverseListenerClient = client;
752
+ client.on("tcp connection", (details, accept, reject) => {
753
+ const handler = this.reverseHandlers.get(details.destPort);
754
+ if (!handler) {
755
+ try {
756
+ reject();
757
+ } catch {
758
+ }
759
+ return;
760
+ }
761
+ const channel = accept();
762
+ handler(channel);
763
+ });
764
+ }
765
+ async dispose() {
766
+ this.connecting = null;
767
+ this.sftpChannel = null;
768
+ this.reverseHandlers.clear();
769
+ this.reverseListenerClient = null;
770
+ if (this.client) {
771
+ this.client.end();
772
+ this.client = null;
773
+ }
774
+ }
775
+ async transferIn(localPath, remotePath, onLog, options) {
776
+ const deps = { config: this.config, hasRemoteCommand: (c) => this.hasRemoteCommand(c) };
777
+ const excludes = options?.excludes ?? [...TRANSFER_EXCLUDES];
778
+ const { args: tarArgs, cleanup: cleanupTarList } = await prepareSourceTarArgs(localPath, {
779
+ excludes,
780
+ includes: options?.includes,
781
+ alsoInclude: options?.alsoInclude
782
+ });
783
+ const tmpLocalDir = await mkdtemp2(join2(tmpdir2(), "openship-xfer-"));
784
+ const localArchive = join2(tmpLocalDir, "context.tar.gz");
785
+ const remoteArchive = `${remotePath}.openship-xfer.tar.gz`;
786
+ try {
787
+ onLog?.(logEntry("Packing source into a single archive..."));
788
+ await packLocalArchive(tarArgs, localArchive);
789
+ const totalBytes = (await stat(localArchive)).size;
790
+ await this.exec(`mkdir -p ${sq(dirname(remoteArchive))}`);
791
+ const rsync = await canUseRemoteRsync(deps);
792
+ if (rsync.ok) {
793
+ onLog?.(logEntry(`Uploading ${formatBytes(totalBytes)} archive via rsync (resumable)...`));
794
+ await uploadFileWithRsync(localArchive, remoteArchive, deps, onLog);
795
+ } else {
796
+ onLog?.(
797
+ logEntry(`Uploading ${formatBytes(totalBytes)} archive via SFTP (resumable) \u2014 ${rsync.reason}.`)
798
+ );
799
+ await this.sftpUploadResumable(localArchive, remoteArchive, totalBytes, onLog);
800
+ }
801
+ await extractRemoteArchive((command) => this.exec(command), remoteArchive, remotePath, totalBytes, onLog);
802
+ } finally {
803
+ await cleanupTarList().catch(() => {
804
+ });
805
+ await fsRm2(tmpLocalDir, { recursive: true, force: true }).catch(() => {
806
+ });
807
+ }
808
+ }
809
+ async hasRemoteCommand(command) {
810
+ try {
811
+ await this.exec(`command -v ${command} >/dev/null 2>&1 && echo ok`, { timeout: 5e3 });
812
+ return true;
813
+ } catch {
814
+ return false;
815
+ }
816
+ }
817
+ /**
818
+ * Resumable SFTP upload (the fallback when rsync isn't available — password
819
+ * auth with no `sshpass`). Each attempt `stat`s the remote to learn how much
820
+ * already landed and streams the REST from that offset (append), so a dropped
821
+ * or stalled connection continues instead of restarting from 0. A watchdog
822
+ * aborts the attempt if no bytes flow for `STALL_MS`, and the loop retries
823
+ * (resuming) up to `MAX_ATTEMPTS`.
824
+ */
825
+ async sftpUploadResumable(localArchive, remoteArchive, totalBytes, onLog) {
826
+ const MAX_ATTEMPTS = 4;
827
+ const STALL_MS = 3e4;
828
+ let lastErr = null;
829
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
830
+ const sftp = await this.sftp();
831
+ let offset = 0;
832
+ try {
833
+ const size = await new Promise(
834
+ (resolve, reject) => sftp.stat(remoteArchive, (err, stats) => err ? reject(err) : resolve(stats.size))
835
+ );
836
+ if (size === totalBytes) return;
837
+ if (size < totalBytes) offset = size;
838
+ } catch {
839
+ offset = 0;
840
+ }
841
+ if (attempt > 1 || offset > 0) {
842
+ onLog?.(
843
+ logEntry(
844
+ `Resuming SFTP upload from ${formatBytes(offset)} (attempt ${attempt}/${MAX_ATTEMPTS})...`,
845
+ "warn"
846
+ )
847
+ );
848
+ }
849
+ try {
850
+ await this.sftpStreamFrom(sftp, localArchive, remoteArchive, offset, totalBytes, STALL_MS, onLog);
851
+ return;
852
+ } catch (err) {
853
+ lastErr = err instanceof Error ? err : new Error(String(err));
854
+ onLog?.(logEntry(`SFTP upload interrupted: ${lastErr.message}`, "warn"));
855
+ }
856
+ }
857
+ throw lastErr ?? new Error("SFTP upload failed");
858
+ }
859
+ /** Stream `localArchive` (from `offset`) into `remoteArchive`, appending when
860
+ * resuming. Rejects on error, on a stall (no bytes for `stallMs`), or if the
861
+ * stream closes before `totalBytes` land. */
862
+ sftpStreamFrom(sftp, localArchive, remoteArchive, offset, totalBytes, stallMs, onLog) {
863
+ return new Promise((resolve, reject) => {
864
+ const read = createReadStream(localArchive, { start: offset });
865
+ const write = sftp.createWriteStream(remoteArchive, { flags: offset > 0 ? "a" : "w" });
866
+ let transferred = offset;
867
+ let lastProgressAt = Date.now();
868
+ const startedAt = Date.now();
869
+ let lastReportedAt = startedAt;
870
+ let settled = false;
871
+ const finish = (fn) => {
872
+ if (settled) return;
873
+ settled = true;
874
+ clearInterval(watch);
875
+ fn();
876
+ };
877
+ const watch = setInterval(() => {
878
+ if (Date.now() - lastProgressAt > stallMs) {
879
+ read.destroy();
880
+ write.end();
881
+ finish(() => reject(new Error(`stalled \u2014 no data for ${Math.round(stallMs / 1e3)}s`)));
882
+ }
883
+ }, 5e3);
884
+ watch.unref?.();
885
+ read.on("data", (chunk) => {
886
+ transferred += chunk.length;
887
+ lastProgressAt = Date.now();
888
+ const now = Date.now();
889
+ if (now - lastReportedAt >= 2500) {
890
+ lastReportedAt = now;
891
+ const elapsed = (now - startedAt) / 1e3;
892
+ const mbps = elapsed > 0 ? (transferred - offset) / 1024 / 1024 / elapsed : 0;
893
+ const pct = totalBytes > 0 ? Math.min(Math.floor(transferred / totalBytes * 100), 100) : 0;
894
+ onLog?.(logEntry(` ~${pct}% \xB7 ${formatBytes(transferred)} \xB7 ${mbps.toFixed(1)} MB/s`));
895
+ }
896
+ });
897
+ read.on("error", (e) => finish(() => reject(e)));
898
+ write.on("error", (e) => finish(() => reject(e)));
899
+ write.on(
900
+ "close",
901
+ () => finish(
902
+ () => transferred >= totalBytes ? resolve() : reject(new Error(`incomplete upload: ${transferred}/${totalBytes} bytes`))
903
+ )
904
+ );
905
+ read.pipe(write);
906
+ });
907
+ }
908
+ };
909
+
910
+ // ../../packages/adapters/src/system/system-ssh-executor.ts
911
+ import { execFile as execFile2, spawn as spawn2 } from "child_process";
912
+ import { once } from "events";
913
+ import { mkdtemp as mkdtemp3, rm as fsRm3, stat as stat2, unlink } from "fs/promises";
914
+ import { connect as netConnect } from "net";
915
+ import { tmpdir as tmpdir3 } from "os";
916
+ import { dirname as dirname2, join as join3 } from "path";
917
+ import { Duplex } from "stream";
918
+ import { promisify as promisify2 } from "util";
919
+ import { randomBytes as randomBytes2 } from "crypto";
920
+
921
+ // ../../packages/adapters/src/system/system-ssh.ts
922
+ import { randomBytes } from "crypto";
923
+ var CONNECT_TIMEOUT_SECONDS = 15;
924
+ function makeControlPath() {
925
+ return `/tmp/openship-ssh-${process.pid}-${randomBytes(6).toString("hex")}.sock`;
926
+ }
927
+ function buildBaseSshArgs(config, controlPath) {
928
+ const args = [
929
+ "-p",
930
+ String(config.port ?? 22),
931
+ // BatchMode keeps the OS ssh non-interactive: agent/keys only, never a
932
+ // password/passphrase prompt that would hang a headless API process.
933
+ "-o",
934
+ "BatchMode=yes",
935
+ "-o",
936
+ "StrictHostKeyChecking=accept-new",
937
+ "-o",
938
+ `ConnectTimeout=${CONNECT_TIMEOUT_SECONDS}`,
939
+ "-o",
940
+ "ServerAliveInterval=15",
941
+ "-o",
942
+ "ServerAliveCountMax=3",
943
+ // One authenticated master; subsequent ssh calls attach to it.
944
+ "-o",
945
+ "ControlMaster=auto",
946
+ "-o",
947
+ `ControlPath=${controlPath}`,
948
+ // Keep the master alive across brief idle gaps (the connection manager
949
+ // caches the executor for ~5 min); dispose() tears it down explicitly.
950
+ "-o",
951
+ "ControlPersist=300"
952
+ ];
953
+ if (config.sshJumpHost?.trim()) {
954
+ args.push("-J", config.sshJumpHost.trim());
955
+ }
956
+ if (config.sshArgs?.trim()) {
957
+ for (const token of config.sshArgs.trim().split(/\s+/)) {
958
+ if (token) args.push(token);
959
+ }
960
+ }
961
+ return args;
962
+ }
963
+ function sshTarget(config) {
964
+ return `${config.username ?? "root"}@${config.host}`;
965
+ }
966
+ function sshChildEnv(config) {
967
+ const env = { ...process.env };
968
+ if (config.sshAgent) {
969
+ env.SSH_AUTH_SOCK = config.sshAgent;
970
+ }
971
+ return env;
972
+ }
973
+
974
+ // ../../packages/adapters/src/system/reverse-tunnel.ts
975
+ import { execFile } from "child_process";
976
+ import { createServer } from "net";
977
+ import { promisify } from "util";
978
+ var execFileAsync = promisify(execFile);
979
+ async function openSystemSshReverseTunnel(opts) {
980
+ const server = createServer((socket) => opts.onConnection(socket));
981
+ await new Promise((resolve, reject) => {
982
+ server.once("error", reject);
983
+ server.listen(0, "127.0.0.1", () => {
984
+ server.removeListener("error", reject);
985
+ resolve();
986
+ });
987
+ });
988
+ const localPort = server.address().port;
989
+ const closeServer = () => new Promise((resolve) => server.close(() => resolve()));
990
+ const sshBin = opts.sshBin ?? "ssh";
991
+ const forwardSpec = (remote) => `127.0.0.1:${remote}:127.0.0.1:${localPort}`;
992
+ let remotePort;
993
+ try {
994
+ const { stdout } = await execFileAsync(
995
+ sshBin,
996
+ [...opts.baseArgs, "-O", "forward", "-R", forwardSpec(0), opts.target],
997
+ { env: opts.env }
998
+ );
999
+ remotePort = Number.parseInt(stdout.trim(), 10);
1000
+ if (!Number.isInteger(remotePort) || remotePort <= 0) {
1001
+ throw new Error(`ssh -O forward returned no port (stdout: ${JSON.stringify(stdout.trim())})`);
1002
+ }
1003
+ } catch (err) {
1004
+ await closeServer();
1005
+ throw err;
1006
+ }
1007
+ let closed = false;
1008
+ const close = async () => {
1009
+ if (closed) return;
1010
+ closed = true;
1011
+ await execFileAsync(
1012
+ sshBin,
1013
+ [...opts.baseArgs, "-O", "cancel", "-R", forwardSpec(remotePort), opts.target],
1014
+ { env: opts.env }
1015
+ ).catch(() => {
1016
+ });
1017
+ await closeServer();
1018
+ };
1019
+ return { port: remotePort, close };
1020
+ }
1021
+
1022
+ // ../../packages/adapters/src/system/system-ssh-executor.ts
1023
+ var execFileAsync2 = promisify2(execFile2);
1024
+ function clampWindow2(value, fallback, min, max) {
1025
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
1026
+ return Math.max(min, Math.min(max, Math.floor(value)));
1027
+ }
1028
+ function describeSshFailure(stderr, fallback) {
1029
+ const text = stderr.trim() || fallback;
1030
+ if (/permission denied|authentication failed|too many authentication failures/i.test(text)) {
1031
+ return new Error(`All configured authentication methods failed: ${text}`);
1032
+ }
1033
+ return new Error(text);
1034
+ }
1035
+ var SystemSshExecutor = class _SystemSshExecutor {
1036
+ config;
1037
+ controlPath = makeControlPath();
1038
+ /** Resolves once the ControlMaster connection is established. */
1039
+ masterPromise = null;
1040
+ /** Remote-socket → local-forward-socket, one StreamLocal forward per target. */
1041
+ socketForwards = /* @__PURE__ */ new Map();
1042
+ localSockets = /* @__PURE__ */ new Set();
1043
+ disconnectListeners = /* @__PURE__ */ new Set();
1044
+ disposed = false;
1045
+ /** Prefix applied to every remote command - keeps dpkg non-interactive. */
1046
+ static ENV_PREFIX = "export DEBIAN_FRONTEND=noninteractive DPKG_FORCE=confnew && ";
1047
+ constructor(config) {
1048
+ if (!config.host) {
1049
+ throw new Error("System SSH executor requires a host.");
1050
+ }
1051
+ this.config = config;
1052
+ }
1053
+ baseArgs() {
1054
+ return buildBaseSshArgs(this.config, this.controlPath);
1055
+ }
1056
+ onDisconnect(cb) {
1057
+ this.disconnectListeners.add(cb);
1058
+ return () => {
1059
+ this.disconnectListeners.delete(cb);
1060
+ };
1061
+ }
1062
+ /**
1063
+ * On an SSH-level failure (exit 255 — which OpenSSH uses for ANY ssh-level
1064
+ * error: a dropped transport AND an auth failure alike, with no granular
1065
+ * code to tell them apart), authoritatively probe the ControlMaster with
1066
+ * `ssh -O check` instead of guessing from locale/version-dependent stderr.
1067
+ * Only a genuinely-dead master counts as a disconnect: we null it so the
1068
+ * next op reopens, and notify subscribers so the manager can react.
1069
+ */
1070
+ async maybeSignalDisconnect(code) {
1071
+ if (code !== 255 || this.disposed) return;
1072
+ if (!this.masterPromise) return;
1073
+ if (await this.isMasterAlive()) return;
1074
+ this.masterPromise = null;
1075
+ const err = new SshDisconnectedError("SSH master connection lost");
1076
+ for (const cb of [...this.disconnectListeners]) {
1077
+ try {
1078
+ cb(err);
1079
+ } catch {
1080
+ }
1081
+ }
1082
+ }
1083
+ /** Authoritative ControlMaster liveness check: `ssh -O check` exits 0
1084
+ * ("Master running") when the multiplex socket is alive, non-zero when it's
1085
+ * missing/dead. Immune to stderr locale/version drift. */
1086
+ async isMasterAlive() {
1087
+ try {
1088
+ await execFileAsync2("ssh", [...this.baseArgs(), "-O", "check", sshTarget(this.config)], {
1089
+ timeout: 5e3,
1090
+ env: sshChildEnv(this.config)
1091
+ });
1092
+ return true;
1093
+ } catch {
1094
+ return false;
1095
+ }
1096
+ }
1097
+ /** Open (once) the multiplexed master connection. Authenticates here. */
1098
+ async ensureMaster() {
1099
+ if (this.masterPromise) return this.masterPromise;
1100
+ this.masterPromise = (async () => {
1101
+ try {
1102
+ await execFileAsync2("ssh", [...this.baseArgs(), "-fN", sshTarget(this.config)], {
1103
+ timeout: 3e4,
1104
+ env: sshChildEnv(this.config)
1105
+ });
1106
+ } catch (err) {
1107
+ this.masterPromise = null;
1108
+ const e = err;
1109
+ throw describeSshFailure(e.stderr ?? "", safeErrorMessage(err));
1110
+ }
1111
+ })();
1112
+ return this.masterPromise;
1113
+ }
1114
+ /**
1115
+ * Open a reverse tunnel over the ControlMaster: the remote binds an ephemeral
1116
+ * 127.0.0.1 port whose connections arrive here as duplex streams. The
1117
+ * system-ssh counterpart to `SshExecutor.reverseForward` (ssh2) — same shape,
1118
+ * so `CommandExecutor.reverseForward` consumers (the git-credential relay,
1119
+ * future callers) work over agent auth too. Delegates to the generic
1120
+ * `openSystemSshReverseTunnel`.
1121
+ */
1122
+ async reverseForward(onConnection) {
1123
+ await this.ensureMaster();
1124
+ return openSystemSshReverseTunnel({
1125
+ baseArgs: this.baseArgs(),
1126
+ target: sshTarget(this.config),
1127
+ env: sshChildEnv(this.config),
1128
+ onConnection
1129
+ });
1130
+ }
1131
+ /** Run a remote command, resolving with stdout/stderr/exit code (never rejects on non-zero). */
1132
+ async runSsh(remoteCommand, opts) {
1133
+ await this.ensureMaster();
1134
+ return new Promise((resolve, reject) => {
1135
+ const child = spawn2("ssh", [...this.baseArgs(), sshTarget(this.config), remoteCommand], {
1136
+ env: sshChildEnv(this.config),
1137
+ stdio: ["pipe", "pipe", "pipe"]
1138
+ });
1139
+ let stdout = "";
1140
+ let stderr = "";
1141
+ let timedOut = false;
1142
+ let timer = null;
1143
+ if (opts?.timeout) {
1144
+ timer = setTimeout(() => {
1145
+ timedOut = true;
1146
+ child.kill("SIGKILL");
1147
+ }, opts.timeout);
1148
+ timer.unref?.();
1149
+ }
1150
+ child.stdout.on("data", (d) => {
1151
+ stdout += d.toString();
1152
+ });
1153
+ child.stderr.on("data", (d) => {
1154
+ stderr += d.toString();
1155
+ });
1156
+ child.on("error", (e) => {
1157
+ if (timer) clearTimeout(timer);
1158
+ reject(new Error(`ssh failed to start: ${e.message}`));
1159
+ });
1160
+ child.on("close", (code) => {
1161
+ if (timer) clearTimeout(timer);
1162
+ const c = code ?? 1;
1163
+ void this.maybeSignalDisconnect(c).catch(() => {
1164
+ });
1165
+ resolve({ stdout, stderr, code: c, timedOut });
1166
+ });
1167
+ if (opts?.input !== void 0) {
1168
+ child.stdin.write(opts.input);
1169
+ }
1170
+ child.stdin.end();
1171
+ });
1172
+ }
1173
+ async exec(command, opts) {
1174
+ const timeout = opts?.timeout ?? 3e4;
1175
+ const res = await this.runSsh(_SystemSshExecutor.ENV_PREFIX + command, { timeout });
1176
+ if (res.timedOut) {
1177
+ throw new Error(`Command timed out after ${timeout}ms: ${command}`);
1178
+ }
1179
+ if (res.code !== 0) {
1180
+ if (res.code === 255) throw describeSshFailure(res.stderr, `Exit code ${res.code}`);
1181
+ throw new Error(res.stderr.trim() || `Exit code ${res.code}`);
1182
+ }
1183
+ return res.stdout.trim();
1184
+ }
1185
+ async streamExec(command, onLog) {
1186
+ await this.ensureMaster();
1187
+ return new Promise((resolve) => {
1188
+ const child = spawn2(
1189
+ "ssh",
1190
+ [...this.baseArgs(), sshTarget(this.config), _SystemSshExecutor.ENV_PREFIX + command],
1191
+ { env: sshChildEnv(this.config), stdio: ["ignore", "pipe", "pipe"] }
1192
+ );
1193
+ const chunks = [];
1194
+ const onChunk = (chunk, level) => {
1195
+ const text = chunk.toString();
1196
+ if (!text) return;
1197
+ chunks.push(text);
1198
+ onLog(logEntry(text, level, chunk.toString("base64")));
1199
+ };
1200
+ child.stdout.on("data", (chunk) => onChunk(chunk, "info"));
1201
+ child.stderr.on("data", (chunk) => onChunk(chunk, "warn"));
1202
+ child.on("error", (err) => {
1203
+ onLog(logEntry(`Process error: ${err.message}`, "error"));
1204
+ resolve({ code: 1, output: err.message });
1205
+ });
1206
+ child.on("close", (code) => {
1207
+ const c = code ?? 1;
1208
+ void this.maybeSignalDisconnect(c).catch(() => {
1209
+ });
1210
+ resolve({ code: c, output: chunks.join("") });
1211
+ });
1212
+ });
1213
+ }
1214
+ async writeFile(path, content) {
1215
+ const remoteCommand = `mkdir -p ${sq(dirname2(path))} && cat > ${sq(path)}`;
1216
+ const res = await this.runSsh(remoteCommand, { input: content });
1217
+ if (res.code !== 0) {
1218
+ throw new Error(res.stderr.trim() || `Failed to write ${path} (exit ${res.code})`);
1219
+ }
1220
+ }
1221
+ async readFile(path) {
1222
+ const res = await this.runSsh(`cat ${sq(path)}`);
1223
+ if (res.code !== 0) {
1224
+ throw new Error(res.stderr.trim() || `Failed to read ${path} (exit ${res.code})`);
1225
+ }
1226
+ return res.stdout;
1227
+ }
1228
+ async exists(path) {
1229
+ const res = await this.runSsh(`test -e ${sq(path)}`);
1230
+ return res.code === 0;
1231
+ }
1232
+ async mkdir(path) {
1233
+ await this.exec(`mkdir -p ${sq(path)}`);
1234
+ }
1235
+ async rm(path) {
1236
+ try {
1237
+ await this.exec(`rm -rf ${sq(path)}`);
1238
+ } catch {
1239
+ }
1240
+ }
1241
+ /** Pipe a local command's stdout into a remote command's stdin over ssh. */
1242
+ async pipeLocal(localCmd, remoteCmd, onLog, onBytes) {
1243
+ await this.ensureMaster();
1244
+ return new Promise((resolve, reject) => {
1245
+ onLog?.(logEntry(`local: ${localCmd}`));
1246
+ const remote = spawn2("ssh", [...this.baseArgs(), sshTarget(this.config), remoteCmd], {
1247
+ env: sshChildEnv(this.config),
1248
+ stdio: ["pipe", "pipe", "pipe"]
1249
+ });
1250
+ const local = spawn2("sh", ["-c", localCmd], { stdio: ["ignore", "pipe", "pipe"] });
1251
+ if (onBytes) {
1252
+ local.stdout.on("data", (chunk) => onBytes(chunk.length));
1253
+ }
1254
+ local.stdout.pipe(remote.stdin);
1255
+ local.stderr.on("data", (d) => {
1256
+ const text = d.toString().trim();
1257
+ if (text) onLog?.(logEntry(`local stderr: ${text}`, "warn"));
1258
+ });
1259
+ remote.stderr.on("data", (d) => {
1260
+ const text = d.toString().trim();
1261
+ if (text) onLog?.(logEntry(`remote stderr: ${text}`, "warn"));
1262
+ });
1263
+ local.on("error", (e) => reject(new Error(`Local process failed to start: ${e.message}`)));
1264
+ remote.on("error", (e) => reject(new Error(`ssh failed to start: ${e.message}`)));
1265
+ remote.on("close", (code) => resolve({ code: code ?? 1 }));
1266
+ });
1267
+ }
1268
+ async transferIn(localPath, remotePath, onLog, options) {
1269
+ const deps = { config: this.config, hasRemoteCommand: (c) => this.hasRemoteCommand(c) };
1270
+ const excludes = options?.excludes ?? [...TRANSFER_EXCLUDES];
1271
+ const { args: tarArgs, cleanup: cleanupTarList } = await prepareSourceTarArgs(localPath, {
1272
+ excludes,
1273
+ includes: options?.includes,
1274
+ alsoInclude: options?.alsoInclude
1275
+ });
1276
+ const tmpLocalDir = await mkdtemp3(join3(tmpdir3(), "openship-xfer-"));
1277
+ const localArchive = join3(tmpLocalDir, "context.tar.gz");
1278
+ const remoteArchive = `${remotePath}.openship-xfer.tar.gz`;
1279
+ try {
1280
+ onLog?.(logEntry("Packing source into a single archive..."));
1281
+ await packLocalArchive(tarArgs, localArchive);
1282
+ const totalBytes = (await stat2(localArchive)).size;
1283
+ await this.exec(`mkdir -p ${sq(dirname2(remoteArchive))}`);
1284
+ const rsync = await canUseRemoteRsync(deps);
1285
+ if (rsync.ok) {
1286
+ onLog?.(logEntry(`Uploading ${formatBytes(totalBytes)} archive via rsync (resumable)...`));
1287
+ await uploadFileWithRsync(localArchive, remoteArchive, deps, onLog);
1288
+ } else {
1289
+ onLog?.(
1290
+ logEntry(`Uploading ${formatBytes(totalBytes)} archive over the SSH connection \u2014 ${rsync.reason}.`)
1291
+ );
1292
+ await this.streamArchiveWithRetry(localArchive, remoteArchive, onLog);
1293
+ }
1294
+ await extractRemoteArchive((command) => this.exec(command), remoteArchive, remotePath, totalBytes, onLog);
1295
+ } finally {
1296
+ await cleanupTarList().catch(() => {
1297
+ });
1298
+ await fsRm3(tmpLocalDir, { recursive: true, force: true }).catch(() => {
1299
+ });
1300
+ }
1301
+ }
1302
+ /** Stream a single local file over the ControlMaster, retrying from 0 on
1303
+ * failure (the `cat >` truncates, so there is no partial to resume). */
1304
+ async streamArchiveWithRetry(localArchive, remoteArchive, onLog) {
1305
+ const MAX_ATTEMPTS = 3;
1306
+ let lastErr = null;
1307
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
1308
+ if (attempt > 1) onLog?.(logEntry(`Retrying upload (attempt ${attempt}/${MAX_ATTEMPTS})...`, "warn"));
1309
+ try {
1310
+ const { code } = await this.pipeLocal(`cat ${sq(localArchive)}`, `cat > ${sq(remoteArchive)}`, onLog);
1311
+ if (code === 0) return;
1312
+ lastErr = new Error(`archive upload failed (exit ${code})`);
1313
+ } catch (err) {
1314
+ lastErr = err instanceof Error ? err : new Error(String(err));
1315
+ }
1316
+ }
1317
+ throw lastErr ?? new Error("archive upload failed");
1318
+ }
1319
+ async hasRemoteCommand(command) {
1320
+ try {
1321
+ const out = await this.exec(`command -v ${command} >/dev/null 2>&1 && echo ok`, { timeout: 5e3 });
1322
+ return out.trim() === "ok";
1323
+ } catch {
1324
+ return false;
1325
+ }
1326
+ }
1327
+ async forwardPort(remoteHost, remotePort) {
1328
+ await this.ensureMaster();
1329
+ const child = spawn2(
1330
+ "ssh",
1331
+ [...this.baseArgs(), "-W", `${remoteHost}:${remotePort}`, sshTarget(this.config)],
1332
+ { env: sshChildEnv(this.config), stdio: ["pipe", "pipe", "ignore"] }
1333
+ );
1334
+ const duplex = Duplex.from({ writable: child.stdin, readable: child.stdout });
1335
+ duplex.on("close", () => {
1336
+ try {
1337
+ child.kill();
1338
+ } catch {
1339
+ }
1340
+ });
1341
+ child.on("exit", () => {
1342
+ duplex.destroy();
1343
+ });
1344
+ child.on("error", (e) => {
1345
+ duplex.destroy(e);
1346
+ });
1347
+ return duplex;
1348
+ }
1349
+ /** Ensure a StreamLocal forward (local unix socket → remote socket) on the master. */
1350
+ async ensureSocketForward(remoteSocket) {
1351
+ let pending = this.socketForwards.get(remoteSocket);
1352
+ if (!pending) {
1353
+ pending = (async () => {
1354
+ await this.ensureMaster();
1355
+ const localSocket = `/tmp/openship-fwd-${process.pid}-${randomBytes2(6).toString("hex")}.sock`;
1356
+ await execFileAsync2(
1357
+ "ssh",
1358
+ [...this.baseArgs(), "-O", "forward", "-L", `${localSocket}:${remoteSocket}`, sshTarget(this.config)],
1359
+ { timeout: 15e3, env: sshChildEnv(this.config) }
1360
+ );
1361
+ this.localSockets.add(localSocket);
1362
+ return localSocket;
1363
+ })();
1364
+ pending.catch(() => {
1365
+ if (this.socketForwards.get(remoteSocket) === pending) {
1366
+ this.socketForwards.delete(remoteSocket);
1367
+ }
1368
+ });
1369
+ this.socketForwards.set(remoteSocket, pending);
1370
+ }
1371
+ return pending;
1372
+ }
1373
+ async forwardUnixSocket(socketPath) {
1374
+ const attempt = async () => {
1375
+ const localSocket = await this.ensureSocketForward(socketPath);
1376
+ const sock = netConnect(localSocket);
1377
+ await once(sock, "connect");
1378
+ return sock;
1379
+ };
1380
+ try {
1381
+ return await attempt();
1382
+ } catch {
1383
+ this.socketForwards.delete(socketPath);
1384
+ return attempt();
1385
+ }
1386
+ }
1387
+ /**
1388
+ * Run a best-effort, fire-and-forget control command on the remote over the
1389
+ * existing master (no output captured, errors swallowed). Used for PTY
1390
+ * resize and marker cleanup.
1391
+ */
1392
+ fireAndForget(command) {
1393
+ try {
1394
+ spawn2("ssh", [...this.baseArgs(), sshTarget(this.config), command], {
1395
+ env: sshChildEnv(this.config),
1396
+ stdio: "ignore"
1397
+ }).on("error", () => {
1398
+ });
1399
+ } catch {
1400
+ }
1401
+ }
1402
+ async openShell(opts) {
1403
+ await this.ensureMaster();
1404
+ const cols = clampWindow2(opts?.cols, 80, 1, 1e3);
1405
+ const rows = clampWindow2(opts?.rows, 24, 1, 500);
1406
+ const term = opts?.term || "xterm-256color";
1407
+ const ptyMarker = `/tmp/openship-pty-${process.pid}-${randomBytes2(6).toString("hex")}`;
1408
+ const remoteInit = `tty > ${ptyMarker} 2>/dev/null; stty sane 2>/dev/null; stty cols ${cols} rows ${rows} 2>/dev/null; exec "\${SHELL:-/bin/sh}" -l`;
1409
+ const env = { ...sshChildEnv(this.config), TERM: term };
1410
+ const child = spawn2("ssh", [...this.baseArgs(), "-tt", sshTarget(this.config), remoteInit], {
1411
+ env,
1412
+ stdio: ["pipe", "pipe", "pipe"]
1413
+ });
1414
+ child.stdin.on("error", () => {
1415
+ });
1416
+ child.stdout.on("error", () => {
1417
+ });
1418
+ child.stderr.on("error", () => {
1419
+ });
1420
+ const closeListeners = [];
1421
+ let closed = false;
1422
+ let resizeTimer = null;
1423
+ let pendingSize = null;
1424
+ const fireClose = (code, signal) => {
1425
+ if (closed) return;
1426
+ closed = true;
1427
+ if (resizeTimer) clearTimeout(resizeTimer);
1428
+ this.fireAndForget(`rm -f ${ptyMarker}`);
1429
+ for (const cb of closeListeners) {
1430
+ try {
1431
+ cb(code, signal);
1432
+ } catch {
1433
+ }
1434
+ }
1435
+ };
1436
+ child.on("exit", (code, signal) => fireClose(code, signal ?? void 0));
1437
+ child.on("error", () => fireClose(null));
1438
+ return {
1439
+ stdin: child.stdin,
1440
+ stdout: child.stdout,
1441
+ stderr: child.stderr,
1442
+ setWindow: (c, r) => {
1443
+ pendingSize = { c: clampWindow2(c, 80, 1, 1e3), r: clampWindow2(r, 24, 1, 500) };
1444
+ if (resizeTimer) clearTimeout(resizeTimer);
1445
+ resizeTimer = setTimeout(() => {
1446
+ const size = pendingSize;
1447
+ pendingSize = null;
1448
+ if (!size || closed) return;
1449
+ this.fireAndForget(
1450
+ `for i in 1 2 3 4 5 6; do P=$(cat ${ptyMarker} 2>/dev/null); if [ -n "$P" ]; then stty -F "$P" cols ${size.c} rows ${size.r} 2>/dev/null || stty -f "$P" cols ${size.c} rows ${size.r} 2>/dev/null; break; fi; sleep 0.2; done`
1451
+ );
1452
+ }, 60);
1453
+ resizeTimer.unref?.();
1454
+ },
1455
+ close: (_signal) => {
1456
+ if (resizeTimer) clearTimeout(resizeTimer);
1457
+ try {
1458
+ child.kill();
1459
+ } catch {
1460
+ }
1461
+ },
1462
+ onClose: (cb) => {
1463
+ closeListeners.push(cb);
1464
+ }
1465
+ };
1466
+ }
1467
+ async dispose() {
1468
+ if (this.disposed) return;
1469
+ this.disposed = true;
1470
+ if (this.masterPromise) {
1471
+ try {
1472
+ await execFileAsync2("ssh", [...this.baseArgs(), "-O", "exit", sshTarget(this.config)], {
1473
+ timeout: 5e3,
1474
+ env: sshChildEnv(this.config)
1475
+ });
1476
+ } catch {
1477
+ }
1478
+ }
1479
+ this.masterPromise = null;
1480
+ this.socketForwards.clear();
1481
+ for (const socket of this.localSockets) {
1482
+ await unlink(socket).catch(() => {
1483
+ });
1484
+ }
1485
+ this.localSockets.clear();
1486
+ }
1487
+ };
1488
+
1489
+ // ../../packages/adapters/src/system/executor.ts
1490
+ function createExecutor(ssh) {
1491
+ if (ssh) {
1492
+ if (ssh.useSystemSsh) return new SystemSshExecutor(ssh);
1493
+ return new SshExecutor(ssh);
1494
+ }
1495
+ return new LocalExecutor();
1496
+ }
1497
+ function hostControlDisabled() {
1498
+ return process.env.OPENSHIP_HOST_CONTROL?.trim().toLowerCase() === "false";
1499
+ }
1500
+ function createHostExecutor() {
1501
+ if (hostControlDisabled()) {
1502
+ throw new Error(
1503
+ "Host control is disabled on this instance (OPENSHIP_HOST_CONTROL=false). Re-run `openship up` without --no-host-control to allow host operations."
1504
+ );
1505
+ }
1506
+ const host = process.env.OPENSHIP_HOST_SSH_HOST?.trim();
1507
+ if (!host) return new LocalExecutor();
1508
+ const keyPath = process.env.OPENSHIP_HOST_SSH_KEY?.trim();
1509
+ const portRaw = Number(process.env.OPENSHIP_HOST_SSH_PORT || "22");
1510
+ const port = Number.isInteger(portRaw) && portRaw > 0 && portRaw < 65536 ? portRaw : 22;
1511
+ let privateKey;
1512
+ if (keyPath) {
1513
+ try {
1514
+ privateKey = readFileSync(keyPath, "utf8");
1515
+ } catch (err) {
1516
+ throw new Error(
1517
+ `Cannot read host SSH key at OPENSHIP_HOST_SSH_KEY=${keyPath}: ${err instanceof Error ? err.message : String(err)}`
1518
+ );
1519
+ }
1520
+ }
1521
+ return new SshExecutor({
1522
+ host,
1523
+ port,
1524
+ username: process.env.OPENSHIP_HOST_SSH_USER?.trim() || "root",
1525
+ privateKey
1526
+ });
1527
+ }
1528
+
1529
+ export {
1530
+ SshExecutor,
1531
+ SystemSshExecutor,
1532
+ createExecutor,
1533
+ hostControlDisabled,
1534
+ createHostExecutor
1535
+ };