openship 0.6.0 → 0.6.5

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 (63) hide show
  1. package/README.md +7 -2
  2. package/dist/{bare-FSUAM5V5.js → bare-XQIPDO4R.js} +10 -9
  3. package/dist/{chunk-PSZSKEPA.js → chunk-3ZAGC5NT.js} +64 -17
  4. package/dist/{chunk-6ODUBKUB.js → chunk-5OUWIPOB.js} +69 -7
  5. package/dist/chunk-6HOA5JT7.js +124 -0
  6. package/dist/{chunk-TWJCLD4W.js → chunk-6JELW75A.js} +4 -4
  7. package/dist/{chunk-GUQ3QCJ7.js → chunk-7334NSJL.js} +399 -369
  8. package/dist/chunk-7S4USQYF.js +1753 -0
  9. package/dist/{chunk-3LDWWY7U.js → chunk-A7EWM2KA.js} +41 -7
  10. package/dist/chunk-ELGRMZOW.js +25 -0
  11. package/dist/chunk-EZPPTWJQ.js +292 -0
  12. package/dist/{chunk-IXM5245F.js → chunk-GRTXMUHA.js} +553 -538
  13. package/dist/chunk-HPXP6LXZ.js +428 -0
  14. package/dist/{chunk-OTLSJYAC.js → chunk-LEYSL46B.js} +2135 -85
  15. package/dist/{chunk-EMXI4UCU.js → chunk-LFCSC4IU.js} +29 -10
  16. package/dist/{chunk-JIMUYEKN.js → chunk-MSZUCNPV.js} +1 -1
  17. package/dist/{chunk-OGY63DGD.js → chunk-PZRRL5PC.js} +71 -28
  18. package/dist/{chunk-VE2YOK4I.js → chunk-QLX7NBEA.js} +1218 -588
  19. package/dist/chunk-RFWJE7ZN.js +68 -0
  20. package/dist/{chunk-VDDMADOF.js → chunk-TM2IQPYU.js} +16 -2
  21. package/dist/{chunk-3FYIXVX5.js → chunk-VDESJSG6.js} +451 -106
  22. package/dist/{chunk-B3CE2OMR.js → chunk-VHMOQL5Y.js} +276 -50
  23. package/dist/{chunk-QEH4QUBX.js → chunk-VLQ3UIVO.js} +393 -53
  24. package/dist/{chunk-VZ2ZHPS2.js → chunk-WE7BXW6L.js} +3 -9
  25. package/dist/chunk-WFRXYWK2.js +444 -0
  26. package/dist/chunk-XRNRT37W.js +316 -0
  27. package/dist/{chunk-EP2TNALB.js → chunk-XVOBL7BC.js} +31 -8
  28. package/dist/cloud-3DODOWTR.js +36 -0
  29. package/dist/{detect-ALF4GXRX.js → detect-P4CIQBRH.js} +3 -3
  30. package/dist/{docker-QECQJ5P4.js → docker-3K2NEBD6.js} +15 -8
  31. package/dist/{docker-edge-executor-62N64MGS.js → docker-edge-executor-YZZ6P7IC.js} +5 -5
  32. package/dist/{edge-import-CQWHN2Q3.js → edge-import-MCJNVTZ2.js} +26 -23
  33. package/dist/edge-preflight-ES6FH4JK.js +42 -0
  34. package/dist/{ensure-container-edge-MJVXJQG5.js → ensure-container-edge-YPITNH45.js} +9 -8
  35. package/dist/{executor-JELARVKC.js → executor-DZGX55K2.js} +12 -5
  36. package/dist/host-channel-preflight-MUXE3KZU.js +49 -0
  37. package/dist/index.js +3668 -4063
  38. package/dist/{nginx-AXGNWRKZ.js → nginx-NSAHWGJV.js} +11 -5
  39. package/dist/node-bootstrap.js +241 -0
  40. package/dist/node-entry.js +52 -1
  41. package/dist/{openresty-lua-IE4P5YQX.js → openresty-lua-DR4D3EZ6.js} +6 -2
  42. package/dist/privilege-YTVI4SKF.js +19 -0
  43. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +4 -1
  44. package/dist/server/index.js +21501 -13137
  45. package/dist/server/lua/geo_country.lua +4 -3
  46. package/dist/server/lua/pipe_stream.lua +52 -39
  47. package/dist/server/lua/site_logger.lua +22 -24
  48. package/dist/server/migrations/0098_instance_product_mode.sql +12 -0
  49. package/dist/server/migrations/0099_mail_webmail_project.sql +24 -0
  50. package/dist/server/migrations/0100_server_ssh_private_key.sql +11 -0
  51. package/dist/server/migrations/0101_mail_resume_step.sql +12 -0
  52. package/dist/server/migrations/0102_project_workload_axes.sql +19 -0
  53. package/dist/server/migrations/0103_dns_credential.sql +41 -0
  54. package/dist/server/migrations/0104_instance_host_control.sql +15 -0
  55. package/dist/server/migrations/meta/_journal.json +49 -0
  56. package/dist/{setup-FA4PW4NU.js → setup-WUUZURHS.js} +10 -9
  57. package/dist/up-XEO3SR6Z.js +58 -0
  58. package/package.json +4 -2
  59. package/dist/chunk-IW2UKBVF.js +0 -144
  60. package/dist/chunk-ZCFVHA25.js +0 -89
  61. package/dist/cloud-E5M6VMCG.js +0 -31
  62. package/dist/server/lua/pipe_log.lua +0 -77
  63. /package/dist/{dist-es-JGBPABJI.js → dist-es-ZP5QDSJ3.js} +0 -0
@@ -2,17 +2,32 @@
2
2
  ":" //# ; exec "$(command -v node || command -v bun)" "$0" "$@"
3
3
  import { createRequire as __ospCreateRequire } from "node:module";
4
4
  const require = __ospCreateRequire(import.meta.url);
5
- import {
6
- ENV_KEY,
7
- ENV_SECRET,
8
- fromEnv
9
- } from "./chunk-XZYFZ6A5.js";
10
5
  import {
11
6
  SignatureV4MultiRegion,
12
7
  XmlNode,
13
8
  XmlShapeDeserializer,
14
9
  XmlText
15
10
  } from "./chunk-QWHBRY2K.js";
11
+ import {
12
+ OS_DIR
13
+ } from "./chunk-ELGRMZOW.js";
14
+ import {
15
+ AwsCrc32,
16
+ __awaiter,
17
+ __generator,
18
+ __values,
19
+ convertToBuffer,
20
+ eventStreamSerdeProvider2 as eventStreamSerdeProvider,
21
+ isEmptyData,
22
+ numToUint8,
23
+ resolveEventStreamSerdeConfig,
24
+ uint32ArrayFrom
25
+ } from "./chunk-ONKEYSMY.js";
26
+ import {
27
+ ENV_KEY,
28
+ ENV_SECRET,
29
+ fromEnv
30
+ } from "./chunk-XZYFZ6A5.js";
16
31
  import {
17
32
  AwsSdkSigV4ASigner,
18
33
  AwsSdkSigV4Signer,
@@ -99,24 +114,6 @@ import {
99
114
  resolveRegionConfig,
100
115
  toEndpointV1
101
116
  } from "./chunk-3NQQXMDR.js";
102
- import {
103
- CloudRuntime
104
- } from "./chunk-PSZSKEPA.js";
105
- import {
106
- systemCatalog
107
- } from "./chunk-GUQ3QCJ7.js";
108
- import {
109
- AwsCrc32,
110
- __awaiter,
111
- __generator,
112
- __values,
113
- convertToBuffer,
114
- eventStreamSerdeProvider2 as eventStreamSerdeProvider,
115
- isEmptyData,
116
- numToUint8,
117
- resolveEventStreamSerdeConfig,
118
- uint32ArrayFrom
119
- } from "./chunk-ONKEYSMY.js";
120
117
  import {
121
118
  Hash,
122
119
  NumericValue,
@@ -136,41 +133,232 @@ import {
136
133
  toUtf8
137
134
  } from "./chunk-ZWLDLTJP.js";
138
135
  import {
136
+ CloudRuntime
137
+ } from "./chunk-3ZAGC5NT.js";
138
+ import {
139
+ systemCatalog
140
+ } from "./chunk-7334NSJL.js";
141
+ import {
142
+ DEFAULT_DOCKER_SOCKET_PATH,
139
143
  DockerRuntime,
140
144
  daemonConnectionFrom,
141
145
  isHostPathSource,
142
146
  resolveExecExitCode,
147
+ resolveLocalDockerSocketPath,
143
148
  scopedVolumeName,
144
149
  startAttachStream,
145
150
  startExecStream
146
- } from "./chunk-3FYIXVX5.js";
151
+ } from "./chunk-VDESJSG6.js";
147
152
  import {
148
153
  BareRuntime
149
- } from "./chunk-IXM5245F.js";
154
+ } from "./chunk-GRTXMUHA.js";
155
+ import {
156
+ createExecutor
157
+ } from "./chunk-VHMOQL5Y.js";
150
158
  import {
151
159
  LocalExecutor
152
160
  } from "./chunk-6FH4AXVY.js";
153
161
  import {
154
- EDGE_CONTAINER_NAME,
155
- edgeCrashReason,
156
162
  invalidateEdgeContainer,
157
163
  sanitizeEdgeVhosts
158
- } from "./chunk-VZ2ZHPS2.js";
164
+ } from "./chunk-WE7BXW6L.js";
159
165
  import {
160
166
  EDGE_CONTAINER_MOUNTS,
161
167
  EDGE_HOST_STATE_DIR,
162
168
  OPENRESTY_DEFAULT_PATHS
163
- } from "./chunk-OGY63DGD.js";
169
+ } from "./chunk-PZRRL5PC.js";
170
+ import {
171
+ envOps
172
+ } from "./chunk-EZPPTWJQ.js";
173
+ import {
174
+ ENVIRONMENT_PROBE_SCRIPT,
175
+ ENVIRONMENT_PROBE_TIMEOUT_MS,
176
+ cacheEnvironment,
177
+ parseEnvironmentProbe,
178
+ profileIsFresh
179
+ } from "./chunk-WFRXYWK2.js";
164
180
  import {
165
181
  DEFAULT_IMAGE_REGISTRY,
182
+ FAMILY_BY_DISTRO,
183
+ HOST_CHANNEL_BLOCKED,
184
+ HOST_CHANNEL_RECHECK,
185
+ HOST_CHANNEL_UNAFFECTED,
186
+ explainHostChannelCause,
166
187
  isAwsS3Endpoint,
167
188
  safeErrorMessage,
168
- withTimeout
169
- } from "./chunk-OTLSJYAC.js";
189
+ withTimeout,
190
+ wrapText
191
+ } from "./chunk-LEYSL46B.js";
192
+
193
+ // src/lib/ports.ts
194
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
195
+ import { join } from "path";
196
+
197
+ // ../../packages/core/src/ports.ts
198
+ import { createServer } from "net";
199
+ var DEFAULT_API_PORT = 4e3;
200
+ var DEFAULT_DASHBOARD_PORT = 3001;
201
+ var LOOPBACK = "127.0.0.1";
202
+ var SCAN_SPAN = 20;
203
+ var MAX_PORT = 65535;
204
+ function isPortFree(port, host = LOOPBACK) {
205
+ return new Promise((resolve3) => {
206
+ const srv = createServer();
207
+ srv.once("error", () => resolve3(false));
208
+ srv.listen(port, host, () => srv.close(() => resolve3(true)));
209
+ });
210
+ }
211
+ async function waitPortFree(port, opts = {}) {
212
+ const timeoutMs = opts.timeoutMs ?? 6e3;
213
+ const intervalMs = opts.intervalMs ?? 250;
214
+ const host = opts.host ?? LOOPBACK;
215
+ const deadline = Date.now() + timeoutMs;
216
+ for (; ; ) {
217
+ if (await isPortFree(port, host)) return true;
218
+ if (Date.now() >= deadline) return false;
219
+ await new Promise((r2) => setTimeout(r2, intervalMs));
220
+ }
221
+ }
222
+ function getFreePort(host = LOOPBACK) {
223
+ return new Promise((resolve3, reject) => {
224
+ const srv = createServer();
225
+ srv.once("error", reject);
226
+ srv.listen(0, host, () => {
227
+ const addr = srv.address();
228
+ const port = addr && typeof addr === "object" ? addr.port : 0;
229
+ srv.close(() => port ? resolve3(port) : reject(new Error("no free port")));
230
+ });
231
+ });
232
+ }
233
+ async function findPortNear(from, opts = {}) {
234
+ const host = opts.host ?? LOOPBACK;
235
+ const span = opts.span ?? SCAN_SPAN;
236
+ const skip = opts.skip ?? (() => false);
237
+ for (let port2 = from; port2 <= Math.min(from + span, MAX_PORT); port2++) {
238
+ if (!skip(port2) && await isPortFree(port2, host)) return port2;
239
+ }
240
+ let port = await getFreePort(host);
241
+ for (let tries = 0; tries < 10 && skip(port); tries++) port = await getFreePort(host);
242
+ return port;
243
+ }
244
+ async function resolvePortPair(prefs = {}) {
245
+ const host = prefs.bindAddr?.trim() || LOOPBACK;
246
+ const ours = new Set(prefs.reclaimable ?? []);
247
+ const prefFor = (kind) => prefs[kind] ?? prefs.previous?.[kind] ?? prefs.stored?.[kind] ?? prefs.defaults?.[kind];
248
+ const remembered = (kind, pref) => prefs[kind] === void 0 && (prefs.previous?.[kind] === pref || prefs.stored?.[kind] === pref);
249
+ const availableForPref = async (port) => ours.has(port) || await isPortFree(port, host);
250
+ const apiPref = prefFor("api");
251
+ const dashPref = prefFor("dashboard");
252
+ let api;
253
+ if (apiPref === void 0) api = await getFreePort(host);
254
+ else if (await availableForPref(apiPref)) api = apiPref;
255
+ else if (remembered("api", apiPref) && await waitPortFree(apiPref, { host })) api = apiPref;
256
+ else {
257
+ api = await findPortNear(apiPref + 1, {
258
+ host,
259
+ skip: (p2) => ours.has(p2) || p2 === dashPref
260
+ });
261
+ }
262
+ const skipForDash = (p2) => ours.has(p2) || p2 === api;
263
+ let dashboard;
264
+ if (dashPref === void 0) {
265
+ dashboard = await getFreePort(host);
266
+ for (let tries = 0; tries < 10 && dashboard === api; tries++) {
267
+ dashboard = await getFreePort(host);
268
+ }
269
+ } else if (dashPref !== api && await availableForPref(dashPref)) {
270
+ dashboard = dashPref;
271
+ } else if (dashPref !== api && remembered("dashboard", dashPref) && await waitPortFree(dashPref, { host })) {
272
+ dashboard = dashPref;
273
+ } else {
274
+ dashboard = await findPortNear(dashPref + 1, { host, skip: skipForDash });
275
+ }
276
+ return {
277
+ api,
278
+ dashboard,
279
+ switched: {
280
+ api: apiPref !== void 0 && api !== apiPref,
281
+ dashboard: dashPref !== void 0 && dashboard !== dashPref
282
+ },
283
+ preferred: { api: apiPref ?? api, dashboard: dashPref ?? dashboard }
284
+ };
285
+ }
170
286
 
171
- // src/lib/edge-import.ts
172
- import chalk from "chalk";
173
- import ora from "ora";
287
+ // src/lib/ports.ts
288
+ var PORTS_FILE = join(OS_DIR, "ports.json");
289
+ var INSTANCE_FILE = join(OS_DIR, "instance.json");
290
+ function saveInstanceUrl(publicUrl) {
291
+ try {
292
+ if (!existsSync(OS_DIR)) mkdirSync(OS_DIR, { recursive: true, mode: 448 });
293
+ writeFileSync(INSTANCE_FILE, JSON.stringify({ publicUrl: publicUrl ?? null }));
294
+ } catch {
295
+ }
296
+ }
297
+ function readInstanceUrl() {
298
+ try {
299
+ return JSON.parse(readFileSync(INSTANCE_FILE, "utf8")).publicUrl ?? null;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+ function readStoredPorts() {
305
+ try {
306
+ return JSON.parse(readFileSync(PORTS_FILE, "utf-8"));
307
+ } catch {
308
+ return {};
309
+ }
310
+ }
311
+ function storedApiPort() {
312
+ return readStoredPorts().api ?? DEFAULT_API_PORT;
313
+ }
314
+ function storedDashboardPort() {
315
+ return readStoredPorts().dashboard ?? DEFAULT_DASHBOARD_PORT;
316
+ }
317
+ function saveStoredPorts(api, dashboard) {
318
+ try {
319
+ if (!existsSync(OS_DIR)) mkdirSync(OS_DIR, { recursive: true, mode: 448 });
320
+ writeFileSync(PORTS_FILE, JSON.stringify({ api, dashboard }));
321
+ } catch {
322
+ }
323
+ }
324
+ function portMoveNotice(resolved, trustedOriginUrls = []) {
325
+ if (!resolved.switched.api && !resolved.switched.dashboard) return [];
326
+ const lines = [
327
+ `A preferred port was busy \u2014 using API ${resolved.api}, dashboard ${resolved.dashboard}.`
328
+ ];
329
+ const stale = [...new Set(stalePortOrigins(resolved.preferred.dashboard, trustedOriginUrls))];
330
+ if (stale.length) {
331
+ lines.push(
332
+ `Heads up: ${stale.join(", ")} still names port ${resolved.preferred.dashboard}, so logins`,
333
+ `from that origin will be rejected. Point it at :${resolved.dashboard} (or free port`,
334
+ `${resolved.preferred.dashboard} and re-run to move back).`
335
+ );
336
+ }
337
+ return lines;
338
+ }
339
+ function stalePortOrigins(movedFrom, urls) {
340
+ const stale = [];
341
+ for (const raw of urls) {
342
+ for (const candidate of (raw ?? "").split(",")) {
343
+ const value = candidate.trim();
344
+ if (!value) continue;
345
+ try {
346
+ if (new URL(value).port === String(movedFrom)) stale.push(value);
347
+ } catch {
348
+ }
349
+ }
350
+ }
351
+ return stale;
352
+ }
353
+ async function resolvePorts(prefs, opts = {}) {
354
+ const resolved = await resolvePortPair({
355
+ ...prefs,
356
+ stored: readStoredPorts(),
357
+ defaults: { api: DEFAULT_API_PORT, dashboard: DEFAULT_DASHBOARD_PORT }
358
+ });
359
+ if (opts.persist !== false) saveStoredPorts(resolved.api, resolved.dashboard);
360
+ return resolved;
361
+ }
174
362
 
175
363
  // ../../packages/adapters/src/runtime/image-transfer.ts
176
364
  import { Transform } from "stream";
@@ -204,6 +392,11 @@ var MAIL_DB_PGDATA = `${MAIL_DB_CONTAINER_DATA_DIR}/pgdata`;
204
392
  var MAIL_HOST_PATHS = {
205
393
  saslPasswd: `${MAIL_HOST_STATE_DIR}/config/postfix/sasl_passwd`,
206
394
  senderRelayhost: `${MAIL_HOST_STATE_DIR}/config/postfix/sender_relayhost`,
395
+ // Per-nexthop TLS policy. Only split ("selected"-scope) relaying writes it: the
396
+ // relay hop must be `encrypt` so SASL credentials never cross a plaintext
397
+ // connection, while direct-to-MX delivery for the un-relayed domains stays
398
+ // opportunistic. A GLOBAL smtp_tls_security_level can't express both.
399
+ relayTlsPolicy: `${MAIL_HOST_STATE_DIR}/config/postfix/openship_tls_policy`,
207
400
  amavisUserConf: `${MAIL_HOST_STATE_DIR}/config/amavis/50-user`
208
401
  };
209
402
 
@@ -212,6 +405,63 @@ var MAIL_DB_IMAGE = process.env.OPENSHIP_MAIL_DB_IMAGE?.trim() || "postgres:16-a
212
405
  var ENGINE_ENV_FILE = `${MAIL_HOST_STATE_DIR}/engine.env`;
213
406
  var DB_ENV_FILE = `${MAIL_HOST_STATE_DIR}/db.env`;
214
407
 
408
+ // ../../packages/adapters/src/system/environment-local.ts
409
+ import { spawnSync } from "child_process";
410
+ var cached = null;
411
+ function unmeasurable(reason) {
412
+ return {
413
+ ...parseEnvironmentProbe({ text: "", failed: true, unanswered: true }),
414
+ unsupportedReason: reason
415
+ };
416
+ }
417
+ function probeLocalHost() {
418
+ if (process.platform === "win32") {
419
+ return unmeasurable(
420
+ "Openship cannot provision this machine: it runs Windows, and Openship's host provisioning is POSIX-only. Deploying to a remote Linux server from here works."
421
+ );
422
+ }
423
+ const result = spawnSync("sh", ["-c", ENVIRONMENT_PROBE_SCRIPT], {
424
+ encoding: "utf8",
425
+ timeout: ENVIRONMENT_PROBE_TIMEOUT_MS,
426
+ // `sh -c` and not the login shell: `wrapLocalBuildCommand`'s `-lc` exists so user
427
+ // builds see a normal profile, but a host probe wants the plain POSIX behaviour the
428
+ // remote path gets, without a dotfile's `echo` in the middle of the output.
429
+ windowsHide: true
430
+ });
431
+ if (result.error) {
432
+ return unmeasurable(
433
+ `Openship could not run a shell on this machine to detect it: ${safeErrorMessage(result.error)}`
434
+ );
435
+ }
436
+ if (result.signal || typeof result.status !== "number") {
437
+ return unmeasurable(
438
+ `Openship could not measure this machine: the detection probe was killed${result.signal ? ` by ${result.signal}` : ""} before it finished.`
439
+ );
440
+ }
441
+ const stdout = result.stdout ?? "";
442
+ return parseEnvironmentProbe({
443
+ // The script always exits 0, so a non-zero status means something ran instead of it
444
+ // — the same signal the SSH path uses, and stderr is what said so.
445
+ text: stdout.trim() ? stdout : result.stderr ?? "",
446
+ failed: result.status !== 0,
447
+ // Every way of not answering is refused above, so the shell exited on its own and
448
+ // whatever it said is this machine's.
449
+ unanswered: false
450
+ });
451
+ }
452
+ function resolveLocalEnvironmentSync() {
453
+ if (cached && profileIsFresh(cached.measuredAt)) return cached.profile;
454
+ const profile = probeLocalHost();
455
+ cached = { measuredAt: Date.now(), profile };
456
+ cacheEnvironment(createExecutor(), profile);
457
+ return profile;
458
+ }
459
+
460
+ // ../../packages/adapters/src/system/modules/types.ts
461
+ var TARGETABLE_FAMILIES = new Set(
462
+ Object.values(FAMILY_BY_DISTRO)
463
+ );
464
+
215
465
  // ../../packages/adapters/src/system/modules/verify.ts
216
466
  import { createHash, createPublicKey, verify as cryptoVerify } from "crypto";
217
467
 
@@ -219,10 +469,6 @@ import { createHash, createPublicKey, verify as cryptoVerify } from "crypto";
219
469
  var CATALOG_BASE_URL = process.env.OPENSHIP_MODULE_CATALOG_BASE_URL?.trim() || "https://raw.githubusercontent.com/openship/native-modules";
220
470
  var CATALOG_REF = process.env.OPENSHIP_MODULE_CATALOG_REF?.trim() || "main";
221
471
 
222
- // ../../packages/adapters/src/system/reachability.ts
223
- import { connect } from "net";
224
- import { request } from "http";
225
-
226
472
  // ../../packages/adapters/src/backup/registry.ts
227
473
  var executorFactories = /* @__PURE__ */ new Map();
228
474
  function registerExecutor(runtimeName, factory) {
@@ -747,12 +993,14 @@ var DockerBackupExecutor = class {
747
993
  try {
748
994
  const data = await this.dockerode.getContainer(service.containerId).inspect();
749
995
  const mounts = data.Mounts ?? [];
750
- return mounts.filter((m2) => m2.Type === "volume" || m2.Type === "bind").map((m2, i2) => ({
751
- id: m2.Name ?? m2.Source ?? `mount-${i2}`,
752
- target: m2.Destination ?? "",
753
- source: m2.Name ?? m2.Source ?? "",
754
- type: m2.Type ?? "volume"
755
- }));
996
+ return mounts.filter((m2) => m2.Type === "volume" || m2.Type === "bind").map(
997
+ (m2, i2) => ({
998
+ id: m2.Name ?? m2.Source ?? `mount-${i2}`,
999
+ target: m2.Destination ?? "",
1000
+ source: m2.Name ?? m2.Source ?? "",
1001
+ type: m2.Type ?? "volume"
1002
+ })
1003
+ );
756
1004
  } catch {
757
1005
  }
758
1006
  }
@@ -784,7 +1032,9 @@ var DockerBackupExecutor = class {
784
1032
  Env: opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${v2}`) : void 0
785
1033
  });
786
1034
  const stream = await exec.start({ stdin: false });
787
- return this.attachDemuxed(this.dockerode, exec.id, stream, opts?.timeoutMs);
1035
+ return this.attachDemuxed(this.dockerode, exec.id, stream, opts, {
1036
+ label: `exec in service ${service.name}`
1037
+ });
788
1038
  }
789
1039
  async streamPath(service, sourceId, opts) {
790
1040
  const sources = await this.listSources(service);
@@ -796,10 +1046,7 @@ var DockerBackupExecutor = class {
796
1046
  throw new Error(`Backup source "${sourceId}" is tmpfs \u2014 not backupable`);
797
1047
  }
798
1048
  const compression = opts?.compression ?? "zstd";
799
- const excludeArgs = (opts?.exclude ?? []).flatMap((p2) => [
800
- "--exclude",
801
- shellEscape4(p2)
802
- ]);
1049
+ const excludeArgs = (opts?.exclude ?? []).flatMap((p2) => ["--exclude", shellEscape4(p2)]);
803
1050
  const tarFlags = compressionFlag(compression);
804
1051
  const tarCmd = compression === "zstd" ? `tar -c${tarFlags} -C /mnt ${excludeArgs.join(" ")} . | zstd -c -3` : `tar -c${tarFlags} -C /mnt ${excludeArgs.join(" ")} .`;
805
1052
  const helperImage = compression === "zstd" ? "alpine:3" : HELPER_IMAGE;
@@ -824,7 +1071,11 @@ var DockerBackupExecutor = class {
824
1071
  return this.handOffHelper(
825
1072
  {
826
1073
  Image: helperImage,
827
- Cmd: ["sh", "-c", compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${tarCmd}` : tarCmd],
1074
+ Cmd: [
1075
+ "sh",
1076
+ "-c",
1077
+ compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${tarCmd}` : tarCmd
1078
+ ],
828
1079
  HostConfig: hostConfig,
829
1080
  AttachStdout: true,
830
1081
  AttachStderr: true,
@@ -1170,7 +1421,7 @@ var DockerBackupExecutor = class {
1170
1421
  } catch {
1171
1422
  }
1172
1423
  }, opts.timeoutMs) : null;
1173
- return new Promise((resolve4, reject) => {
1424
+ return new Promise((resolve3, reject) => {
1174
1425
  body.on("error", (err) => {
1175
1426
  if (timer) clearTimeout(timer);
1176
1427
  try {
@@ -1182,7 +1433,7 @@ var DockerBackupExecutor = class {
1182
1433
  stream.on("end", async () => {
1183
1434
  if (timer) clearTimeout(timer);
1184
1435
  try {
1185
- resolve4({
1436
+ resolve3({
1186
1437
  code: await resolveExecExitCode(exec, exec.id),
1187
1438
  stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
1188
1439
  });
@@ -1230,7 +1481,10 @@ var DockerBackupExecutor = class {
1230
1481
  }
1231
1482
  /** dockerode `exec.start` returns a multiplexed stream — stdout +
1232
1483
  * stderr interleaved with frame headers. demux into clean streams. */
1233
- attachDemuxed(docker, execId, stream, timeoutMs) {
1484
+ attachDemuxed(docker, execId, stream, opts, meta) {
1485
+ const label = meta?.label ?? "exec";
1486
+ const idleMs = opts?.idleTimeoutMs ?? DEFAULT_HELPER_IDLE_MS;
1487
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS;
1234
1488
  const stdout = new PassThrough();
1235
1489
  const stderrChunks = [];
1236
1490
  const stderrSink = new PassThrough();
@@ -1238,34 +1492,54 @@ var DockerBackupExecutor = class {
1238
1492
  if (stderrChunks.length < 16) stderrChunks.push(chunk);
1239
1493
  });
1240
1494
  docker.modem.demuxStream(stream, stdout, stderrSink);
1241
- const awaitExit = new Promise((resolve4, reject) => {
1242
- const timer = timeoutMs ? setTimeout(() => {
1243
- stdout.destroy(new Error(`exec timed out after ${timeoutMs}ms`));
1244
- reject(new Error(`exec timed out after ${timeoutMs}ms`));
1245
- }, timeoutMs) : null;
1246
- stream.on("end", async () => {
1247
- if (timer) clearTimeout(timer);
1248
- try {
1249
- resolve4({
1250
- code: await resolveExecExitCode(docker.getExec(execId), execId),
1251
- stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
1252
- });
1253
- } catch (err) {
1254
- reject(err);
1255
- }
1256
- });
1257
- stream.on("error", (err) => {
1258
- if (timer) clearTimeout(timer);
1259
- reject(err);
1260
- });
1261
- });
1262
- return { stdout, awaitExit };
1263
- }
1264
- demuxContainerStream(container, stream, opts) {
1265
- const stdout = new PassThrough();
1266
- const stderrChunks = [];
1267
- const stderrSink = new PassThrough();
1268
- stderrSink.on("data", (chunk) => {
1495
+ let sinksEnded = false;
1496
+ const endSinks = () => {
1497
+ if (sinksEnded) return;
1498
+ sinksEnded = true;
1499
+ stdout.end();
1500
+ stderrSink.end();
1501
+ };
1502
+ stream.on("end", endSinks);
1503
+ stream.on("close", endSinks);
1504
+ const watchdog = createIdleWatchdog(
1505
+ idleMs,
1506
+ `${label} produced no data for ${Math.round(idleMs / 1e3)}s and was abandoned.`
1507
+ );
1508
+ stream.on("data", () => watchdog.touch());
1509
+ const awaitExit = (async () => {
1510
+ const onEnd = new Promise((resolve3, reject) => {
1511
+ stream.on("end", async () => {
1512
+ try {
1513
+ resolve3({
1514
+ code: await resolveExecExitCode(docker.getExec(execId), execId),
1515
+ stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
1516
+ });
1517
+ } catch (err) {
1518
+ reject(err);
1519
+ }
1520
+ });
1521
+ stream.on("error", (err) => reject(err));
1522
+ });
1523
+ try {
1524
+ return await withTimeout(
1525
+ Promise.race([onEnd, watchdog.promise]),
1526
+ timeoutMs,
1527
+ `${label} exceeded its ${Math.round(timeoutMs / 1e3)}s ceiling and was abandoned.`
1528
+ );
1529
+ } catch (err) {
1530
+ stdout.destroy(err);
1531
+ throw err;
1532
+ } finally {
1533
+ watchdog.dispose();
1534
+ }
1535
+ })();
1536
+ return { stdout, awaitExit };
1537
+ }
1538
+ demuxContainerStream(container, stream, opts) {
1539
+ const stdout = new PassThrough();
1540
+ const stderrChunks = [];
1541
+ const stderrSink = new PassThrough();
1542
+ stderrSink.on("data", (chunk) => {
1269
1543
  if (stderrChunks.length < 16) stderrChunks.push(chunk);
1270
1544
  });
1271
1545
  container.modem.demuxStream(stream, stdout, stderrSink);
@@ -1359,7 +1633,7 @@ var CloudBackupExecutor = class {
1359
1633
  const stdout = new Readable2({ read() {
1360
1634
  } });
1361
1635
  let stderrBuf = "";
1362
- const awaitExit = new Promise((resolve4, reject) => {
1636
+ const awaitExit = new Promise((resolve3, reject) => {
1363
1637
  const timer = opts?.timeoutMs ? setTimeout(() => {
1364
1638
  stdout.destroy(new Error(`exec timed out after ${opts.timeoutMs}ms`));
1365
1639
  reject(new Error(`exec timed out after ${opts.timeoutMs}ms`));
@@ -1392,7 +1666,7 @@ var CloudBackupExecutor = class {
1392
1666
  }
1393
1667
  stdout.push(null);
1394
1668
  if (timer) clearTimeout(timer);
1395
- resolve4({ code: exitCode, stderr: stderrBuf });
1669
+ resolve3({ code: exitCode, stderr: stderrBuf });
1396
1670
  } catch (err) {
1397
1671
  if (timer) clearTimeout(timer);
1398
1672
  stdout.destroy(err);
@@ -1501,7 +1775,7 @@ import { pipeline } from "stream/promises";
1501
1775
  import { createWriteStream, promises as fs } from "fs";
1502
1776
  import { randomBytes as randomBytes2 } from "crypto";
1503
1777
  import { tmpdir } from "os";
1504
- import { join } from "path";
1778
+ import { join as join2 } from "path";
1505
1779
  var BareBackupExecutor = class {
1506
1780
  constructor(runtime) {
1507
1781
  this.runtime = runtime;
@@ -1575,8 +1849,8 @@ var BareBackupExecutor = class {
1575
1849
  async pipeIntoCommand(service, cmd, body, opts) {
1576
1850
  const exec = this.executor();
1577
1851
  const id = randomBytes2(6).toString("hex");
1578
- const localDir = join(tmpdir(), `openship-bare-restore-${id}`);
1579
- const localFile = join(localDir, "artifact.bin");
1852
+ const localDir = join2(tmpdir(), `openship-bare-restore-${id}`);
1853
+ const localFile = join2(localDir, "artifact.bin");
1580
1854
  const remoteDir = `/tmp/openship-bare-restore-${id}`;
1581
1855
  const remoteFile = `${remoteDir}/artifact.bin`;
1582
1856
  await fs.mkdir(localDir, { recursive: true });
@@ -1625,7 +1899,7 @@ registerExecutor("bare", (runtime) => {
1625
1899
  // ../../packages/adapters/src/backup/destinations/local.ts
1626
1900
  import { createHash as createHash4, randomBytes as randomBytes3 } from "crypto";
1627
1901
  import { createReadStream, createWriteStream as createWriteStream2, promises as fs2 } from "fs";
1628
- import { dirname, join as join2, normalize, posix, resolve } from "path";
1902
+ import { dirname, join as join3, normalize, posix, resolve } from "path";
1629
1903
  import { pipeline as pipeline2 } from "stream";
1630
1904
  import { promisify } from "util";
1631
1905
  var pipelineP = promisify(pipeline2);
@@ -1648,13 +1922,13 @@ var LocalDestinationImpl = class {
1648
1922
  if (normalized.startsWith("..") || normalized.includes(`${normalize("../")}`)) {
1649
1923
  throw new Error(`Invalid key (traversal): ${key}`);
1650
1924
  }
1651
- return join2(this.root, normalized);
1925
+ return join3(this.root, normalized);
1652
1926
  }
1653
1927
  async preflight() {
1654
1928
  try {
1655
1929
  await fs2.mkdir(this.root, { recursive: true });
1656
1930
  const probeKey = `.openship-probe-${randomBytes3(6).toString("hex")}`;
1657
- const probePath = join2(this.root, probeKey);
1931
+ const probePath = join3(this.root, probeKey);
1658
1932
  await fs2.writeFile(probePath, "ok");
1659
1933
  await fs2.readFile(probePath);
1660
1934
  await fs2.unlink(probePath);
@@ -1728,7 +2002,7 @@ var LocalDestinationImpl = class {
1728
2002
  for (const dirent of dirents) {
1729
2003
  if (entries.length >= limit) return;
1730
2004
  const childRel = posix.join(relBase, dirent.name);
1731
- const childAbs = join2(dir, dirent.name);
2005
+ const childAbs = join3(dir, dirent.name);
1732
2006
  if (dirent.isDirectory()) {
1733
2007
  await walk(childAbs, childRel);
1734
2008
  } else if (dirent.isFile()) {
@@ -2370,8 +2644,8 @@ var flexibleChecksumsMiddleware = (config, middlewareConfig) => (next, context)
2370
2644
  if (hasHeaderWithPrefix("x-amz-checksum-", args.request.headers)) {
2371
2645
  return next(args);
2372
2646
  }
2373
- const { request: request2, input } = args;
2374
- const { body: requestBody, headers } = request2;
2647
+ const { request, input } = args;
2648
+ const { body: requestBody, headers } = request;
2375
2649
  const { base64Encoder, streamHasher } = config;
2376
2650
  const { requestChecksumRequired, requestAlgorithmMember } = middlewareConfig;
2377
2651
  const requestChecksumCalculation = await config.requestChecksumCalculation();
@@ -2442,7 +2716,7 @@ var flexibleChecksumsMiddleware = (config, middlewareConfig) => (next, context)
2442
2716
  const result = await next({
2443
2717
  ...args,
2444
2718
  request: {
2445
- ...request2,
2719
+ ...request,
2446
2720
  headers: updatedHeaders,
2447
2721
  body: updatedBody
2448
2722
  }
@@ -2638,9 +2912,9 @@ var CONTENT_LENGTH_HEADER = "content-length";
2638
2912
  var DECODED_CONTENT_LENGTH_HEADER = "x-amz-decoded-content-length";
2639
2913
  function checkContentLengthHeader() {
2640
2914
  return (next, context) => async (args) => {
2641
- const { request: request2 } = args;
2642
- if (HttpRequest.isInstance(request2)) {
2643
- if (!(CONTENT_LENGTH_HEADER in request2.headers) && !(DECODED_CONTENT_LENGTH_HEADER in request2.headers)) {
2915
+ const { request } = args;
2916
+ if (HttpRequest.isInstance(request)) {
2917
+ if (!(CONTENT_LENGTH_HEADER in request.headers) && !(DECODED_CONTENT_LENGTH_HEADER in request.headers)) {
2644
2918
  const message = `Are you using a Stream of unknown length as the Body of a PutObject request? Consider using Upload instead from @aws-sdk/lib-storage.`;
2645
2919
  if (typeof context?.logger?.warn === "function" && !(context.logger instanceof NoOpLogger)) {
2646
2920
  context.logger.warn(message);
@@ -2956,8 +3230,8 @@ var getS3ExpressPlugin = (options) => ({
2956
3230
  });
2957
3231
 
2958
3232
  // ../../node_modules/@aws-sdk/middleware-sdk-s3/dist-es/submodules/s3/middleware-s3-express/functions/signS3Express.js
2959
- var signS3Express = async (s3ExpressIdentity, signingOptions, request2, sigV4MultiRegionSigner) => {
2960
- const signedRequest = await sigV4MultiRegionSigner.signWithCredentials(request2, s3ExpressIdentity, {});
3233
+ var signS3Express = async (s3ExpressIdentity, signingOptions, request, sigV4MultiRegionSigner) => {
3234
+ const signedRequest = await sigV4MultiRegionSigner.signWithCredentials(request, s3ExpressIdentity, {});
2961
3235
  if (signedRequest.headers["X-Amz-Security-Token"] || signedRequest.headers["x-amz-security-token"]) {
2962
3236
  throw new Error("X-Amz-Security-Token must not be set for s3-express requests.");
2963
3237
  }
@@ -2980,15 +3254,15 @@ var s3ExpressHttpSigningMiddleware = (config) => (next, context) => async (args)
2980
3254
  throw new Error(`No HttpAuthScheme was selected: unable to sign request`);
2981
3255
  }
2982
3256
  const { httpAuthOption: { signingProperties = {} }, identity, signer } = scheme;
2983
- let request2;
3257
+ let request;
2984
3258
  if (context.s3ExpressIdentity) {
2985
- request2 = await signS3Express(context.s3ExpressIdentity, signingProperties, args.request, await config.signer());
3259
+ request = await signS3Express(context.s3ExpressIdentity, signingProperties, args.request, await config.signer());
2986
3260
  } else {
2987
- request2 = await signer.sign(args.request, identity, signingProperties);
3261
+ request = await signer.sign(args.request, identity, signingProperties);
2988
3262
  }
2989
3263
  const output = await next({
2990
3264
  ...args,
2991
- request: request2
3265
+ request
2992
3266
  }).catch((signer.errorHandler || defaultErrorHandler)(signingProperties));
2993
3267
  (signer.successHandler || defaultSuccessHandler)(output.response, signingProperties);
2994
3268
  return output;
@@ -3060,9 +3334,9 @@ var getThrow200ExceptionsPlugin = (config) => ({
3060
3334
  var validate = (str) => typeof str === "string" && str.indexOf("arn:") === 0 && str.split(":").length >= 6;
3061
3335
 
3062
3336
  // ../../node_modules/@aws-sdk/core/dist-es/submodules/util/util-format-url/format-url.js
3063
- function formatUrl(request2) {
3064
- const { port, query } = request2;
3065
- let { protocol, path, hostname } = request2;
3337
+ function formatUrl(request) {
3338
+ const { port, query } = request;
3339
+ let { protocol, path, hostname } = request;
3066
3340
  if (protocol && protocol.slice(-1) !== ":") {
3067
3341
  protocol += ":";
3068
3342
  }
@@ -3077,14 +3351,14 @@ function formatUrl(request2) {
3077
3351
  queryString = `?${queryString}`;
3078
3352
  }
3079
3353
  let auth = "";
3080
- if (request2.username != null || request2.password != null) {
3081
- const username = request2.username ?? "";
3082
- const password = request2.password ?? "";
3354
+ if (request.username != null || request.password != null) {
3355
+ const username = request.username ?? "";
3356
+ const password = request.password ?? "";
3083
3357
  auth = `${username}:${password}@`;
3084
3358
  }
3085
3359
  let fragment = "";
3086
- if (request2.fragment) {
3087
- fragment = `#${request2.fragment}`;
3360
+ if (request.fragment) {
3361
+ fragment = `#${request.fragment}`;
3088
3362
  }
3089
3363
  return `${protocol}//${auth}${hostname}${path}${queryString}${fragment}`;
3090
3364
  }
@@ -3477,18 +3751,18 @@ var AwsRestXmlProtocol = class extends HttpBindingProtocol {
3477
3751
  return "aws.protocols#restXml";
3478
3752
  }
3479
3753
  async serializeRequest(operationSchema, input, context) {
3480
- const request2 = await super.serializeRequest(operationSchema, input, context);
3754
+ const request = await super.serializeRequest(operationSchema, input, context);
3481
3755
  const inputSchema = NormalizedSchema.of(operationSchema.input);
3482
- if (!request2.headers["content-type"]) {
3756
+ if (!request.headers["content-type"]) {
3483
3757
  const contentType = this.mixin.resolveRestContentType(this.getDefaultContentType(), inputSchema);
3484
3758
  if (contentType) {
3485
- request2.headers["content-type"] = contentType;
3759
+ request.headers["content-type"] = contentType;
3486
3760
  }
3487
3761
  }
3488
- if (typeof request2.body === "string" && request2.headers["content-type"] === this.getDefaultContentType() && !request2.body.startsWith("<?xml ") && !this.hasUnstructuredPayloadBinding(inputSchema)) {
3489
- request2.body = '<?xml version="1.0" encoding="UTF-8"?>' + request2.body;
3762
+ if (typeof request.body === "string" && request.headers["content-type"] === this.getDefaultContentType() && !request.body.startsWith("<?xml ") && !this.hasUnstructuredPayloadBinding(inputSchema)) {
3763
+ request.body = '<?xml version="1.0" encoding="UTF-8"?>' + request.body;
3490
3764
  }
3491
- return request2;
3765
+ return request;
3492
3766
  }
3493
3767
  async deserializeResponse(operationSchema, context, response) {
3494
3768
  return super.deserializeResponse(operationSchema, context, response);
@@ -3541,7 +3815,7 @@ var AwsRestXmlProtocol = class extends HttpBindingProtocol {
3541
3815
  // ../../node_modules/@aws-sdk/middleware-sdk-s3/dist-es/submodules/s3/protocol/S3RestXmlProtocol.js
3542
3816
  var S3RestXmlProtocol = class extends AwsRestXmlProtocol {
3543
3817
  async serializeRequest(operationSchema, input, context) {
3544
- const request2 = await super.serializeRequest(operationSchema, input, context);
3818
+ const request = await super.serializeRequest(operationSchema, input, context);
3545
3819
  const ns = NormalizedSchema.of(operationSchema.input);
3546
3820
  const staticStructureSchema = ns.getSchema();
3547
3821
  let bucketMemberIndex = 0;
@@ -3559,7 +3833,7 @@ var S3RestXmlProtocol = class extends AwsRestXmlProtocol {
3559
3833
  }
3560
3834
  }
3561
3835
  }
3562
- return request2;
3836
+ return request;
3563
3837
  }
3564
3838
  };
3565
3839
 
@@ -3575,12 +3849,12 @@ var NODE_USE_ARN_REGION_CONFIG_OPTIONS = {
3575
3849
  // ../../node_modules/@aws-sdk/middleware-sdk-s3/dist-es/submodules/s3/middleware-expect-continue/middleware-expect-continue.js
3576
3850
  function addExpectContinueMiddleware(options) {
3577
3851
  return (next) => async (args) => {
3578
- const { request: request2 } = args;
3579
- if (options.expectContinueHeader !== false && HttpRequest.isInstance(request2) && request2.body && options.runtime === "node" && options.requestHandler?.constructor?.name !== "FetchHttpHandler") {
3852
+ const { request } = args;
3853
+ if (options.expectContinueHeader !== false && HttpRequest.isInstance(request) && request.body && options.runtime === "node" && options.requestHandler?.constructor?.name !== "FetchHttpHandler") {
3580
3854
  let sendHeader = true;
3581
3855
  if (typeof options.expectContinueHeader === "number") {
3582
3856
  try {
3583
- const bodyLength = Number(request2.headers?.["content-length"]) ?? options.bodyLengthChecker?.(request2.body) ?? Infinity;
3857
+ const bodyLength = Number(request.headers?.["content-length"]) ?? options.bodyLengthChecker?.(request.body) ?? Infinity;
3584
3858
  sendHeader = bodyLength >= options.expectContinueHeader;
3585
3859
  } catch (e2) {
3586
3860
  }
@@ -3588,12 +3862,12 @@ function addExpectContinueMiddleware(options) {
3588
3862
  sendHeader = !!options.expectContinueHeader;
3589
3863
  }
3590
3864
  if (sendHeader) {
3591
- request2.headers.Expect = "100-continue";
3865
+ request.headers.Expect = "100-continue";
3592
3866
  }
3593
3867
  }
3594
3868
  return next({
3595
3869
  ...args,
3596
- request: request2
3870
+ request
3597
3871
  });
3598
3872
  };
3599
3873
  }
@@ -11398,7 +11672,7 @@ var defaultProvider = (init = {}) => memoizeChain([
11398
11672
  },
11399
11673
  async (awsIdentityProperties) => {
11400
11674
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromIni");
11401
- const { fromIni } = await import("./dist-es-JGBPABJI.js");
11675
+ const { fromIni } = await import("./dist-es-ZP5QDSJ3.js");
11402
11676
  return fromIni(init)(awsIdentityProperties);
11403
11677
  },
11404
11678
  async (awsIdentityProperties) => {
@@ -11450,14 +11724,14 @@ var readableStreamHasher = (hashCtor, readableStream) => {
11450
11724
  const hash = new hashCtor();
11451
11725
  const hashCalculator = new HashCalculator(hash);
11452
11726
  readableStream.pipe(hashCalculator);
11453
- return new Promise((resolve4, reject) => {
11727
+ return new Promise((resolve3, reject) => {
11454
11728
  readableStream.on("error", (err) => {
11455
11729
  hashCalculator.end();
11456
11730
  reject(err);
11457
11731
  });
11458
11732
  hashCalculator.on("error", reject);
11459
11733
  hashCalculator.on("finish", () => {
11460
- hash.digest().then(resolve4).catch(reject);
11734
+ hash.digest().then(resolve3).catch(reject);
11461
11735
  });
11462
11736
  });
11463
11737
  };
@@ -11890,8 +12164,8 @@ var BYTE_LENGTH_SOURCE;
11890
12164
  BYTE_LENGTH_SOURCE2["START_END_DIFF"] = "the numeric difference between Body.start and Body.end";
11891
12165
  BYTE_LENGTH_SOURCE2["LSTAT"] = "the size of the file given by Body.path on disk as reported by lstatSync";
11892
12166
  })(BYTE_LENGTH_SOURCE || (BYTE_LENGTH_SOURCE = {}));
11893
- var byteLengthSource = (input, override2) => {
11894
- if (override2 != null) {
12167
+ var byteLengthSource = (input, override) => {
12168
+ if (override != null) {
11895
12169
  return BYTE_LENGTH_SOURCE.CONTENT_LENGTH;
11896
12170
  }
11897
12171
  if (input == null) {
@@ -12187,8 +12461,8 @@ var Upload = class _Upload extends EventEmitter {
12187
12461
  const requestHandler = this.client.config.requestHandler;
12188
12462
  const eventEmitter = requestHandler instanceof EventEmitter ? requestHandler : null;
12189
12463
  let lastSeenBytes = 0;
12190
- const uploadEventListener = (event, request2) => {
12191
- const requestPartSize = Number(request2.query["partNumber"]) || -1;
12464
+ const uploadEventListener = (event, request) => {
12465
+ const requestPartSize = Number(request.query["partNumber"]) || -1;
12192
12466
  if (requestPartSize !== dataPart.partNumber) {
12193
12467
  return;
12194
12468
  }
@@ -12313,7 +12587,7 @@ to input.params.ContentLength in bytes.
12313
12587
  }
12314
12588
  }
12315
12589
  async __abortTimeout(abortSignal) {
12316
- return new Promise((resolve4, reject) => {
12590
+ return new Promise((resolve3, reject) => {
12317
12591
  abortSignal.onabort = () => {
12318
12592
  const abortError = new Error("Upload aborted.");
12319
12593
  abortError.name = "AbortError";
@@ -12426,13 +12700,13 @@ var getSignedUrl = async (client, command, options = {}) => {
12426
12700
  s3Presigner = new S3RequestPresigner(client.config);
12427
12701
  }
12428
12702
  const presignInterceptMiddleware = (next, context) => async (args) => {
12429
- const { request: request2 } = args;
12430
- if (!HttpRequest.isInstance(request2)) {
12703
+ const { request } = args;
12704
+ if (!HttpRequest.isInstance(request)) {
12431
12705
  throw new Error("Request to be presigned is not an valid HTTP request.");
12432
12706
  }
12433
- delete request2.headers["amz-sdk-invocation-id"];
12434
- delete request2.headers["amz-sdk-request"];
12435
- delete request2.headers["x-amz-user-agent"];
12707
+ delete request.headers["amz-sdk-invocation-id"];
12708
+ delete request.headers["amz-sdk-request"];
12709
+ delete request.headers["x-amz-user-agent"];
12436
12710
  let presigned2;
12437
12711
  const presignerOptions = {
12438
12712
  ...options,
@@ -12440,9 +12714,9 @@ var getSignedUrl = async (client, command, options = {}) => {
12440
12714
  signingService: options.signingService ?? context["signing_service"]
12441
12715
  };
12442
12716
  if (context.s3ExpressIdentity) {
12443
- presigned2 = await s3Presigner.presignWithCredentials(request2, context.s3ExpressIdentity, presignerOptions);
12717
+ presigned2 = await s3Presigner.presignWithCredentials(request, context.s3ExpressIdentity, presignerOptions);
12444
12718
  } else {
12445
- presigned2 = await s3Presigner.presign(request2, presignerOptions);
12719
+ presigned2 = await s3Presigner.presign(request, presignerOptions);
12446
12720
  }
12447
12721
  return {
12448
12722
  response: {},
@@ -12680,6 +12954,8 @@ var CAPS3 = /* @__PURE__ */ new Set([
12680
12954
  "streamingPut",
12681
12955
  "streamingGet"
12682
12956
  ]);
12957
+ var UPLOAD_STALL_TIMEOUT_MS = 10 * 60 * 1e3;
12958
+ var UPLOAD_STALL_PROBE_MS = 30 * 1e3;
12683
12959
  var SftpDestinationImpl = class {
12684
12960
  kind;
12685
12961
  capabilities = CAPS3;
@@ -12727,7 +13003,7 @@ var SftpDestinationImpl = class {
12727
13003
  // ── Connection helper ────────────────────────────────────────────────
12728
13004
  async withSftp(fn) {
12729
13005
  const client = new Client2();
12730
- return new Promise((resolve4, reject) => {
13006
+ return new Promise((resolve3, reject) => {
12731
13007
  const cleanup = () => {
12732
13008
  try {
12733
13009
  client.end();
@@ -12744,7 +13020,7 @@ var SftpDestinationImpl = class {
12744
13020
  fn(sftp).then(
12745
13021
  (val) => {
12746
13022
  cleanup();
12747
- resolve4(val);
13023
+ resolve3(val);
12748
13024
  },
12749
13025
  (e2) => {
12750
13026
  cleanup();
@@ -12764,12 +13040,12 @@ var SftpDestinationImpl = class {
12764
13040
  let current = dir.startsWith("/") ? "" : ".";
12765
13041
  for (const part of parts) {
12766
13042
  current = current === "" ? `/${part}` : posix2.join(current, part);
12767
- await new Promise((resolve4, reject) => {
13043
+ await new Promise((resolve3, reject) => {
12768
13044
  sftp.mkdir(current, (err) => {
12769
- if (!err) return resolve4();
13045
+ if (!err) return resolve3();
12770
13046
  sftp.stat(current, (statErr, stats) => {
12771
13047
  if (statErr) return reject(err);
12772
- if (stats.isDirectory()) return resolve4();
13048
+ if (stats.isDirectory()) return resolve3();
12773
13049
  reject(new Error(`${current} exists but is not a directory`));
12774
13050
  });
12775
13051
  });
@@ -12783,14 +13059,14 @@ var SftpDestinationImpl = class {
12783
13059
  await this.withSftp(async (sftp) => {
12784
13060
  await this.ensureDir(sftp, this.rootPath);
12785
13061
  const probePath = posix2.join(this.rootPath, probeName);
12786
- await new Promise((resolve4, reject) => {
13062
+ await new Promise((resolve3, reject) => {
12787
13063
  const ws = sftp.createWriteStream(probePath);
12788
13064
  ws.on("error", reject);
12789
- ws.on("close", () => resolve4());
13065
+ ws.on("close", () => resolve3());
12790
13066
  ws.end("ok");
12791
13067
  });
12792
13068
  await new Promise(
12793
- (resolve4, reject) => sftp.unlink(probePath, (err) => err ? reject(err) : resolve4())
13069
+ (resolve3, reject) => sftp.unlink(probePath, (err) => err ? reject(err) : resolve3())
12794
13070
  );
12795
13071
  });
12796
13072
  return { ok: true };
@@ -12807,27 +13083,49 @@ var SftpDestinationImpl = class {
12807
13083
  let bytesWritten = 0;
12808
13084
  await this.withSftp(async (sftp) => {
12809
13085
  await this.ensureDir(sftp, posix2.dirname(target));
12810
- await new Promise((resolve4, reject) => {
13086
+ await new Promise((resolve3, reject) => {
12811
13087
  const ws = sftp.createWriteStream(tmp);
12812
- ws.on("error", reject);
12813
- ws.on("close", () => resolve4());
13088
+ let lastProgressAt = Date.now();
13089
+ let settled = false;
13090
+ let watchdog;
13091
+ const finish = (err) => {
13092
+ if (settled) return;
13093
+ settled = true;
13094
+ if (watchdog) clearInterval(watchdog);
13095
+ if (err) {
13096
+ ws.destroy();
13097
+ body.destroy();
13098
+ reject(err);
13099
+ } else {
13100
+ resolve3();
13101
+ }
13102
+ };
13103
+ watchdog = setInterval(() => {
13104
+ if (settled || Date.now() - lastProgressAt <= UPLOAD_STALL_TIMEOUT_MS) return;
13105
+ finish(
13106
+ new Error(
13107
+ `SFTP upload stalled: no write progress for ${UPLOAD_STALL_TIMEOUT_MS / 1e3}s (wrote ${bytesWritten} bytes so far). Retry; if it recurs, check both ends \u2014 whether the source is still producing bytes, and the destination's SFTP subsystem.`
13108
+ )
13109
+ );
13110
+ }, UPLOAD_STALL_PROBE_MS);
13111
+ watchdog.unref?.();
13112
+ ws.on("error", (err) => finish(err));
13113
+ ws.on("close", () => finish());
12814
13114
  body.on("data", (chunk) => {
12815
13115
  bytesWritten += chunk.byteLength;
13116
+ lastProgressAt = Date.now();
12816
13117
  });
12817
- body.on("error", (err) => {
12818
- ws.destroy();
12819
- reject(err);
12820
- });
13118
+ body.on("error", (err) => finish(err));
12821
13119
  body.pipe(ws);
12822
13120
  });
12823
- await new Promise((resolve4, reject) => {
13121
+ await new Promise((resolve3, reject) => {
12824
13122
  sftp.rename(tmp, target, (err) => {
12825
- if (!err) return resolve4();
13123
+ if (!err) return resolve3();
12826
13124
  sftp.unlink(target, () => {
12827
13125
  sftp.rename(
12828
13126
  tmp,
12829
13127
  target,
12830
- (err2) => err2 ? reject(err2) : resolve4()
13128
+ (err2) => err2 ? reject(err2) : resolve3()
12831
13129
  );
12832
13130
  });
12833
13131
  });
@@ -12874,14 +13172,14 @@ var SftpDestinationImpl = class {
12874
13172
  const target = this.fullPath(key);
12875
13173
  try {
12876
13174
  return await this.withSftp(
12877
- (sftp) => new Promise((resolve4, reject) => {
13175
+ (sftp) => new Promise((resolve3, reject) => {
12878
13176
  sftp.stat(target, (err, stats) => {
12879
13177
  if (err) {
12880
13178
  const code = err.code;
12881
- if (code === 2) return resolve4(null);
13179
+ if (code === 2) return resolve3(null);
12882
13180
  return reject(err);
12883
13181
  }
12884
- resolve4({
13182
+ resolve3({
12885
13183
  sizeBytes: stats.size,
12886
13184
  uploadedAt: new Date(stats.mtime * 1e3)
12887
13185
  });
@@ -12898,14 +13196,14 @@ var SftpDestinationImpl = class {
12898
13196
  const entries = [];
12899
13197
  await this.withSftp(async (sftp) => {
12900
13198
  const walk = async (dir, relBase) => {
12901
- const dirents = await new Promise((resolve4, reject) => {
13199
+ const dirents = await new Promise((resolve3, reject) => {
12902
13200
  sftp.readdir(dir, (err, list) => {
12903
13201
  if (err) {
12904
13202
  const code = err.code;
12905
- if (code === 2) return resolve4([]);
13203
+ if (code === 2) return resolve3([]);
12906
13204
  return reject(err);
12907
13205
  }
12908
- resolve4(list);
13206
+ resolve3(list);
12909
13207
  });
12910
13208
  });
12911
13209
  for (const dirent of dirents) {
@@ -12931,10 +13229,10 @@ var SftpDestinationImpl = class {
12931
13229
  const target = this.fullPath(key);
12932
13230
  await this.withSftp(
12933
13231
  (sftp) => new Promise(
12934
- (resolve4, reject) => sftp.unlink(target, (err) => {
12935
- if (!err) return resolve4();
13232
+ (resolve3, reject) => sftp.unlink(target, (err) => {
13233
+ if (!err) return resolve3();
12936
13234
  const code = err.code;
12937
- if (code === 2) return resolve4();
13235
+ if (code === 2) return resolve3();
12938
13236
  reject(err);
12939
13237
  })
12940
13238
  )
@@ -12946,11 +13244,11 @@ var SftpDestinationImpl = class {
12946
13244
  const failed = [];
12947
13245
  await this.withSftp(async (sftp) => {
12948
13246
  for (const key of keys) {
12949
- await new Promise((resolve4) => {
13247
+ await new Promise((resolve3) => {
12950
13248
  sftp.unlink(this.fullPath(key), (err) => {
12951
13249
  if (!err) {
12952
13250
  deleted.push(key);
12953
- return resolve4();
13251
+ return resolve3();
12954
13252
  }
12955
13253
  const code = err.code;
12956
13254
  if (code === 2) {
@@ -12958,7 +13256,7 @@ var SftpDestinationImpl = class {
12958
13256
  } else {
12959
13257
  failed.push({ key, error: err.message });
12960
13258
  }
12961
- resolve4();
13259
+ resolve3();
12962
13260
  });
12963
13261
  });
12964
13262
  }
@@ -12969,210 +13267,29 @@ var SftpDestinationImpl = class {
12969
13267
  registerDestination("sftp", (row) => new SftpDestinationImpl(row));
12970
13268
  registerDestination("openship_server", (row) => new SftpDestinationImpl(row));
12971
13269
 
12972
- // src/lib/compose.ts
12973
- import { spawn as spawn2, spawnSync as spawnSync3 } from "child_process";
12974
- import { randomBytes as randomBytes5 } from "crypto";
12975
- import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
12976
- import { homedir as homedir2, userInfo } from "os";
12977
- import { join as join7 } from "path";
12978
-
12979
- // src/lib/paths.ts
12980
- import { homedir } from "os";
12981
- import { join as join3, resolve as resolve2 } from "path";
12982
- var DEFAULT_HOME = join3(homedir(), ".openship");
12983
- var override = process.env.OPENSHIP_HOME?.trim();
12984
- var OS_DIR = override ? resolve2(override) : DEFAULT_HOME;
12985
- var IS_ALT_HOME = OS_DIR !== DEFAULT_HOME;
12986
- var DATA_DIR = join3(OS_DIR, "data");
12987
- var LOG_DIR = join3(OS_DIR, "logs");
12988
- var AUTH_SECRET_FILE = join3(OS_DIR, "auth-secret");
12989
- var INTERNAL_TOKEN_FILE = join3(OS_DIR, "internal-token");
12990
-
12991
- // src/lib/ports.ts
12992
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
12993
- import { join as join4 } from "path";
12994
-
12995
- // ../../packages/core/src/ports.ts
12996
- import { createServer } from "net";
12997
- var DEFAULT_API_PORT = 4e3;
12998
- var DEFAULT_DASHBOARD_PORT = 3001;
12999
- var LOOPBACK = "127.0.0.1";
13000
- var SCAN_SPAN = 20;
13001
- var MAX_PORT = 65535;
13002
- function isPortFree(port, host = LOOPBACK) {
13003
- return new Promise((resolve4) => {
13004
- const srv = createServer();
13005
- srv.once("error", () => resolve4(false));
13006
- srv.listen(port, host, () => srv.close(() => resolve4(true)));
13007
- });
13270
+ // src/lib/this-host.ts
13271
+ function thisHost() {
13272
+ return envOps(resolveLocalEnvironmentSync());
13008
13273
  }
13009
- async function waitPortFree(port, opts = {}) {
13010
- const timeoutMs = opts.timeoutMs ?? 6e3;
13011
- const intervalMs = opts.intervalMs ?? 250;
13012
- const host = opts.host ?? LOOPBACK;
13013
- const deadline = Date.now() + timeoutMs;
13014
- for (; ; ) {
13015
- if (await isPortFree(port, host)) return true;
13016
- if (Date.now() >= deadline) return false;
13017
- await new Promise((r2) => setTimeout(r2, intervalMs));
13018
- }
13274
+ function pasteable(op) {
13275
+ if (!op.supported) return null;
13276
+ const sudo = thisHost().profile.isRoot ? "" : "sudo ";
13277
+ return op.value.map((step) => `${sudo}${step}`).join(" && ");
13019
13278
  }
13020
- function getFreePort(host = LOOPBACK) {
13021
- return new Promise((resolve4, reject) => {
13022
- const srv = createServer();
13023
- srv.once("error", reject);
13024
- srv.listen(0, host, () => {
13025
- const addr = srv.address();
13026
- const port = addr && typeof addr === "object" ? addr.port : 0;
13027
- srv.close(() => port ? resolve4(port) : reject(new Error("no free port")));
13028
- });
13029
- });
13279
+ function startUnitHint(unit) {
13280
+ return pasteable(thisHost().serviceEnableStart(unit));
13030
13281
  }
13031
- async function findPortNear(from, opts = {}) {
13032
- const host = opts.host ?? LOOPBACK;
13033
- const span = opts.span ?? SCAN_SPAN;
13034
- const skip = opts.skip ?? (() => false);
13035
- for (let port2 = from; port2 <= Math.min(from + span, MAX_PORT); port2++) {
13036
- if (!skip(port2) && await isPortFree(port2, host)) return port2;
13037
- }
13038
- let port = await getFreePort(host);
13039
- for (let tries = 0; tries < 10 && skip(port); tries++) port = await getFreePort(host);
13040
- return port;
13041
- }
13042
- async function resolvePortPair(prefs = {}) {
13043
- const host = prefs.bindAddr?.trim() || LOOPBACK;
13044
- const ours = new Set(prefs.reclaimable ?? []);
13045
- const prefFor = (kind) => prefs[kind] ?? prefs.previous?.[kind] ?? prefs.stored?.[kind] ?? prefs.defaults?.[kind];
13046
- const remembered = (kind, pref) => prefs[kind] === void 0 && (prefs.previous?.[kind] === pref || prefs.stored?.[kind] === pref);
13047
- const availableForPref = async (port) => ours.has(port) || await isPortFree(port, host);
13048
- const apiPref = prefFor("api");
13049
- const dashPref = prefFor("dashboard");
13050
- let api;
13051
- if (apiPref === void 0) api = await getFreePort(host);
13052
- else if (await availableForPref(apiPref)) api = apiPref;
13053
- else if (remembered("api", apiPref) && await waitPortFree(apiPref, { host })) api = apiPref;
13054
- else {
13055
- api = await findPortNear(apiPref + 1, {
13056
- host,
13057
- skip: (p2) => ours.has(p2) || p2 === dashPref
13058
- });
13059
- }
13060
- const skipForDash = (p2) => ours.has(p2) || p2 === api;
13061
- let dashboard;
13062
- if (dashPref === void 0) {
13063
- dashboard = await getFreePort(host);
13064
- for (let tries = 0; tries < 10 && dashboard === api; tries++) {
13065
- dashboard = await getFreePort(host);
13066
- }
13067
- } else if (dashPref !== api && await availableForPref(dashPref)) {
13068
- dashboard = dashPref;
13069
- } else if (dashPref !== api && remembered("dashboard", dashPref) && await waitPortFree(dashPref, { host })) {
13070
- dashboard = dashPref;
13071
- } else {
13072
- dashboard = await findPortNear(dashPref + 1, { host, skip: skipForDash });
13073
- }
13074
- return {
13075
- api,
13076
- dashboard,
13077
- switched: {
13078
- api: apiPref !== void 0 && api !== apiPref,
13079
- dashboard: dashPref !== void 0 && dashboard !== dashPref
13080
- },
13081
- preferred: { api: apiPref ?? api, dashboard: dashPref ?? dashboard }
13082
- };
13083
- }
13084
-
13085
- // src/lib/ports.ts
13086
- var PORTS_FILE = join4(OS_DIR, "ports.json");
13087
- var INSTANCE_FILE = join4(OS_DIR, "instance.json");
13088
- function saveInstanceUrl(publicUrl) {
13089
- try {
13090
- if (!existsSync(OS_DIR)) mkdirSync(OS_DIR, { recursive: true, mode: 448 });
13091
- writeFileSync(INSTANCE_FILE, JSON.stringify({ publicUrl: publicUrl ?? null }));
13092
- } catch {
13093
- }
13094
- }
13095
- function readInstanceUrl() {
13096
- try {
13097
- return JSON.parse(readFileSync(INSTANCE_FILE, "utf8")).publicUrl ?? null;
13098
- } catch {
13099
- return null;
13100
- }
13101
- }
13102
- function readStoredPorts() {
13103
- try {
13104
- return JSON.parse(readFileSync(PORTS_FILE, "utf-8"));
13105
- } catch {
13106
- return {};
13107
- }
13108
- }
13109
- function storedApiPort() {
13110
- return readStoredPorts().api ?? DEFAULT_API_PORT;
13111
- }
13112
- function storedDashboardPort() {
13113
- return readStoredPorts().dashboard ?? DEFAULT_DASHBOARD_PORT;
13114
- }
13115
- function saveStoredPorts(api, dashboard) {
13116
- try {
13117
- if (!existsSync(OS_DIR)) mkdirSync(OS_DIR, { recursive: true, mode: 448 });
13118
- writeFileSync(PORTS_FILE, JSON.stringify({ api, dashboard }));
13119
- } catch {
13120
- }
13121
- }
13122
- function portMoveNotice(resolved, trustedOriginUrls = []) {
13123
- if (!resolved.switched.api && !resolved.switched.dashboard) return [];
13124
- const lines = [
13125
- `A preferred port was busy \u2014 using API ${resolved.api}, dashboard ${resolved.dashboard}.`
13126
- ];
13127
- const stale = [...new Set(stalePortOrigins(resolved.preferred.dashboard, trustedOriginUrls))];
13128
- if (stale.length) {
13129
- lines.push(
13130
- `Heads up: ${stale.join(", ")} still names port ${resolved.preferred.dashboard}, so logins`,
13131
- `from that origin will be rejected. Point it at :${resolved.dashboard} (or free port`,
13132
- `${resolved.preferred.dashboard} and re-run to move back).`
13133
- );
13134
- }
13135
- return lines;
13136
- }
13137
- function stalePortOrigins(movedFrom, urls) {
13138
- const stale = [];
13139
- for (const raw of urls) {
13140
- for (const candidate of (raw ?? "").split(",")) {
13141
- const value = candidate.trim();
13142
- if (!value) continue;
13143
- try {
13144
- if (new URL(value).port === String(movedFrom)) stale.push(value);
13145
- } catch {
13146
- }
13147
- }
13148
- }
13149
- return stale;
13150
- }
13151
- async function resolvePorts(prefs, opts = {}) {
13152
- const resolved = await resolvePortPair({
13153
- ...prefs,
13154
- stored: readStoredPorts(),
13155
- defaults: { api: DEFAULT_API_PORT, dashboard: DEFAULT_DASHBOARD_PORT }
13156
- });
13157
- if (opts.persist !== false) saveStoredPorts(resolved.api, resolved.dashboard);
13158
- return resolved;
13159
- }
13160
-
13161
- // src/lib/source-install.ts
13162
- import { spawnSync as spawnSync2 } from "child_process";
13163
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
13164
- import { join as join6 } from "path";
13165
13282
 
13166
13283
  // src/lib/from-source.ts
13167
- import { spawn, spawnSync } from "child_process";
13284
+ import { spawn, spawnSync as spawnSync2 } from "child_process";
13168
13285
  import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
13169
- import { join as join5, resolve as resolve3 } from "path";
13286
+ import { join as join4, resolve as resolve2 } from "path";
13170
13287
  var DEFAULT_REPO = "https://github.com/oblien/openship.git";
13171
- var SOURCE_CHECKOUT_DIR = join5(OS_DIR, "src");
13172
- var SOURCE_DIST_DIR = join5(OS_DIR, "from-source-dist");
13288
+ var SOURCE_CHECKOUT_DIR = join4(OS_DIR, "src");
13289
+ var SOURCE_DIST_DIR = join4(OS_DIR, "from-source-dist");
13173
13290
  function has(cmd) {
13174
13291
  try {
13175
- return spawnSync(cmd, ["--version"], { stdio: "ignore" }).status === 0;
13292
+ return spawnSync2(cmd, ["--version"], { stdio: "ignore" }).status === 0;
13176
13293
  } catch {
13177
13294
  return false;
13178
13295
  }
@@ -13194,11 +13311,11 @@ function run(cmd, args, cwd, env) {
13194
13311
  });
13195
13312
  }
13196
13313
  function shortSha(cwd) {
13197
- const r2 = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd, encoding: "utf8" });
13314
+ const r2 = spawnSync2("git", ["rev-parse", "--short", "HEAD"], { cwd, encoding: "utf8" });
13198
13315
  return r2.status === 0 ? (r2.stdout ?? "").trim() || "unknown" : "unknown";
13199
13316
  }
13200
13317
  function isMonorepo(dir) {
13201
- return existsSync2(join5(dir, "package.json")) && existsSync2(join5(dir, "apps/api/package.json")) && existsSync2(join5(dir, "apps/dashboard/package.json"));
13318
+ return existsSync2(join4(dir, "package.json")) && existsSync2(join4(dir, "apps/api/package.json")) && existsSync2(join4(dir, "apps/dashboard/package.json"));
13202
13319
  }
13203
13320
  async function prepareFromSource(opts) {
13204
13321
  if (!has("bun")) {
@@ -13209,7 +13326,7 @@ async function prepareFromSource(opts) {
13209
13326
  let sourceDir;
13210
13327
  let ref;
13211
13328
  if (opts.source) {
13212
- sourceDir = resolve3(opts.source);
13329
+ sourceDir = resolve2(opts.source);
13213
13330
  if (!isMonorepo(sourceDir)) {
13214
13331
  throw new Error(
13215
13332
  `--source ${sourceDir} doesn't look like an Openship checkout (missing package.json / apps/api / apps/dashboard).`
@@ -13224,7 +13341,7 @@ async function prepareFromSource(opts) {
13224
13341
  const repoUrl = opts.repo || DEFAULT_REPO;
13225
13342
  sourceDir = SOURCE_CHECKOUT_DIR;
13226
13343
  mkdirSync2(OS_DIR, { recursive: true });
13227
- if (!existsSync2(join5(sourceDir, ".git"))) {
13344
+ if (!existsSync2(join4(sourceDir, ".git"))) {
13228
13345
  console.log(` Cloning ${repoUrl} \u2192 ${sourceDir}`);
13229
13346
  await run("git", ["clone", repoUrl, sourceDir], OS_DIR);
13230
13347
  }
@@ -13243,25 +13360,43 @@ async function prepareFromSource(opts) {
13243
13360
  console.log(" Building release dist (compiles the dashboard \u2014 needs RAM/CPU)\u2026");
13244
13361
  await run(
13245
13362
  "bun",
13246
- ["run", join5(sourceDir, "apps/api/scripts/build-release.ts")],
13363
+ ["run", join4(sourceDir, "apps/api/scripts/build-release.ts")],
13247
13364
  sourceDir,
13248
13365
  { DIST_DIR: distDir, NODE_ENV: "production", CLOUD_MODE: "false", OPENSHIP_TARGET: "local" }
13249
13366
  );
13250
13367
  console.log(" Installing runtime dependencies in the dist\u2026");
13251
13368
  await run("bun", ["install", "--production", "--frozen-lockfile"], distDir);
13252
- const apiDir = join5(distDir, "api");
13253
- const dashboardDir = join5(distDir, "dashboard");
13254
- if (!existsSync2(join5(apiDir, "src/index.ts"))) {
13369
+ const apiDir = join4(distDir, "api");
13370
+ const dashboardDir = join4(distDir, "dashboard");
13371
+ if (!existsSync2(join4(apiDir, "src/index.ts"))) {
13255
13372
  throw new Error(`Build produced no API at ${apiDir}/src/index.ts \u2014 build-release layout drift?`);
13256
13373
  }
13257
- if (!existsSync2(join5(dashboardDir, "apps/dashboard/server.js"))) {
13374
+ if (!existsSync2(join4(dashboardDir, "apps/dashboard/server.js"))) {
13258
13375
  throw new Error(`Build produced no dashboard at ${dashboardDir}/apps/dashboard/server.js.`);
13259
13376
  }
13260
13377
  return { apiDir, dashboardDir, ref, sha, sourceDir };
13261
13378
  }
13262
13379
 
13380
+ // src/lib/compose.ts
13381
+ import { spawn as spawn2, spawnSync as spawnSync4 } from "child_process";
13382
+ import { randomBytes as randomBytes5 } from "crypto";
13383
+ import {
13384
+ chmodSync,
13385
+ existsSync as existsSync4,
13386
+ mkdirSync as mkdirSync3,
13387
+ readFileSync as readFileSync3,
13388
+ renameSync,
13389
+ rmSync,
13390
+ writeFileSync as writeFileSync2
13391
+ } from "fs";
13392
+ import { homedir, userInfo } from "os";
13393
+ import { dirname as dirname2, join as join6 } from "path";
13394
+
13263
13395
  // src/lib/source-install.ts
13264
- var MARKER = join6(OS_DIR, "source-install.json");
13396
+ import { spawnSync as spawnSync3 } from "child_process";
13397
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
13398
+ import { join as join5 } from "path";
13399
+ var MARKER = join5(OS_DIR, "source-install.json");
13265
13400
  function parseSourceInstall(raw) {
13266
13401
  try {
13267
13402
  const obj = JSON.parse(raw);
@@ -13284,7 +13419,7 @@ function parseRemoteSha(stdout) {
13284
13419
  return sha && /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 7) : null;
13285
13420
  }
13286
13421
  function remoteSha(repo, ref) {
13287
- const r2 = spawnSync2("git", ["ls-remote", repo, ref], { encoding: "utf8" });
13422
+ const r2 = spawnSync3("git", ["ls-remote", repo, ref], { encoding: "utf8" });
13288
13423
  if (r2.status !== 0 || !r2.stdout) return null;
13289
13424
  return parseRemoteSha(r2.stdout);
13290
13425
  }
@@ -13295,7 +13430,7 @@ async function rebuildFromSource(info) {
13295
13430
  if (!has("git")) {
13296
13431
  throw new Error("`git` is required to update the source checkout but wasn't found on PATH.");
13297
13432
  }
13298
- if (!existsSync3(join6(info.dir, ".git"))) {
13433
+ if (!existsSync3(join5(info.dir, ".git"))) {
13299
13434
  throw new Error(
13300
13435
  `Source checkout ${info.dir} is missing (no .git) \u2014 reinstall from source with scripts/install-source.sh.`
13301
13436
  );
@@ -13304,7 +13439,7 @@ async function rebuildFromSource(info) {
13304
13439
  await run("git", ["checkout", info.ref], info.dir);
13305
13440
  await run("git", ["pull", "--ff-only", "origin", info.ref], info.dir).catch(() => {
13306
13441
  });
13307
- const cliDir = join6(info.dir, "apps/cli");
13442
+ const cliDir = join5(info.dir, "apps/cli");
13308
13443
  await run("bun", ["install"], info.dir);
13309
13444
  await run("bun", ["run", "build"], cliDir);
13310
13445
  await run("bun", ["run", "build/stage-dashboard.ts"], cliDir);
@@ -13317,11 +13452,13 @@ var EDGE_ACME_HOST_DIR = `${EDGE_HOST_STATE_DIR}/acme`;
13317
13452
  function edgeVolumeYaml(indent) {
13318
13453
  return EDGE_CONTAINER_MOUNTS.map((m2) => `${indent}- ${m2.host}:${m2.container}:z`).join("\n");
13319
13454
  }
13320
- var COMPOSE_DIR = join7(OS_DIR, "compose");
13321
- var INSTALL_METHOD_FILE = join7(OS_DIR, "install-method");
13322
- var COMPOSE_FILE = join7(COMPOSE_DIR, "docker-compose.yml");
13323
- var BUILD_FILE = join7(COMPOSE_DIR, "docker-compose.build.yml");
13324
- var ENV_FILE = join7(COMPOSE_DIR, ".env");
13455
+ var COMPOSE_DIR = join6(OS_DIR, "compose");
13456
+ var INSTALL_METHOD_FILE = join6(OS_DIR, "install-method");
13457
+ var COMPOSE_FILE = join6(COMPOSE_DIR, "docker-compose.yml");
13458
+ var BUILD_FILE = join6(COMPOSE_DIR, "docker-compose.build.yml");
13459
+ var ENV_FILE = join6(COMPOSE_DIR, ".env");
13460
+ var ENV_BACKUP_FILE = join6(COMPOSE_DIR, ".env.bak");
13461
+ var ENV_TMP_FILE = join6(COMPOSE_DIR, ".env.tmp");
13325
13462
  var BUILT_SERVICES = [
13326
13463
  { service: "api", dockerfile: "apps/api/Dockerfile" },
13327
13464
  { service: "dashboard", dockerfile: "apps/dashboard/Dockerfile" },
@@ -13341,7 +13478,7 @@ function writeInstallMethod(method) {
13341
13478
  writeFileSync2(INSTALL_METHOD_FILE, method, { mode: 384 });
13342
13479
  }
13343
13480
  function dockerState() {
13344
- const ok = (args) => spawnSync3("docker", args, { stdio: "ignore" }).status === 0;
13481
+ const ok = (args) => spawnSync4("docker", args, { stdio: "ignore" }).status === 0;
13345
13482
  if (!ok(["--version"])) return { binary: false, plugin: false, daemon: false };
13346
13483
  return { binary: true, plugin: ok(["compose", "version"]), daemon: ok(["version"]) };
13347
13484
  }
@@ -13356,13 +13493,13 @@ function dockerGap(state = dockerState()) {
13356
13493
  };
13357
13494
  }
13358
13495
  if (!state.daemon) {
13359
- const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
13496
+ const start = pasteable(thisHost().dockerStart());
13360
13497
  return {
13361
13498
  summary: "Docker is installed but its daemon isn't reachable",
13362
13499
  // Reinstalling changes nothing: a group change only applies to NEW logins,
13363
13500
  // and a stopped daemon needs starting, not installing.
13364
13501
  installable: false,
13365
- hint: asRoot ? "Start it with: systemctl start docker" : `Add your user to the docker group: sudo usermod -aG docker ${userInfo().username} \u2014 then log out and back in (or run: newgrp docker). If the daemon is stopped: sudo systemctl start docker`
13502
+ hint: thisHost().profile.isRoot ? start ? `Start it with: ${start}` : "Start the Docker daemon \u2014 this host has no service manager Openship recognizes." : `Add your user to the docker group: sudo usermod -aG docker ${userInfo().username} \u2014 then log out and back in (or run: newgrp docker). If the daemon is stopped: ${start ?? "start it the way this host starts services"}`
13366
13503
  };
13367
13504
  }
13368
13505
  return null;
@@ -13371,7 +13508,7 @@ function hasDockerCompose() {
13371
13508
  return dockerGap() === null;
13372
13509
  }
13373
13510
  function hasCmd(cmd) {
13374
- return spawnSync3(cmd, ["--version"], { stdio: "ignore" }).status === 0;
13511
+ return spawnSync4(cmd, ["--version"], { stdio: "ignore" }).status === 0;
13375
13512
  }
13376
13513
  function shQuote(s2) {
13377
13514
  return `'${s2.replace(/'/g, `'\\''`)}'`;
@@ -13380,23 +13517,22 @@ function dockerInstallPreview(state = dockerState()) {
13380
13517
  const gap = dockerGap(state);
13381
13518
  if (!gap) return { state, gap: null, wouldInstall: false };
13382
13519
  if (process.platform !== "linux" || !gap.installable) return { state, gap, wouldInstall: false };
13383
- const plan = systemCatalog.installs.docker({
13384
- os: "linux",
13385
- serviceManager: "systemd"
13386
- });
13387
- if (!plan.supported || !plan.installCommand) return { state, gap, wouldInstall: false };
13520
+ const plan = systemCatalog.installs.docker(resolveLocalEnvironmentSync());
13521
+ if (!plan.supported) return { state, gap, wouldInstall: false, unsupportedReason: plan.reason };
13522
+ const start = plan.value.service;
13388
13523
  return {
13389
13524
  state,
13390
13525
  gap,
13391
13526
  wouldInstall: true,
13392
- installCommand: plan.installCommand,
13393
- ...plan.startCommand ? { startCommand: plan.startCommand } : {}
13527
+ installCommand: plan.value.installCommand,
13528
+ ...start?.supported ? { startCommand: start.value } : {},
13529
+ ...start && !start.supported ? { startBlockedReason: start.reason } : {}
13394
13530
  };
13395
13531
  }
13396
13532
  async function ensureDocker(opts = {}) {
13397
13533
  const notice = opts.onNotice ?? ((line) => process.stderr.write(` ${line}
13398
13534
  `));
13399
- const { state, gap, wouldInstall, installCommand, startCommand } = dockerInstallPreview();
13535
+ const { state, gap, wouldInstall, installCommand, startCommand, unsupportedReason, startBlockedReason } = dockerInstallPreview();
13400
13536
  if (!gap) return true;
13401
13537
  if (process.platform !== "linux") return false;
13402
13538
  if (!gap.installable) {
@@ -13404,14 +13540,17 @@ async function ensureDocker(opts = {}) {
13404
13540
  if (gap.hint) notice(gap.hint);
13405
13541
  return false;
13406
13542
  }
13407
- if (!wouldInstall || !installCommand) return false;
13543
+ if (!wouldInstall || !installCommand) {
13544
+ if (unsupportedReason) notice(unsupportedReason);
13545
+ return false;
13546
+ }
13408
13547
  const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
13409
13548
  const sudo = !asRoot && hasCmd("sudo") ? "sudo " : "";
13410
13549
  notice("This can take 2-5 minutes on a small VPS (~150 MB of packages) and stays quiet while apt works.");
13411
13550
  if (state.binary) {
13412
13551
  notice("Docker's installer will warn that docker already exists and pause ~20s before continuing \u2014 that's expected.");
13413
13552
  }
13414
- const sh = (script) => new Promise((resolve4) => {
13553
+ const sh = (script) => new Promise((resolve3) => {
13415
13554
  const child = spawn2("sh", ["-c", sudo ? `${sudo}sh -c ${shQuote(script)}` : script], {
13416
13555
  stdio: "inherit"
13417
13556
  });
@@ -13422,13 +13561,14 @@ async function ensureDocker(opts = {}) {
13422
13561
  }, 3e4);
13423
13562
  const done = (code) => {
13424
13563
  clearInterval(tick);
13425
- resolve4(code);
13564
+ resolve3(code);
13426
13565
  };
13427
13566
  child.on("error", () => done(1));
13428
13567
  child.on("close", (code) => done(code ?? 1));
13429
13568
  });
13430
13569
  if (await sh(installCommand) !== 0) return false;
13431
13570
  if (startCommand) await sh(startCommand);
13571
+ else if (startBlockedReason) notice(startBlockedReason);
13432
13572
  const after = dockerGap();
13433
13573
  if (!after) {
13434
13574
  notice("Docker ready.");
@@ -13481,7 +13621,14 @@ services:
13481
13621
  # (set by \`openship up\` when a public URL is configured for off-box access).
13482
13622
  ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${API_PORT:-4000}:\${API_PORT:-4000}"]
13483
13623
  volumes:
13484
- - /var/run/docker.sock:/var/run/docker.sock
13624
+ # The daemon socket, at the path the api's own default expects INSIDE the
13625
+ # container. The HOST side is a variable because it MOVES: a rootless daemon
13626
+ # keeps its socket under the invoking user's runtime dir, and Docker creates a
13627
+ # missing bind source as an empty DIRECTORY instead of failing \u2014 so the rootful
13628
+ # path there brings the stack up and then fails every container operation in
13629
+ # the transport, naming neither the path nor the cause (#482). \`openship up\`
13630
+ # writes OPENSHIP_DOCKER_SOCKET when the detected path isn't this default.
13631
+ - \${OPENSHIP_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
13485
13632
  # Routing state shared with the edge, as HOST BIND MOUNTS (generated from
13486
13633
  # EDGE_CONTAINER_MOUNTS): the vhost tree, /etc/letsencrypt, the ACME webroot
13487
13634
  # and the static doc-roots the API writes and the edge serves. Named volumes
@@ -13490,9 +13637,16 @@ services:
13490
13637
  ${edgeVolumeYaml(" ")}
13491
13638
  # Host-op SSH key (createHostExecutor \u2192 host.docker.internal). /dev/null
13492
13639
  # when the host channel isn't provisioned \u2192 OPENSHIP_HOST_SSH_HOST stays
13493
- # unset and the API falls back to LocalExecutor.
13640
+ # unset, and host operations REFUSE (they'd otherwise run against this
13641
+ # container's filesystem, #509) while ordinary deploys keep working over
13642
+ # the socket below.
13494
13643
  - \${OPENSHIP_HOST_KEY_PATH:-/dev/null}:/run/secrets/openship_host_key:ro
13495
- extra_hosts: ["host.docker.internal:host-gateway"]
13644
+ # \`host-gateway\` is resolved by the daemon and is right on a stock rootful
13645
+ # install. It is NOT universally right: under rootless Docker it lands in
13646
+ # RootlessKit's namespace rather than the host's, so the host's sshd isn't
13647
+ # there (#482). OPENSHIP_HOST_GATEWAY re-points the alias (e.g. at the box's
13648
+ # LAN address) without patching this file, which \`openship up\` rewrites.
13649
+ extra_hosts: ["host.docker.internal:\${OPENSHIP_HOST_GATEWAY:-host-gateway}"]
13496
13650
  env_file: [.env]
13497
13651
  environment:
13498
13652
  NODE_ENV: production
@@ -13546,97 +13700,437 @@ volumes:
13546
13700
  function keepSecret(existing, key) {
13547
13701
  return existing[key] || randomBytes5(32).toString("hex");
13548
13702
  }
13703
+ var GENERATED_SECRET_KEYS = ["POSTGRES_PASSWORD", "BETTER_AUTH_SECRET", "INTERNAL_TOKEN"];
13704
+ function survivingDbVolume(prev) {
13705
+ const candidates = /* @__PURE__ */ new Set([prev.COMPOSE_PROJECT_NAME, "openship", "compose"]);
13706
+ for (const project of candidates) {
13707
+ if (project && dbVolumeExists(project)) return `${project}_postgres_data`;
13708
+ }
13709
+ return null;
13710
+ }
13711
+ function secretRotationRisk(prev) {
13712
+ const keys = GENERATED_SECRET_KEYS.filter((k2) => !prev[k2]);
13713
+ if (keys.length === 0) return null;
13714
+ const volume = survivingDbVolume(prev);
13715
+ return volume ? { volume, keys: [...keys] } : null;
13716
+ }
13717
+ function composeSecretRotationRisk(opts = {}) {
13718
+ if (opts.resetSecrets) return null;
13719
+ return secretRotationRisk(readEnvFile());
13720
+ }
13721
+ function renderSecretRotationRefusal(rotation, opts = {}) {
13722
+ return `
13723
+ ${opts.dryRun ? "A real run would REFUSE here" : "Refusing to continue"}: this would regenerate secrets your existing install already has.
13724
+
13725
+ missing from ${ENV_FILE}: ${rotation.keys.join(", ")}
13726
+ but this install's data is still here: ${rotation.volume}
13727
+
13728
+ A new POSTGRES_PASSWORD cannot match that volume \u2014 Postgres only applies the password
13729
+ when it first creates the data directory \u2014 and a new BETTER_AUTH_SECRET cannot decrypt
13730
+ the environment variables already stored in the database. Nothing has been written yet.
13731
+
13732
+ ` + secretRecoveryAdvice(rotation);
13733
+ }
13734
+ function secretRecoveryAdvice(rotation) {
13735
+ const adopted = adoptedStack(projectOfDbVolume(rotation.volume));
13736
+ if (adopted) {
13737
+ return ` This stack was not created by \`openship up\`: it is already running under the compose
13738
+ project name we pin, so this run would ADOPT it.
13739
+
13740
+ project: ${adopted.project}
13741
+ started from: ${adopted.configFiles || "an unlabelled `docker run`"}
13742
+ not from: ${COMPOSE_FILE}
13743
+
13744
+ So there is no \`.env\` of ours to restore and no backup of one \u2014 the real values are in
13745
+ the api container's own environment. Copy them out of it, then re-run:
13746
+
13747
+ echo 'COMPOSE_PROJECT_NAME=${adopted.project}' >> ${ENV_FILE}
13748
+ docker inspect ${adopted.apiContainer} --format '{{range .Config.Env}}{{println .}}{{end}}' \\
13749
+ | grep -E '^(${rotation.keys.join("|")})=' >> ${ENV_FILE}
13750
+
13751
+ COMPOSE_PROJECT_NAME matters as much as the secrets do: it is the volume prefix, so
13752
+ without it this stack comes up on empty volumes beside the data above.
13753
+
13754
+ Only if that container is gone and the values exist nowhere else \u2014 this DESTROYS them,
13755
+ leaving every environment variable stored in the database permanently unreadable:
13756
+ openship up --reset-secrets
13757
+ `;
13758
+ }
13759
+ return ` Put the original values back and re-run:
13760
+ ` + (existsSync4(ENV_BACKUP_FILE) ? ` cp ${ENV_BACKUP_FILE} ${ENV_FILE} # the .env a previous \`openship up\` replaced
13761
+ ` : ` restore ${ENV_FILE} from a backup, or copy the keys above out of one
13762
+ `) + ` If the file is there but this user can't read it, re-run as the user that owns it
13763
+ (or as root) rather than letting the secrets be regenerated.
13764
+
13765
+ To reset instead \u2014 the database password gets realigned, but every stored environment
13766
+ variable becomes permanently unreadable:
13767
+ openship up --reset-secrets
13768
+ `;
13769
+ }
13770
+ function projectOfDbVolume(volume) {
13771
+ return volume.replace(/_postgres_data$/, "");
13772
+ }
13549
13773
  function readEnvFile() {
13550
13774
  const out = {};
13775
+ let text;
13551
13776
  try {
13552
- for (const line of readFileSync3(ENV_FILE, "utf8").split("\n")) {
13553
- const m2 = line.match(/^([A-Z0-9_]+)=(.*)$/);
13554
- if (m2) out[m2[1]] = m2[2];
13777
+ text = readFileSync3(ENV_FILE, "utf8");
13778
+ } catch (err) {
13779
+ if (err.code !== "ENOENT") {
13780
+ console.log(
13781
+ ` ! Could not read ${ENV_FILE}: ${err.message}
13782
+ Its contents are being treated as absent. If this install already exists, fix the permissions and re-run rather than letting secrets be regenerated.`
13783
+ );
13555
13784
  }
13556
- } catch {
13785
+ return out;
13786
+ }
13787
+ for (const line of text.split("\n")) {
13788
+ const m2 = line.match(/^([A-Z0-9_]+)=(.*)$/);
13789
+ if (m2) out[m2[1]] = m2[2];
13557
13790
  }
13558
13791
  return out;
13559
13792
  }
13793
+ var HOST_CHANNEL_DEFAULT_HOST = "host.docker.internal";
13794
+ var HOST_CHANNEL_DEFAULT_PORT = 22;
13560
13795
  var HOST_KEY_COMMENT = "openship-host-executor";
13561
13796
  var HOST_KEY_FROM = "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8,127.0.0.1";
13562
13797
  function hostKeyAuthLine(pub) {
13563
13798
  return `from="${HOST_KEY_FROM}",restrict,pty ${pub}`;
13564
13799
  }
13565
13800
  function rewriteHostAuthorizedKeys(existing, pub) {
13566
- const kept = existing.split("\n").filter((line) => line.trim() && !line.includes(HOST_KEY_COMMENT));
13567
- return [...kept, hostKeyAuthLine(pub)].join("\n") + "\n";
13801
+ return [...keptForeignKeys(existing), hostKeyAuthLine(pub)].join("\n") + "\n";
13802
+ }
13803
+ function keptForeignKeys(existing) {
13804
+ return existing.split("\n").filter((line) => line.trim() && !line.includes(HOST_KEY_COMMENT));
13805
+ }
13806
+ function stripHostAuthorizedKeys(existing) {
13807
+ const kept = keptForeignKeys(existing);
13808
+ return kept.length ? kept.join("\n") + "\n" : "";
13809
+ }
13810
+ var ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
13811
+ function chooseHostChannelUser(input) {
13812
+ if (input.invokerUid === 0) {
13813
+ return { user: "root", authKeysPath: ROOT_AUTHORIZED_KEYS, viaSudo: false, rootUnavailable: false };
13814
+ }
13815
+ if (input.hasPasswordlessSudo) {
13816
+ return { user: "root", authKeysPath: ROOT_AUTHORIZED_KEYS, viaSudo: true, rootUnavailable: false };
13817
+ }
13818
+ return {
13819
+ user: input.invokerName,
13820
+ authKeysPath: join6(input.invokerHome, ".ssh", "authorized_keys"),
13821
+ viaSudo: false,
13822
+ rootUnavailable: true
13823
+ };
13824
+ }
13825
+ function hasPasswordlessSudo() {
13826
+ try {
13827
+ return spawnSync4("sudo", ["-n", "true"], { stdio: "ignore" }).status === 0;
13828
+ } catch {
13829
+ return false;
13830
+ }
13568
13831
  }
13569
13832
  function plannedHostChannel(hostControl) {
13570
13833
  if (!hostControl || process.platform !== "linux") return null;
13571
- let user;
13834
+ let invoker;
13572
13835
  try {
13573
- user = userInfo().username;
13836
+ const info = userInfo();
13837
+ invoker = { uid: info.uid, username: info.username, home: info.homedir };
13838
+ } catch {
13839
+ invoker = { uid: -1, username: process.env.USER || process.env.LOGNAME || "root", home: homedir() };
13840
+ }
13841
+ const decision = chooseHostChannelUser({
13842
+ invokerUid: invoker.uid,
13843
+ invokerName: invoker.username,
13844
+ invokerHome: invoker.home,
13845
+ // Only probe sudo when it could change the outcome — a root invoker never needs it.
13846
+ hasPasswordlessSudo: invoker.uid !== 0 && hasPasswordlessSudo()
13847
+ });
13848
+ return { ...decision, keyPath: join6(COMPOSE_DIR, "host-ssh", "id_ed25519") };
13849
+ }
13850
+ function authorizeKeyAt(authKeysPath, pub) {
13851
+ mkdirSync3(dirname2(authKeysPath), { recursive: true, mode: 448 });
13852
+ const existing = existsSync4(authKeysPath) ? readFileSync3(authKeysPath, "utf8") : "";
13853
+ const next = rewriteHostAuthorizedKeys(existing, pub);
13854
+ if (next !== existing) writeFileSync2(authKeysPath, next, { mode: 384 });
13855
+ chmodSync(authKeysPath, 384);
13856
+ }
13857
+ function authorizeRootKeyViaSudo(pub) {
13858
+ const mk = spawnSync4("sudo", ["-n", "sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"], {
13859
+ stdio: "ignore"
13860
+ });
13861
+ if (mk.status !== 0) return false;
13862
+ const read = spawnSync4(
13863
+ "sudo",
13864
+ ["-n", "sh", "-c", `cat ${ROOT_AUTHORIZED_KEYS} 2>/dev/null || true`],
13865
+ { encoding: "utf8" }
13866
+ );
13867
+ if (read.status !== 0) return false;
13868
+ const next = rewriteHostAuthorizedKeys(read.stdout ?? "", pub);
13869
+ const write = spawnSync4("sudo", ["-n", "tee", ROOT_AUTHORIZED_KEYS], {
13870
+ input: next,
13871
+ stdio: ["pipe", "ignore", "ignore"]
13872
+ });
13873
+ if (write.status !== 0) return false;
13874
+ return spawnSync4("sudo", ["-n", "chmod", "600", ROOT_AUTHORIZED_KEYS], { stdio: "ignore" }).status === 0;
13875
+ }
13876
+ function revokeKeyAt(authKeysPath) {
13877
+ try {
13878
+ if (!existsSync4(authKeysPath)) return true;
13879
+ const existing = readFileSync3(authKeysPath, "utf8");
13880
+ if (!existing.includes(HOST_KEY_COMMENT)) return true;
13881
+ writeFileSync2(authKeysPath, stripHostAuthorizedKeys(existing), { mode: 384 });
13882
+ chmodSync(authKeysPath, 384);
13883
+ return true;
13884
+ } catch {
13885
+ return false;
13886
+ }
13887
+ }
13888
+ function revokeRootKeyViaSudo() {
13889
+ const read = spawnSync4(
13890
+ "sudo",
13891
+ ["-n", "sh", "-c", `cat ${ROOT_AUTHORIZED_KEYS} 2>/dev/null || true`],
13892
+ { encoding: "utf8" }
13893
+ );
13894
+ if (read.status !== 0) return false;
13895
+ const existing = read.stdout ?? "";
13896
+ if (!existing.includes(HOST_KEY_COMMENT)) return true;
13897
+ const write = spawnSync4("sudo", ["-n", "tee", ROOT_AUTHORIZED_KEYS], {
13898
+ input: stripHostAuthorizedKeys(existing),
13899
+ stdio: ["pipe", "ignore", "ignore"]
13900
+ });
13901
+ return write.status === 0;
13902
+ }
13903
+ function retireHostSshChannel(prev) {
13904
+ const keyDir = join6(COMPOSE_DIR, "host-ssh");
13905
+ if (!existsSync4(keyDir) && !prev.OPENSHIP_HOST_SSH_HOST) return;
13906
+ const invokerKeys = join6(homedir(), ".ssh", "authorized_keys");
13907
+ const asRoot = (prev.OPENSHIP_HOST_SSH_USER?.trim() || "root") === "root";
13908
+ const weAreRoot = typeof process.getuid === "function" && process.getuid() === 0;
13909
+ const revoked = asRoot ? weAreRoot ? revokeKeyAt(ROOT_AUTHORIZED_KEYS) : hasPasswordlessSudo() && revokeRootKeyViaSudo() : revokeKeyAt(invokerKeys);
13910
+ const alsoInvoker = asRoot ? revokeKeyAt(invokerKeys) : true;
13911
+ try {
13912
+ rmSync(keyDir, { recursive: true, force: true });
13574
13913
  } catch {
13575
- user = process.env.USER || process.env.LOGNAME || "root";
13576
13914
  }
13577
- return { user, keyPath: join7(COMPOSE_DIR, "host-ssh", "id_ed25519") };
13915
+ const stuck = asRoot ? ROOT_AUTHORIZED_KEYS : invokerKeys;
13916
+ console.log(
13917
+ revoked && alsoInvoker ? " Host control off: dropped OPENSHIP_HOST_SSH_* from .env, deleted the host key and\n revoked its authorized_keys line." : ` Host control off: dropped OPENSHIP_HOST_SSH_* from .env and deleted the host key (it
13918
+ can no longer be used). Removing its line needs root \u2014 run:
13919
+ sudo sed -i '/${HOST_KEY_COMMENT}/d' ${stuck}`
13920
+ );
13578
13921
  }
13579
- function provisionHostSshChannel(hostControl) {
13580
- const target = plannedHostChannel(hostControl);
13581
- if (!target) return null;
13582
- const { user, keyPath } = target;
13922
+ function provisionHostSshChannel(cfg) {
13923
+ const target = plannedHostChannel(cfg.hostControl);
13924
+ if (!target) return { channel: null };
13925
+ const { user, keyPath, authKeysPath, viaSudo, rootUnavailable } = target;
13926
+ const provisioned = (channel) => {
13927
+ const port = toPort(cfg.hostSshPort) ?? HOST_CHANNEL_DEFAULT_PORT;
13928
+ return probeHostSshd(port) === "absent" ? { channel, issue: { code: "no-sshd", detail: `port ${port}` } } : { channel };
13929
+ };
13583
13930
  try {
13584
- mkdirSync3(join7(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 448 });
13931
+ mkdirSync3(join6(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 448 });
13585
13932
  if (!existsSync4(keyPath)) {
13586
- const g2 = spawnSync3(
13933
+ const g2 = spawnSync4(
13587
13934
  "ssh-keygen",
13588
13935
  ["-t", "ed25519", "-N", "", "-q", "-f", keyPath, "-C", HOST_KEY_COMMENT],
13589
- { stdio: "ignore" }
13936
+ // stderr is captured rather than discarded: it is the only account of why the
13937
+ // channel is missing, and the caller prints it.
13938
+ { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] }
13590
13939
  );
13591
- if (g2.status !== 0) return null;
13940
+ if (g2.status !== 0) return { channel: null, issue: { code: "keygen", detail: keygenWhy(g2) } };
13592
13941
  }
13593
13942
  const pub = readFileSync3(`${keyPath}.pub`, "utf8").trim();
13594
- if (!pub) return null;
13595
- const userSshDir = join7(homedir2(), ".ssh");
13596
- mkdirSync3(userSshDir, { recursive: true, mode: 448 });
13597
- const authKeys = join7(userSshDir, "authorized_keys");
13598
- const existing = existsSync4(authKeys) ? readFileSync3(authKeys, "utf8") : "";
13599
- const next = rewriteHostAuthorizedKeys(existing, pub);
13600
- if (next !== existing) writeFileSync2(authKeys, next, { mode: 384 });
13601
- chmodSync(authKeys, 384);
13602
- return { user, keyPath };
13603
- } catch {
13604
- return null;
13943
+ if (!pub) return { channel: null, issue: { code: "empty-key" } };
13944
+ if (rootUnavailable) {
13945
+ console.warn(
13946
+ " \u26A0 Host control needs root, but this user isn't root and passwordless sudo isn't\n available. Mail and edge host operations will fail. Re-run `openship up` as root\n (or enable passwordless sudo) to fix. Continuing with a limited host channel."
13947
+ );
13948
+ authorizeKeyAt(authKeysPath, pub);
13949
+ return provisioned({ user, keyPath });
13950
+ }
13951
+ if (viaSudo) {
13952
+ if (!authorizeRootKeyViaSudo(pub)) {
13953
+ console.warn(
13954
+ " \u26A0 Could not authorize the host key for root via sudo \u2014 falling back to the\n current user. Root host operations (mail/edge) may fail."
13955
+ );
13956
+ const invoker = userInfo().username;
13957
+ authorizeKeyAt(join6(homedir(), ".ssh", "authorized_keys"), pub);
13958
+ return provisioned({ user: invoker, keyPath });
13959
+ }
13960
+ revokeKeyAt(join6(homedir(), ".ssh", "authorized_keys"));
13961
+ return provisioned({ user, keyPath });
13962
+ }
13963
+ authorizeKeyAt(authKeysPath, pub);
13964
+ return provisioned({ user, keyPath });
13965
+ } catch (err) {
13966
+ return { channel: null, issue: { code: "error", detail: err?.message } };
13605
13967
  }
13606
13968
  }
13969
+ function keygenWhy(r2) {
13970
+ if (r2.error?.code === "ENOENT") {
13971
+ return "ssh-keygen is not installed \u2014 the openssh-client package provides it";
13972
+ }
13973
+ const stderr = (r2.stderr ?? "").trim().split("\n").pop();
13974
+ return stderr || r2.error?.message || `ssh-keygen exited ${r2.status ?? "abnormally"}`;
13975
+ }
13976
+ function probeHostSshd(port) {
13977
+ for (const [cmd, args] of [
13978
+ ["ss", ["-H", "-ltn"]],
13979
+ ["netstat", ["-ltn"]]
13980
+ ]) {
13981
+ const r2 = spawnSync4(cmd, [...args], { encoding: "utf8" });
13982
+ if (r2.status !== 0 || !r2.stdout) continue;
13983
+ return listensOnPort(r2.stdout, port) ? "listening" : "absent";
13984
+ }
13985
+ return "unknown";
13986
+ }
13987
+ function listensOnPort(listing, port) {
13988
+ return listing.split("\n").filter((line) => /\bLISTEN\b/.test(line)).some(
13989
+ (line) => line.trim().split(/\s+/).some((token) => token.endsWith(`:${port}`))
13990
+ );
13991
+ }
13992
+ function carriedHostChannel(prev) {
13993
+ const keyPath = prev.OPENSHIP_HOST_KEY_PATH?.trim();
13994
+ if (!prev.OPENSHIP_HOST_SSH_HOST?.trim() || !keyPath || !existsSync4(keyPath)) return null;
13995
+ return { user: prev.OPENSHIP_HOST_SSH_USER?.trim() || "root", keyPath };
13996
+ }
13997
+ var HOST_ISSUE_INDENT = " ";
13998
+ function hostIssueNote(text) {
13999
+ return wrapText(text, 80).map((l2) => `${HOST_ISSUE_INDENT}${l2}
14000
+ `).join("");
14001
+ }
14002
+ function renderHostChannelIssue(issue, ctx) {
14003
+ const detail = issue.detail ? ` (${issue.detail})` : "";
14004
+ const headline = issue.code === "no-sshd" ? ` \u26A0 Host control is provisioned, but nothing on this host is listening for SSH${detail}.` : ` \u26A0 Host control could NOT be provisioned on this box.`;
14005
+ let cause;
14006
+ switch (issue.code) {
14007
+ case "keygen":
14008
+ cause = `The channel's key could not be generated${detail}.`;
14009
+ break;
14010
+ case "empty-key":
14011
+ cause = "The channel's key was generated but its public half read back empty.";
14012
+ break;
14013
+ case "no-sshd":
14014
+ cause = `The api container now has a key and an address, and will be refused the first time it dials ${ctx.target}:
14015
+ ` + explainHostChannelCause("refused", {
14016
+ target: ctx.target,
14017
+ // Measured here rather than left to core's default, which is Debian's `ssh` —
14018
+ // a unit name that does not exist on the RHEL family, so the remedy printed on
14019
+ // an Amazon Linux box was a command that silently does nothing.
14020
+ sshdEnableHint: thisHost().sshdEnableHint()
14021
+ }).body;
14022
+ break;
14023
+ case "error":
14024
+ cause = `Setting up the channel's key failed${detail}.`;
14025
+ break;
14026
+ }
14027
+ const continuity = issue.code === "no-sshd" ? "" : ctx.kept ? hostIssueNote(
14028
+ "Kept the host channel this install already had in `.env` rather than dropping it: its key is still authorized, so the api container keeps the access it had instead of losing it to this failure."
14029
+ ) : hostIssueNote(
14030
+ "OPENSHIP_HOST_SSH_HOST is therefore unset, and host operations refuse rather than run against the api container's own filesystem."
14031
+ );
14032
+ return `${headline}
14033
+ ` + hostIssueNote(cause) + continuity + hostIssueNote(HOST_CHANNEL_UNAFFECTED) + `${HOST_ISSUE_INDENT}Unavailable until the channel works:
14034
+ ` + HOST_CHANNEL_BLOCKED.map((l2) => `${HOST_ISSUE_INDENT} \xB7 ${l2}
14035
+ `).join("") + hostIssueNote(HOST_CHANNEL_RECHECK);
14036
+ }
13607
14037
  function composeProjectName(prev) {
13608
14038
  if (prev.COMPOSE_PROJECT_NAME) return prev.COMPOSE_PROJECT_NAME;
13609
14039
  return Object.keys(prev).length > 0 ? "compose" : "openship";
13610
14040
  }
13611
14041
  var PGDATA_ROOT = "/var/lib/postgresql/data";
13612
- function resolvePgData(prev) {
13613
- if (prev.OPENSHIP_PGDATA) return prev.OPENSHIP_PGDATA;
13614
- return dbVolumeExists(composeProjectName(prev)) ? PGDATA_ROOT : `${PGDATA_ROOT}/pgdata`;
13615
- }
13616
- function orphanedStackContainers(project) {
13617
- const r2 = spawnSync3(
14042
+ function probePgDataDir(volume) {
14043
+ const r2 = spawnSync4(
13618
14044
  "docker",
13619
14045
  [
13620
- "ps",
13621
- "-a",
13622
- "--format",
13623
- '{{.Names}} {{.Label "com.docker.compose.project"}} {{.Label "com.docker.compose.project.config_files"}}'
14046
+ "run",
14047
+ "--rm",
14048
+ "-v",
14049
+ `${volume}:/from:ro`,
14050
+ "alpine:3",
14051
+ "sh",
14052
+ "-c",
14053
+ `if [ -f /from/PG_VERSION ]; then echo root; elif [ -f /from/pgdata/PG_VERSION ]; then echo sub; elif [ -z "$(ls -A /from 2>/dev/null | grep -v '^lost+found$')" ]; then echo empty; else echo foreign; fi`
13624
14054
  ],
13625
14055
  { encoding: "utf8" }
13626
14056
  );
13627
- if (r2.status !== 0 || !r2.stdout) return [];
13628
- return r2.stdout.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
13629
- const [name, proj, configFiles] = line.split(" ");
13630
- return { name: name ?? "", project: proj ?? "", configFiles: configFiles ?? "" };
13631
- }).filter(
13632
- (c2) => c2.name && c2.project && c2.project !== project && // config_files is a comma-separated list (base + build override).
13633
- c2.configFiles.split(",").some((f2) => f2.trim() === COMPOSE_FILE)
13634
- ).map(({ name, project: proj }) => ({ name, project: proj }));
14057
+ if (r2.status !== 0) return "unknown";
14058
+ const out = `${r2.stdout ?? ""}`.trim();
14059
+ return out === "root" || out === "sub" || out === "empty" || out === "foreign" ? out : "unknown";
14060
+ }
14061
+ function resolvePgData(prev) {
14062
+ const SUB = `${PGDATA_ROOT}/pgdata`;
14063
+ if (prev.OPENSHIP_PGDATA) return prev.OPENSHIP_PGDATA;
14064
+ const volume = survivingDbVolume(prev);
14065
+ if (!volume) return SUB;
14066
+ return probePgDataDir(volume) === "root" ? PGDATA_ROOT : SUB;
14067
+ }
14068
+ function pgDataDetectionRisk(prev) {
14069
+ if (prev.OPENSHIP_PGDATA) return null;
14070
+ const volume = survivingDbVolume(prev);
14071
+ if (!volume) return null;
14072
+ return probePgDataDir(volume) === "foreign" ? { volume } : null;
14073
+ }
14074
+ function composePgDataRisk(_opts = {}) {
14075
+ return pgDataDetectionRisk(readEnvFile());
14076
+ }
14077
+ function renderPgDataRefusal(risk, opts = {}) {
14078
+ const SUB = `${PGDATA_ROOT}/pgdata`;
14079
+ return `
14080
+ ${opts.dryRun ? "A real run would REFUSE here" : "Refusing to continue"}: this install's data volume holds data, but not a Postgres cluster we recognize.
14081
+
14082
+ volume: ${risk.volume}
14083
+ checked: ${PGDATA_ROOT}/PG_VERSION and ${SUB}/PG_VERSION \u2014 neither present
14084
+
14085
+ Guessing where the cluster lives is how a wrong path makes Postgres run initdb over
14086
+ your existing data and orphan it, so nothing has been written. If you know the layout,
14087
+ pin it and re-run:
14088
+
14089
+ echo 'OPENSHIP_PGDATA=${SUB}' >> ${ENV_FILE} # or ${PGDATA_ROOT} if the cluster is at the root
14090
+ `;
14091
+ }
14092
+ var PS_SWEEP_FORMAT = '{{.Names}} {{.Label "com.docker.compose.project"}} {{.Label "com.docker.compose.project.config_files"}} {{.Label "com.docker.compose.service"}}';
14093
+ function parsePsSweep(stdout) {
14094
+ return stdout.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
14095
+ const [name, project, configFiles, service] = line.split(" ");
14096
+ return {
14097
+ name: name ?? "",
14098
+ project: project ?? "",
14099
+ configFiles: configFiles ?? "",
14100
+ service: service ?? ""
14101
+ };
14102
+ });
14103
+ }
14104
+ function psSweepRaw() {
14105
+ const r2 = spawnSync4("docker", ["ps", "-a", "--format", PS_SWEEP_FORMAT], { encoding: "utf8" });
14106
+ return r2.status === 0 && r2.stdout ? r2.stdout : null;
14107
+ }
14108
+ function psSweep() {
14109
+ return parsePsSweep(psSweepRaw() ?? "");
14110
+ }
14111
+ function fromOurComposeFile(row) {
14112
+ return row.configFiles.split(",").some((f2) => f2.trim() === COMPOSE_FILE);
14113
+ }
14114
+ function orphanedStackContainers(project) {
14115
+ return psSweep().filter((c2) => c2.name && c2.project && c2.project !== project && fromOurComposeFile(c2)).map(({ name, project: proj }) => ({ name, project: proj }));
14116
+ }
14117
+ function adoptedStackOrigin(psStdout, project) {
14118
+ const rows = parsePsSweep(psStdout).filter((c2) => c2.name && c2.project === project);
14119
+ if (rows.length === 0 || rows.some(fromOurComposeFile)) return null;
14120
+ return {
14121
+ project,
14122
+ configFiles: rows.map((c2) => c2.configFiles).find(Boolean) ?? "",
14123
+ apiContainer: rows.find((c2) => c2.service === "api")?.name || `${project}-api-1`
14124
+ };
14125
+ }
14126
+ function adoptedStack(project) {
14127
+ const sweep = psSweepRaw();
14128
+ return sweep ? adoptedStackOrigin(sweep, project) : null;
13635
14129
  }
13636
14130
  function reconcileDbPassword(user, password) {
13637
14131
  if (!password) return;
13638
14132
  if (compose(["up", "-d", "--wait", "postgres"], { quiet: true }) !== 0) return;
13639
- const attempt = (asRole) => spawnSync3(
14133
+ const attempt = (asRole) => spawnSync4(
13640
14134
  "docker",
13641
14135
  [
13642
14136
  "compose",
@@ -13698,7 +14192,21 @@ function removeOrphanedStack(project) {
13698
14192
  console.log(
13699
14193
  ` Removing ${orphans.length} container(s) from a previous stack (project "${projects}") \u2014 they would hold the ports this stack needs. Volumes are kept.`
13700
14194
  );
13701
- spawnSync3("docker", ["rm", "-f", ...orphans.map((o2) => o2.name)], { stdio: "ignore" });
14195
+ spawnSync4("docker", ["rm", "-f", ...orphans.map((o2) => o2.name)], { stdio: "ignore" });
14196
+ }
14197
+ function edgeNameNeedsReclaim(psStdout, project) {
14198
+ const row = parsePsSweep(psStdout).find((c2) => c2.name === "openship-edge");
14199
+ if (!row) return false;
14200
+ return !(row.project === project && fromOurComposeFile(row));
14201
+ }
14202
+ function reconcileEdgeContainerName(project) {
14203
+ const sweep = psSweepRaw();
14204
+ if (!sweep) return;
14205
+ if (!edgeNameNeedsReclaim(sweep, project)) return;
14206
+ console.log(
14207
+ " Replacing an unmanaged `openship-edge` container so compose can own it (config is on the host, so no routes are lost)."
14208
+ );
14209
+ spawnSync4("docker", ["rm", "-f", "openship-edge"], { stdio: "ignore" });
13702
14210
  }
13703
14211
  function migrateLegacyEdgeVolumes(project) {
13704
14212
  const pairs = [
@@ -13707,18 +14215,18 @@ function migrateLegacyEdgeVolumes(project) {
13707
14215
  { volume: `${project}_openship_acme`, host: EDGE_ACME_HOST_DIR },
13708
14216
  { volume: `${project}_openship_static`, host: "/opt/openship/static" }
13709
14217
  ];
13710
- const ls = spawnSync3("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
14218
+ const ls = spawnSync4("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
13711
14219
  if (ls.status !== 0 || !ls.stdout) return;
13712
14220
  const existing = new Set(ls.stdout.split("\n").map((l2) => l2.trim()).filter(Boolean));
13713
14221
  for (const { volume, host } of pairs) {
13714
14222
  if (!existing.has(volume)) continue;
13715
14223
  mkdirSync3(host, { recursive: true });
13716
- const alreadyThere = spawnSync3("sh", ["-c", `ls -A ${JSON.stringify(host)} 2>/dev/null | head -1`], {
14224
+ const alreadyThere = spawnSync4("sh", ["-c", `ls -A ${JSON.stringify(host)} 2>/dev/null | head -1`], {
13717
14225
  encoding: "utf8"
13718
14226
  });
13719
14227
  if (alreadyThere.stdout?.trim()) continue;
13720
14228
  console.log(` Moving edge state from volume ${volume} onto ${host}...`);
13721
- const copy = spawnSync3(
14229
+ const copy = spawnSync4(
13722
14230
  "docker",
13723
14231
  [
13724
14232
  "run",
@@ -13743,7 +14251,7 @@ function migrateLegacyEdgeVolumes(project) {
13743
14251
  }
13744
14252
  }
13745
14253
  function warnOrphanedVolumes(project) {
13746
- const r2 = spawnSync3("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
14254
+ const r2 = spawnSync4("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
13747
14255
  if (r2.status !== 0 || !r2.stdout) return;
13748
14256
  const others = new Set(
13749
14257
  r2.stdout.split("\n").map((l2) => l2.trim()).filter((l2) => l2.endsWith("_openship_sites")).map((l2) => l2.slice(0, -"_openship_sites".length)).filter((p2) => p2 && p2 !== project)
@@ -13771,6 +14279,52 @@ var ACME_ENV_KEYS = [
13771
14279
  "OPENSHIP_ACME_CA_BUNDLE",
13772
14280
  "OPENSHIP_ACME_TOS_AGREED"
13773
14281
  ];
14282
+ function hostGatewayEnv(prev) {
14283
+ const value = process.env.OPENSHIP_HOST_GATEWAY?.trim() || prev.OPENSHIP_HOST_GATEWAY?.trim();
14284
+ return value ? [`OPENSHIP_HOST_GATEWAY=${value}`] : [];
14285
+ }
14286
+ function rootlessSocketCandidates() {
14287
+ const runtimeDir = process.env.XDG_RUNTIME_DIR?.trim();
14288
+ const sudoUid = process.env.SUDO_UID?.trim();
14289
+ let uid;
14290
+ try {
14291
+ uid = userInfo().uid;
14292
+ } catch {
14293
+ }
14294
+ return [
14295
+ ...runtimeDir ? [join6(runtimeDir, "docker.sock")] : [],
14296
+ ...sudoUid ? [`/run/user/${sudoUid}/docker.sock`] : [],
14297
+ ...uid !== void 0 ? [`/run/user/${uid}/docker.sock`] : []
14298
+ ];
14299
+ }
14300
+ function detectDockerSocketPath() {
14301
+ const fromClient = resolveLocalDockerSocketPath(void 0, process.env);
14302
+ if (fromClient !== DEFAULT_DOCKER_SOCKET_PATH) return fromClient;
14303
+ if (existsSync4(DEFAULT_DOCKER_SOCKET_PATH)) return DEFAULT_DOCKER_SOCKET_PATH;
14304
+ return rootlessSocketCandidates().find((p2) => existsSync4(p2)) ?? DEFAULT_DOCKER_SOCKET_PATH;
14305
+ }
14306
+ function resolveDockerSocket(prev) {
14307
+ const pinned = process.env.OPENSHIP_DOCKER_SOCKET?.trim() || prev.OPENSHIP_DOCKER_SOCKET?.trim();
14308
+ return pinned ? { path: pinned, pinned: true } : { path: detectDockerSocketPath(), pinned: false };
14309
+ }
14310
+ function dockerSocketEnv(prev) {
14311
+ const { path, pinned } = resolveDockerSocket(prev);
14312
+ return pinned || path !== DEFAULT_DOCKER_SOCKET_PATH ? [`OPENSHIP_DOCKER_SOCKET=${path}`] : [];
14313
+ }
14314
+ function warnMissingDockerSocket(path) {
14315
+ console.warn(
14316
+ `
14317
+ ! The Docker socket this install mounts into the api isn't there:
14318
+ ${path}
14319
+ Docker will create that path as an empty DIRECTORY, so the stack will come up
14320
+ and then fail every container operation with a transport error naming neither
14321
+ this path nor the cause (#482). Point it at the real socket:
14322
+ docker context inspect --format '{{.Endpoints.docker.Host}}'
14323
+ OPENSHIP_DOCKER_SOCKET=/run/user/$(id -u)/docker.sock openship up --compose
14324
+ (a rootless daemon keeps its socket under the daemon user's runtime dir).
14325
+ `
14326
+ );
14327
+ }
13774
14328
  function renderAcmeEnv(prev) {
13775
14329
  return ACME_ENV_KEYS.flatMap((key) => {
13776
14330
  const value = process.env[key]?.trim() || prev[key]?.trim();
@@ -13800,7 +14354,19 @@ function resolveEnvConfig(prev, opts) {
13800
14354
  registry: keepConfig(prev, "OPENSHIP_IMAGE_REGISTRY", opts.registry) ?? DEFAULT_IMAGE_REGISTRY,
13801
14355
  // Tri-state: the flag is absent on a plain re-run, so fall back to what the
13802
14356
  // install chose rather than silently re-granting host control.
13803
- hostControl: opts.noHostControl === void 0 ? prev.OPENSHIP_HOST_CONTROL !== "false" : !opts.noHostControl
14357
+ hostControl: opts.noHostControl === void 0 ? prev.OPENSHIP_HOST_CONTROL !== "false" : !opts.noHostControl,
14358
+ // Sticky too: `--mail` is only the install-time default, so a plain re-run
14359
+ // (or `openship update`) must not hand a mail box back the platform shell.
14360
+ // Turning it OFF is the dashboard's job — Settings → Instance stores the mode
14361
+ // in the database, which wins over this env var either way.
14362
+ productMode: opts.mail || keepConfig(prev, "OPENSHIP_PRODUCT") === "mail" ? "mail" : "platform",
14363
+ // Sticky like every other operator choice here. These were literals in
14364
+ // `renderEnv`, so a box where `host.docker.internal:22` is simply not the
14365
+ // right address — rootless Docker, sshd on another port — had nowhere to say
14366
+ // so, and every `openship up` overwrote a hand-edited `.env` back to the
14367
+ // value that didn't work (#490/#482).
14368
+ hostSshHost: keepConfig(prev, "OPENSHIP_HOST_SSH_HOST", opts.hostSshHost) ?? HOST_CHANNEL_DEFAULT_HOST,
14369
+ hostSshPort: keepConfig(prev, "OPENSHIP_HOST_SSH_PORT", opts.hostSshPort) ?? String(HOST_CHANNEL_DEFAULT_PORT)
13804
14370
  };
13805
14371
  }
13806
14372
  function toPort(value) {
@@ -13814,6 +14380,20 @@ function composeEnvPorts() {
13814
14380
  function composeBindAddr() {
13815
14381
  return readEnvFile().OPENSHIP_BIND_ADDR?.trim() || "127.0.0.1";
13816
14382
  }
14383
+ function composeHostChannel() {
14384
+ const env = readEnvFile();
14385
+ const host = env.OPENSHIP_HOST_SSH_HOST?.trim();
14386
+ if (!host) return null;
14387
+ return {
14388
+ host,
14389
+ port: toPort(env.OPENSHIP_HOST_SSH_PORT) ?? 22,
14390
+ user: env.OPENSHIP_HOST_SSH_USER?.trim() || "root"
14391
+ };
14392
+ }
14393
+ function composeHostChannelExpected() {
14394
+ if (!existsSync4(COMPOSE_FILE)) return false;
14395
+ return readEnvFile().OPENSHIP_HOST_CONTROL !== "false";
14396
+ }
13817
14397
  function composeTrustedOriginUrls() {
13818
14398
  const env = readEnvFile();
13819
14399
  return [env.OPENSHIP_PUBLIC_URL, env.OPENSHIP_EXTRA_TRUSTED_ORIGINS].filter(
@@ -13821,7 +14401,7 @@ function composeTrustedOriginUrls() {
13821
14401
  );
13822
14402
  }
13823
14403
  function composeHeldPorts() {
13824
- const r2 = spawnSync3(
14404
+ const r2 = spawnSync4(
13825
14405
  "docker",
13826
14406
  ["ps", "--format", '{{.Label "com.docker.compose.project.config_files"}} {{.Ports}}'],
13827
14407
  { encoding: "utf8" }
@@ -13847,8 +14427,24 @@ async function resolveComposePorts(prefs, opts = {}) {
13847
14427
  opts
13848
14428
  );
13849
14429
  }
13850
- function renderEnv(opts, host, cfg) {
13851
- const prev = readEnvFile();
14430
+ var OMITTABLE_MANAGED_KEYS = /* @__PURE__ */ new Set([
14431
+ "OPENSHIP_HOST_SSH_HOST",
14432
+ "OPENSHIP_HOST_SSH_USER",
14433
+ "OPENSHIP_HOST_SSH_PORT",
14434
+ "OPENSHIP_HOST_SSH_KEY",
14435
+ "OPENSHIP_HOST_KEY_PATH",
14436
+ "OPENSHIP_HOST_GATEWAY",
14437
+ "TRUST_PROXY"
14438
+ ]);
14439
+ var envKeyOf = (line) => line.match(/^([A-Z0-9_]+)=/)?.[1];
14440
+ function operatorEnvPassthrough(managed, prev) {
14441
+ return Object.entries(prev).filter(([key]) => !managed.has(key) && !OMITTABLE_MANAGED_KEYS.has(key)).map(([key, value]) => `${key}=${value}`);
14442
+ }
14443
+ var PRESERVED_ENV_HEADER = "# Preserved from your existing .env \u2014 set by you, not by `openship up` (#485).";
14444
+ function resolveImageVersion(opts) {
14445
+ return opts.version?.trim() || process.env.OPENSHIP_VERSION?.trim() || (true ? "0.6.5" : "latest");
14446
+ }
14447
+ function managedEnvLines(opts, host, cfg, prev) {
13852
14448
  const lines = [
13853
14449
  "# Managed by `openship up`. Secrets are generated once and preserved.",
13854
14450
  // Do NOT edit on a live install — it is the volume prefix (see composeProjectName).
@@ -13859,8 +14455,17 @@ function renderEnv(opts, host, cfg) {
13859
14455
  // Read by createHostExecutor (throws when false) and by the servers list
13860
14456
  // (hides the local row). Written explicitly so the policy is visible in .env.
13861
14457
  `OPENSHIP_HOST_CONTROL=${cfg.hostControl ? "true" : "false"}`,
14458
+ // The host side of the api's docker socket mount, when it isn't the rootful
14459
+ // default. Unlike the host channel above this is NOT optional plumbing — every
14460
+ // deploy goes through it — so it is resolved on every run, host control or not.
14461
+ ...dockerSocketEnv(prev),
14462
+ // Which shell the dashboard defaults to. Emitted on EVERY run, including the
14463
+ // "platform" default: that keeps the key permanently in the managed set, so
14464
+ // the #485 passthrough can never mistake it for operator config (which would
14465
+ // pin it and make the Settings → Instance toggle look broken after an `up`).
14466
+ `OPENSHIP_PRODUCT=${cfg.productMode}`,
13862
14467
  `OPENSHIP_IMAGE_REGISTRY=${cfg.registry}`,
13863
- `OPENSHIP_VERSION=${opts.version || (true ? "0.6.0" : "latest")}`,
14468
+ `OPENSHIP_VERSION=${resolveImageVersion(opts)}`,
13864
14469
  `POSTGRES_PASSWORD=${keepSecret(prev, "POSTGRES_PASSWORD")}`,
13865
14470
  // Pinned once (see resolvePgData): fresh install → subdir, existing volume → root.
13866
14471
  `OPENSHIP_PGDATA=${resolvePgData(prev)}`,
@@ -13885,19 +14490,33 @@ function renderEnv(opts, host, cfg) {
13885
14490
  if (cfg.trustProxy) lines.push("TRUST_PROXY=true");
13886
14491
  if (host) {
13887
14492
  lines.push(
13888
- "OPENSHIP_HOST_SSH_HOST=host.docker.internal",
14493
+ `OPENSHIP_HOST_SSH_HOST=${cfg.hostSshHost}`,
13889
14494
  `OPENSHIP_HOST_SSH_USER=${host.user}`,
13890
- "OPENSHIP_HOST_SSH_PORT=22",
14495
+ `OPENSHIP_HOST_SSH_PORT=${cfg.hostSshPort}`,
13891
14496
  "OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key",
13892
- `OPENSHIP_HOST_KEY_PATH=${host.keyPath}`
14497
+ `OPENSHIP_HOST_KEY_PATH=${host.keyPath}`,
14498
+ ...hostGatewayEnv(prev)
13893
14499
  );
13894
14500
  }
13895
- return lines.join("\n") + "\n";
14501
+ return lines;
14502
+ }
14503
+ function renderEnvAndCarried(opts, host, cfg, prev) {
14504
+ const lines = managedEnvLines(opts, host, cfg, prev);
14505
+ const managed = new Set(lines.map(envKeyOf).filter((k2) => !!k2));
14506
+ const preserved = operatorEnvPassthrough(managed, prev);
14507
+ const body = preserved.length ? [...lines, "", PRESERVED_ENV_HEADER, ...preserved] : lines;
14508
+ return {
14509
+ text: body.join("\n") + "\n",
14510
+ carried: preserved.map((line) => line.slice(0, line.indexOf("=")))
14511
+ };
14512
+ }
14513
+ function renderEnv(opts, host, cfg) {
14514
+ return renderEnvAndCarried(opts, host, cfg, readEnvFile()).text;
13896
14515
  }
13897
14516
  function sourceBuildDir() {
13898
14517
  const marker = readSourceInstall();
13899
14518
  if (!marker?.dir) return null;
13900
- const hasAll = BUILT_SERVICES.every((s2) => existsSync4(join7(marker.dir, s2.dockerfile)));
14519
+ const hasAll = BUILT_SERVICES.every((s2) => existsSync4(join6(marker.dir, s2.dockerfile)));
13901
14520
  return hasAll ? marker.dir : null;
13902
14521
  }
13903
14522
  function renderBuildOverride(repoDir) {
@@ -13916,19 +14535,48 @@ function materialize(opts) {
13916
14535
  mkdirSync3(COMPOSE_DIR, { recursive: true, mode: 448 });
13917
14536
  const prev = readEnvFile();
13918
14537
  const cfg = resolveEnvConfig(prev, opts);
13919
- const host = provisionHostSshChannel(cfg.hostControl);
14538
+ const provision = provisionHostSshChannel(cfg);
14539
+ let host = provision.channel;
14540
+ let kept = false;
14541
+ if (!host && provision.issue) {
14542
+ const previousChannel = carriedHostChannel(prev);
14543
+ if (previousChannel) {
14544
+ host = previousChannel;
14545
+ kept = true;
14546
+ }
14547
+ }
14548
+ if (!cfg.hostControl) retireHostSshChannel(prev);
14549
+ if (provision.issue && !opts.alreadyFetched) {
14550
+ console.warn(
14551
+ renderHostChannelIssue(provision.issue, {
14552
+ target: `${cfg.hostSshHost}:${cfg.hostSshPort}`,
14553
+ kept
14554
+ })
14555
+ );
14556
+ }
14557
+ const socket = resolveDockerSocket(prev);
14558
+ if (!opts.alreadyFetched && !existsSync4(socket.path)) warnMissingDockerSocket(socket.path);
13920
14559
  let before = "";
13921
14560
  try {
13922
14561
  before = readFileSync3(ENV_FILE, "utf8");
13923
14562
  } catch {
13924
14563
  }
13925
- const rendered = renderEnv(opts, host, cfg);
14564
+ const { text: rendered, carried } = renderEnvAndCarried(opts, host, cfg, prev);
13926
14565
  writeFileSync2(COMPOSE_FILE, COMPOSE_YAML);
13927
- writeFileSync2(ENV_FILE, rendered, { mode: 384 });
14566
+ writeEnvFile(rendered, before);
14567
+ if (carried.length) {
14568
+ console.log(` Kept ${carried.length} operator-set .env variable(s): ${carried.join(", ")}`);
14569
+ }
13928
14570
  const buildDir = opts.build === false ? null : sourceBuildDir();
13929
14571
  if (buildDir) writeFileSync2(BUILD_FILE, renderBuildOverride(buildDir));
13930
14572
  return { buildDir, cfg, envChanged: before !== "" && rendered !== before };
13931
14573
  }
14574
+ function writeEnvFile(rendered, before) {
14575
+ if (rendered === before) return;
14576
+ if (before) writeFileSync2(ENV_BACKUP_FILE, before, { mode: 384 });
14577
+ writeFileSync2(ENV_TMP_FILE, rendered, { mode: 384 });
14578
+ renameSync(ENV_TMP_FILE, ENV_FILE);
14579
+ }
13932
14580
  var SECRET_ENV_KEY = /(PASSWORD|SECRET|TOKEN|HMAC_KEY)$/;
13933
14581
  function composePlan(opts) {
13934
14582
  const prev = readEnvFile();
@@ -13941,13 +14589,14 @@ function composePlan(opts) {
13941
14589
  const at2 = line.indexOf("=");
13942
14590
  if (at2 < 1 || line.startsWith("#")) continue;
13943
14591
  const key = line.slice(0, at2);
14592
+ const value = line.slice(at2 + 1);
13944
14593
  if (!SECRET_ENV_KEY.test(key)) {
13945
- settings.push({ key, value: line.slice(at2 + 1) });
14594
+ settings.push({ key, value });
13946
14595
  continue;
13947
14596
  }
13948
- const minted = !prev[key];
13949
- if (minted) newSecrets.push(key);
13950
- settings.push({ key, value: minted ? "<generated on this run>" : "<preserved>" });
14597
+ const preserved = !!prev[key] && prev[key] === value;
14598
+ if (!preserved) newSecrets.push(key);
14599
+ settings.push({ key, value: preserved ? "<preserved>" : "<generated on this run>" });
13951
14600
  }
13952
14601
  return {
13953
14602
  composeFile: COMPOSE_FILE,
@@ -13957,6 +14606,8 @@ function composePlan(opts) {
13957
14606
  yaml: COMPOSE_YAML,
13958
14607
  settings,
13959
14608
  newSecrets,
14609
+ secretRotation: opts.resetSecrets ? null : secretRotationRisk(prev),
14610
+ pgDataRisk: pgDataDetectionRisk(prev),
13960
14611
  existing: Object.keys(prev).length > 0,
13961
14612
  mountDirs: EDGE_CONTAINER_MOUNTS.map((m2) => m2.host),
13962
14613
  buildDir,
@@ -13964,7 +14615,7 @@ function composePlan(opts) {
13964
14615
  };
13965
14616
  }
13966
14617
  function dbVolumeExists(project) {
13967
- const r2 = spawnSync3("docker", ["volume", "inspect", `${project}_postgres_data`], {
14618
+ const r2 = spawnSync4("docker", ["volume", "inspect", `${project}_postgres_data`], {
13968
14619
  stdio: "ignore"
13969
14620
  });
13970
14621
  return r2.status === 0;
@@ -13972,18 +14623,69 @@ function dbVolumeExists(project) {
13972
14623
  function compose(args, opts) {
13973
14624
  const files = ["-f", COMPOSE_FILE];
13974
14625
  if (opts?.withBuildOverride) files.push("-f", BUILD_FILE);
13975
- const r2 = spawnSync3("docker", ["compose", ...files, ...args], {
14626
+ const r2 = spawnSync4("docker", ["compose", ...files, ...args], {
13976
14627
  cwd: COMPOSE_DIR,
13977
14628
  stdio: opts?.quiet ? "ignore" : "inherit"
13978
14629
  });
13979
14630
  return r2.status ?? 1;
13980
14631
  }
14632
+ function missingPinnedImages(registry, version) {
14633
+ const missing = [];
14634
+ for (const { service } of BUILT_SERVICES) {
14635
+ const ref = `${registry}/openship-${service}:${version}`;
14636
+ const r2 = spawnSync4("docker", ["manifest", "inspect", ref], { encoding: "utf8" });
14637
+ if (r2.status === 0) continue;
14638
+ const stderr = `${r2.stderr ?? ""}`.toLowerCase();
14639
+ if (/manifest unknown|no such manifest|manifest for .+ not found/.test(stderr)) {
14640
+ missing.push(ref);
14641
+ continue;
14642
+ }
14643
+ return null;
14644
+ }
14645
+ return missing;
14646
+ }
14647
+ function pinnedImagesReady(opts) {
14648
+ if (sourceBuildDir()) return true;
14649
+ const prev = readEnvFile();
14650
+ const version = resolveImageVersion(opts);
14651
+ const missing = missingPinnedImages(resolveEnvConfig(prev, opts).registry, version);
14652
+ if (!missing || missing.length === 0) return true;
14653
+ const prevVersion = prev.OPENSHIP_VERSION?.trim();
14654
+ const out = [
14655
+ "",
14656
+ ` The pinned image tag \`${version}\` isn't in the registry yet:`,
14657
+ ...missing.map((ref) => ` ${ref}`),
14658
+ "",
14659
+ " The CLI reached npm before its container images finished publishing \u2014 a",
14660
+ " release-ordering race (#486). Nothing on this box was changed.",
14661
+ "",
14662
+ " Any one of these gets you moving:",
14663
+ " \u2022 Wait a few minutes and re-run \u2014 the images usually land right after a release.",
14664
+ " \u2022 Pin a tag that exists:",
14665
+ " openship up --compose --image-version <tag>",
14666
+ " OPENSHIP_VERSION=<tag> openship up --compose"
14667
+ ];
14668
+ if (prevVersion && prevVersion !== version) {
14669
+ out.push(` \u2022 Your current install runs \`${prevVersion}\`, a tag known good on this box.`);
14670
+ }
14671
+ out.push("");
14672
+ console.error(out.join("\n"));
14673
+ return false;
14674
+ }
13981
14675
  function composePrefetch(opts) {
13982
- const { buildDir } = materialize(opts);
14676
+ const rotation = composeSecretRotationRisk(opts);
14677
+ if (rotation) {
14678
+ console.error(renderSecretRotationRefusal(rotation));
14679
+ return { ok: false, envChanged: false };
14680
+ }
14681
+ const { buildDir, envChanged } = materialize(opts);
13983
14682
  if (buildDir) {
13984
- return compose(["pull", "postgres", "redis"], { withBuildOverride: true }) === 0 && compose(["build"], { withBuildOverride: true }) === 0;
14683
+ return {
14684
+ ok: compose(["pull", "postgres", "redis"], { withBuildOverride: true }) === 0 && compose(["build"], { withBuildOverride: true }) === 0,
14685
+ envChanged
14686
+ };
13985
14687
  }
13986
- return compose(["pull"]) === 0;
14688
+ return { ok: compose(["pull"]) === 0, envChanged };
13987
14689
  }
13988
14690
  function ensureEdgeMountDirs() {
13989
14691
  const failed = [];
@@ -13997,15 +14699,32 @@ function ensureEdgeMountDirs() {
13997
14699
  return failed;
13998
14700
  }
13999
14701
  function isRootlessDocker() {
14000
- const r2 = spawnSync3("docker", ["info", "-f", "{{println .SecurityOptions}}"], {
14702
+ const r2 = spawnSync4("docker", ["info", "-f", "{{println .SecurityOptions}}"], {
14001
14703
  encoding: "utf8"
14002
14704
  });
14003
14705
  return r2.status === 0 && /rootless/i.test(r2.stdout ?? "");
14004
14706
  }
14005
14707
  async function composeUp(opts) {
14006
- const { buildDir, cfg, envChanged } = materialize(opts);
14708
+ if (!opts.alreadyFetched && !pinnedImagesReady(opts)) {
14709
+ const pre = resolveEnvConfig(readEnvFile(), opts);
14710
+ return { ok: false, apiPort: pre.apiPort, dashPort: pre.dashPort };
14711
+ }
14712
+ const rotation = composeSecretRotationRisk(opts);
14713
+ if (rotation) {
14714
+ const pre = resolveEnvConfig(readEnvFile(), opts);
14715
+ console.error(renderSecretRotationRefusal(rotation));
14716
+ return { ok: false, refused: true, apiPort: pre.apiPort, dashPort: pre.dashPort };
14717
+ }
14718
+ const pgData = composePgDataRisk(opts);
14719
+ if (pgData) {
14720
+ const pre = resolveEnvConfig(readEnvFile(), opts);
14721
+ console.error(renderPgDataRefusal(pgData));
14722
+ return { ok: false, refused: true, apiPort: pre.apiPort, dashPort: pre.dashPort };
14723
+ }
14724
+ const { buildDir, cfg, envChanged: rendered } = materialize(opts);
14007
14725
  const apiPort = cfg.apiPort;
14008
14726
  const dashPort = cfg.dashPort;
14727
+ const envChanged = opts.envChanged ?? rendered;
14009
14728
  const up = (extra = {}) => compose(
14010
14729
  envChanged ? ["up", "-d", "--force-recreate", ...ENV_CONSUMING_SERVICES] : ["up", "-d"],
14011
14730
  extra
@@ -14013,6 +14732,7 @@ async function composeUp(opts) {
14013
14732
  const env = readEnvFile();
14014
14733
  const project = env.COMPOSE_PROJECT_NAME || "openship";
14015
14734
  removeOrphanedStack(project);
14735
+ reconcileEdgeContainerName(project);
14016
14736
  migrateLegacyEdgeVolumes(project);
14017
14737
  warnOrphanedVolumes(project);
14018
14738
  const unmakeableMounts = ensureEdgeMountDirs();
@@ -14079,7 +14799,7 @@ function composeUninstall(opts = {}) {
14079
14799
  const version = env.OPENSHIP_VERSION || "latest";
14080
14800
  for (const { service } of BUILT_SERVICES) {
14081
14801
  const ref = `${registry}/openship-${service}:${version}`;
14082
- if (spawnSync3("docker", ["image", "rm", "-f", ref], { stdio: "ignore" }).status === 0) {
14802
+ if (spawnSync4("docker", ["image", "rm", "-f", ref], { stdio: "ignore" }).status === 0) {
14083
14803
  removedImages.push(ref);
14084
14804
  }
14085
14805
  }
@@ -14087,14 +14807,15 @@ function composeUninstall(opts = {}) {
14087
14807
  return { ok, removedImages };
14088
14808
  }
14089
14809
  async function composeUpdate(version) {
14090
- if (!existsSync4(COMPOSE_FILE)) return false;
14091
- return (await composeUp(version ? { version } : {})).ok;
14810
+ if (!existsSync4(COMPOSE_FILE)) return { applied: false, refused: false };
14811
+ const r2 = await composeUp(version ? { version } : {});
14812
+ return { applied: r2.ok, refused: !!r2.refused };
14092
14813
  }
14093
14814
  function composePs() {
14094
14815
  return compose(["ps"]);
14095
14816
  }
14096
14817
  function composeRunning() {
14097
- const r2 = spawnSync3(
14818
+ const r2 = spawnSync4(
14098
14819
  "docker",
14099
14820
  ["compose", "-f", COMPOSE_FILE, "ps", "--status", "running", "-q"],
14100
14821
  { cwd: COMPOSE_DIR, encoding: "utf8" }
@@ -14102,6 +14823,7 @@ function composeRunning() {
14102
14823
  return r2.status === 0 && r2.stdout.trim().length > 0;
14103
14824
  }
14104
14825
  function composeStart() {
14826
+ reconcileEdgeContainerName(readEnvFile().COMPOSE_PROJECT_NAME || "openship");
14105
14827
  return compose(["up", "-d"], { quiet: true }) === 0;
14106
14828
  }
14107
14829
  function composeRestart() {
@@ -14110,112 +14832,14 @@ function composeRestart() {
14110
14832
  function composeInternalToken() {
14111
14833
  return readEnvFile().INTERNAL_TOKEN ?? null;
14112
14834
  }
14113
-
14114
- // src/lib/edge-import.ts
14115
- async function waitForApiHealth(port, tries) {
14116
- for (let i2 = 0; i2 < tries; i2++) {
14117
- try {
14118
- const r2 = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(2e3) });
14119
- if (r2.ok) return true;
14120
- } catch {
14121
- }
14122
- await new Promise((r2) => setTimeout(r2, 1e3));
14123
- }
14124
- return false;
14125
- }
14126
- async function waitForEdgeRunning(tries) {
14127
- const exec = new LocalExecutor();
14128
- for (let i2 = 0; i2 < tries; i2++) {
14129
- const out = await exec.exec(`docker inspect -f '{{.State.Running}}' ${EDGE_CONTAINER_NAME}`).catch(() => "");
14130
- if (out.trim() === "true") return true;
14131
- await new Promise((r2) => setTimeout(r2, 1e3));
14132
- }
14133
- return false;
14134
- }
14135
- async function importMigratedSites(apiPort, sites, certPems, staticRootOverrides) {
14136
- const token = composeInternalToken();
14137
- if (!token) {
14138
- const error = "couldn't read the stack's internal token";
14139
- console.log(chalk.yellow(` Skipping site import \u2014 ${error}. Re-run to retry.
14140
- `));
14141
- return { ok: false, registered: [], error };
14142
- }
14143
- const spinner = ora(
14144
- `Importing ${sites.length} migrated site${sites.length === 1 ? "" : "s"} into the edge\u2026`
14145
- ).start();
14146
- if (!await waitForApiHealth(apiPort, 60)) {
14147
- const error = "API didn't become healthy in time";
14148
- spinner.warn(`${error} \u2014 migrated sites not imported. Re-run \`openship up\` to retry.`);
14149
- return { ok: false, registered: [], error };
14150
- }
14151
- if (!await waitForEdgeRunning(60)) {
14152
- const error = `the ${EDGE_CONTAINER_NAME} container isn't running yet`;
14153
- spinner.warn(`${error} \u2014 migrated sites not imported. Re-run \`openship up\` to retry.`);
14154
- return { ok: false, registered: [], error };
14155
- }
14156
- try {
14157
- const r2 = await fetch(`http://127.0.0.1:${apiPort}/api/system/edge/import-sites`, {
14158
- method: "POST",
14159
- headers: { "Content-Type": "application/json", "X-Internal-Token": token },
14160
- body: JSON.stringify({ sites, certPems, staticRootOverrides }),
14161
- signal: AbortSignal.timeout(12e4)
14162
- });
14163
- const data = await r2.json().catch(() => ({}));
14164
- if (!r2.ok) {
14165
- const error = data.error ?? `HTTP ${r2.status}`;
14166
- spinner.warn(`Site import failed: ${error}. Re-run to retry.`);
14167
- return { ok: false, registered: [], error };
14168
- }
14169
- const registered = data.registered ?? [];
14170
- const warnings = data.warnings ?? [];
14171
- const missed = sites.length - registered.length;
14172
- if (sites.length > 0 && registered.length === 0) {
14173
- spinner.fail(
14174
- `Migrated NONE of the ${sites.length} site${sites.length === 1 ? "" : "s"} \u2014 Openship holds :80/:443 and ${sites.length === 1 ? "that hostname is" : "those hostnames are"} NOT being served.`
14175
- );
14176
- for (const w2 of warnings.slice(0, 8)) console.log(chalk.red(` \u2022 ${w2}`));
14177
- const edgeLog = await edgeCrashReason(new LocalExecutor());
14178
- if (edgeLog) {
14179
- console.log(chalk.red(`
14180
- The edge container is not running. Its log says:`));
14181
- console.log(chalk.red(` ${edgeLog}`));
14182
- }
14183
- console.log(
14184
- chalk.yellow(
14185
- `
14186
- Retry the import with \`openship up\` once the cause above is fixed, or put your
14187
- previous proxy back: docker stop ${EDGE_CONTAINER_NAME} && sudo systemctl enable --now nginx
14188
- `
14189
- )
14190
- );
14191
- return { ok: false, registered, error: warnings[0] ?? "no sites were registered" };
14192
- }
14193
- if (missed > 0) {
14194
- spinner.warn(
14195
- `Migrated ${registered.length}/${sites.length} sites \u2014 ${missed} not served (see below).`
14196
- );
14197
- for (const w2 of warnings.slice(0, 8)) console.log(chalk.yellow(` \u2022 ${w2}`));
14198
- return { ok: false, registered, error: `${missed} site(s) not registered` };
14199
- }
14200
- spinner.succeed(
14201
- `Migrated ${registered.length} site${registered.length === 1 ? "" : "s"} into Openship's edge.`
14202
- );
14203
- for (const w2 of warnings.slice(0, 8)) console.log(chalk.dim(` \u2022 ${w2}`));
14204
- return { ok: true, registered };
14205
- } catch (e2) {
14206
- const error = e2.message;
14207
- spinner.warn(`Site import failed: ${error}. Re-run to retry.`);
14208
- return { ok: false, registered: [], error };
14209
- }
14210
- }
14835
+ var composePaths = {
14836
+ dir: COMPOSE_DIR,
14837
+ file: COMPOSE_FILE,
14838
+ env: ENV_FILE,
14839
+ envBackup: ENV_BACKUP_FILE
14840
+ };
14211
14841
 
14212
14842
  export {
14213
- OS_DIR,
14214
- IS_ALT_HOME,
14215
- DATA_DIR,
14216
- LOG_DIR,
14217
- AUTH_SECRET_FILE,
14218
- INTERNAL_TOKEN_FILE,
14219
14843
  PORTS_FILE,
14220
14844
  saveInstanceUrl,
14221
14845
  readInstanceUrl,
@@ -14224,6 +14848,8 @@ export {
14224
14848
  storedDashboardPort,
14225
14849
  portMoveNotice,
14226
14850
  resolvePorts,
14851
+ thisHost,
14852
+ startUnitHint,
14227
14853
  DEFAULT_REPO,
14228
14854
  SOURCE_CHECKOUT_DIR,
14229
14855
  SOURCE_DIST_DIR,
@@ -14234,14 +14860,20 @@ export {
14234
14860
  remoteSha,
14235
14861
  rebuildFromSource,
14236
14862
  readInstallMethod,
14237
- dockerGap,
14238
14863
  hasDockerCompose,
14239
14864
  dockerInstallPreview,
14240
14865
  ensureDocker,
14866
+ composeSecretRotationRisk,
14867
+ renderSecretRotationRefusal,
14868
+ composePgDataRisk,
14869
+ renderPgDataRefusal,
14870
+ composeHostChannel,
14871
+ composeHostChannelExpected,
14241
14872
  composeTrustedOriginUrls,
14242
14873
  resolveComposePorts,
14243
14874
  sourceBuildDir,
14244
14875
  composePlan,
14876
+ pinnedImagesReady,
14245
14877
  composePrefetch,
14246
14878
  composeUp,
14247
14879
  composeDown,
@@ -14252,7 +14884,5 @@ export {
14252
14884
  composeStart,
14253
14885
  composeRestart,
14254
14886
  composeInternalToken,
14255
- waitForApiHealth,
14256
- waitForEdgeRunning,
14257
- importMigratedSites
14887
+ composePaths
14258
14888
  };