openship 0.4.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +27 -1
  2. package/dist/bare-FSUAM5V5.js +23 -0
  3. package/dist/{bowser-O3AEA6AN.js → bowser-HNOTPGJO.js} +2 -2
  4. package/dist/{chunk-5MZ624VX.js → chunk-2GWYYHOR.js} +1 -1
  5. package/dist/chunk-3FYIXVX5.js +8051 -0
  6. package/dist/{chunk-ILFETZOM.js → chunk-3LDWWY7U.js} +21 -1
  7. package/dist/{chunk-W73X43R7.js → chunk-3NQQXMDR.js} +3 -3
  8. package/dist/{chunk-OWED2GRM.js → chunk-42WQABXM.js} +2 -2
  9. package/dist/{chunk-TYVME3XD.js → chunk-6FH4AXVY.js} +20 -3
  10. package/dist/{chunk-OXWDY22G.js → chunk-6ODUBKUB.js} +3 -3
  11. package/dist/{chunk-DYPH4HCI.js → chunk-AO252K7Z.js} +1 -1
  12. package/dist/{chunk-XPPZ7B2J.js → chunk-B3CE2OMR.js} +14 -12
  13. package/dist/{chunk-IXCHURVK.js → chunk-DOD2BXHQ.js} +4 -4
  14. package/dist/{chunk-VWPEKOH6.js → chunk-DRK3MEDS.js} +3 -3
  15. package/dist/{chunk-V6AN6XLZ.js → chunk-EMXI4UCU.js} +125 -47
  16. package/dist/{chunk-GKHUSZCD.js → chunk-EP2TNALB.js} +2 -2
  17. package/dist/{chunk-SLTYVHU7.js → chunk-GUQ3QCJ7.js} +942 -519
  18. package/dist/chunk-I3DPMTSF.js +29 -0
  19. package/dist/{chunk-L2VOGB2X.js → chunk-IW2UKBVF.js} +3 -3
  20. package/dist/{chunk-VDMQJHPI.js → chunk-IXM5245F.js} +228 -13
  21. package/dist/{chunk-MZ2WEHHY.js → chunk-JIMUYEKN.js} +4 -4
  22. package/dist/chunk-OGY63DGD.js +705 -0
  23. package/dist/{chunk-O6ZZF52V.js → chunk-ONKEYSMY.js} +2 -2
  24. package/dist/{chunk-6APULWSU.js → chunk-OTLSJYAC.js} +1744 -118
  25. package/dist/{chunk-EI575GHN.js → chunk-PDW4L3NV.js} +3 -3
  26. package/dist/{chunk-5NYUCONN.js → chunk-PSZSKEPA.js} +33 -51
  27. package/dist/{chunk-KI2EY3WB.js → chunk-PXKCZB2I.js} +1 -1
  28. package/dist/{chunk-FXTBHTWY.js → chunk-QEH4QUBX.js} +649 -85
  29. package/dist/{chunk-6APIPIJY.js → chunk-QWHBRY2K.js} +5 -5
  30. package/dist/{chunk-6B5YCFFV.js → chunk-RVER2EOO.js} +5 -5
  31. package/dist/{chunk-Q2CIRAOK.js → chunk-RXQBD4ZY.js} +1 -1
  32. package/dist/{chunk-TGIINPVM.js → chunk-TWJCLD4W.js} +60 -10
  33. package/dist/{chunk-4Z5QO7N6.js → chunk-VDDMADOF.js} +3 -3
  34. package/dist/{chunk-BBHX7T4R.js → chunk-VE2YOK4I.js} +991 -254
  35. package/dist/{chunk-2D3I5G6P.js → chunk-VZ2ZHPS2.js} +73 -43
  36. package/dist/{chunk-AVIB7CT7.js → chunk-WNWQBB7F.js} +2 -2
  37. package/dist/{chunk-FRNAR33O.js → chunk-XZYFZ6A5.js} +3 -3
  38. package/dist/{chunk-RPHRPFEH.js → chunk-ZCFVHA25.js} +1 -1
  39. package/dist/{chunk-FPRHYBY2.js → chunk-ZFJXPCQP.js} +1 -1
  40. package/dist/{chunk-JYGD5WSN.js → chunk-ZWLDLTJP.js} +1 -1
  41. package/dist/cloud-E5M6VMCG.js +31 -0
  42. package/dist/{cloud-7QYOGF7F.js → cloud-KRTZ2IL5.js} +3 -3
  43. package/dist/{detect-AZLOHUFX.js → detect-ALF4GXRX.js} +7 -8
  44. package/dist/{dist-IQJ6H6UZ.js → dist-BM6NPWIH.js} +3 -3
  45. package/dist/{dist-es-X3B5QYKB.js → dist-es-2YRDXMUU.js} +6 -6
  46. package/dist/{dist-es-OSNJBLXB.js → dist-es-CCVZEFRZ.js} +5 -5
  47. package/dist/{dist-es-VNKHQVIB.js → dist-es-IOH6G4GM.js} +4 -4
  48. package/dist/{dist-es-RYHIPOTN.js → dist-es-JGBPABJI.js} +14 -14
  49. package/dist/{dist-es-JSTDUS7Q.js → dist-es-KWSMDPIP.js} +6 -6
  50. package/dist/{dist-es-5DY7K32L.js → dist-es-UYDDOVZ3.js} +6 -6
  51. package/dist/{dist-es-WV4AMOII.js → dist-es-XUOR4NQN.js} +8 -8
  52. package/dist/docker-QECQJ5P4.js +32 -0
  53. package/dist/docker-edge-executor-62N64MGS.js +17 -0
  54. package/dist/edge-import-CQWHN2Q3.js +48 -0
  55. package/dist/ensure-container-edge-MJVXJQG5.js +32 -0
  56. package/dist/{event-streams-EHRAKL75.js → event-streams-TR4GAC53.js} +4 -4
  57. package/dist/{executor-XJ6RP4Q2.js → executor-JELARVKC.js} +8 -8
  58. package/dist/index.js +1209 -560
  59. package/dist/{loadSso-46FQRIZS.js → loadSso-UGF4JMRK.js} +10 -10
  60. package/dist/{local-executor-V7U5VOZW.js → local-executor-WOFZD72X.js} +4 -4
  61. package/dist/nginx-AXGNWRKZ.js +31 -0
  62. package/dist/node-entry.js +4 -0
  63. package/dist/{noop-FMMFG2EZ.js → noop-NJ72F46T.js} +3 -3
  64. package/dist/openresty-lua-IE4P5YQX.js +81 -0
  65. package/dist/server/index.js +116248 -94749
  66. package/dist/server/lua/geo_country.lua +31 -6
  67. package/dist/server/lua/maxminddb.lua +403 -0
  68. package/dist/server/lua/mgmt_api.lua +431 -68
  69. package/dist/server/lua/pipe_log.lua +10 -4
  70. package/dist/server/lua/site_logger.lua +220 -12
  71. package/dist/server/migrations/0073_project_volumes_object_storage.sql +3 -0
  72. package/dist/server/migrations/0074_project_compose_path.sql +1 -0
  73. package/dist/server/migrations/0075_service_command_argv.sql +1 -0
  74. package/dist/server/migrations/0076_rollback_retention.sql +17 -0
  75. package/dist/server/migrations/0077_domain_redirect.sql +13 -0
  76. package/dist/server/migrations/0078_grant_source_scope.sql +30 -0
  77. package/dist/server/migrations/0079_project_readiness.sql +18 -0
  78. package/dist/server/migrations/0080_deployment_error_classification.sql +26 -0
  79. package/dist/server/migrations/0081_project_readiness_repair.sql +34 -0
  80. package/dist/server/migrations/0082_edge_target_verification.sql +39 -0
  81. package/dist/server/migrations/0083_service_incident.sql +84 -0
  82. package/dist/server/migrations/0084_analytics_daily_rollup.sql +72 -0
  83. package/dist/server/migrations/0085_resource_usage.sql +68 -0
  84. package/dist/server/migrations/0086_remote_infra_updates.sql +41 -0
  85. package/dist/server/migrations/0087_server_container_version_columns.sql +17 -0
  86. package/dist/server/migrations/0088_audit_source_and_settings.sql +27 -0
  87. package/dist/server/migrations/0089_auto_scan_infra.sql +7 -0
  88. package/dist/server/migrations/0090_project_collect_paths.sql +18 -0
  89. package/dist/server/migrations/0091_project_server_id.sql +26 -0
  90. package/dist/server/migrations/0092_project_internal_alias.sql +13 -0
  91. package/dist/server/migrations/0093_service_incident_org_index.sql +13 -0
  92. package/dist/server/migrations/0094_backup_restore_meta.sql +15 -0
  93. package/dist/server/migrations/0095_backup_restore_cancel.sql +19 -0
  94. package/dist/server/migrations/0096_backup_policy_retention_defaults.sql +20 -0
  95. package/dist/server/migrations/0097_update_status_upstream_only.sql +35 -0
  96. package/dist/server/migrations/meta/_journal.json +175 -0
  97. package/dist/setup-FA4PW4NU.js +21 -0
  98. package/dist/{signin-ITOPKKUB.js → signin-3M3UPB6K.js} +10 -10
  99. package/dist/{sso-oidc-NON2PD7Y.js → sso-oidc-3VDIYOEJ.js} +10 -10
  100. package/dist/{sts-HHFFSAY2.js → sts-B3WIYLXR.js} +9 -9
  101. package/package.json +6 -3
  102. package/dist/bare-JY5Y5ELU.js +0 -23
  103. package/dist/chunk-SJIFLLX4.js +0 -2931
  104. package/dist/chunk-VFINNTUL.js +0 -362
  105. package/dist/cloud-KYWY3RP4.js +0 -30
  106. package/dist/docker-LODK5Y2S.js +0 -16
  107. package/dist/docker-edge-executor-VHW3JPJS.js +0 -13
  108. package/dist/edge-import-ABUDKKJI.js +0 -45
  109. package/dist/ensure-container-edge-SW6HENSG.js +0 -31
  110. package/dist/nginx-3L4SIBJA.js +0 -24
  111. package/dist/openresty-lua-4WLGDSER.js +0 -42
  112. package/dist/setup-NU6T2PDU.js +0 -20
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env sh
2
- ":" //# ; exec "$(command -v bun || command -v node)" "$0" "$@"
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
5
  import {
6
6
  ENV_KEY,
7
7
  ENV_SECRET,
8
8
  fromEnv
9
- } from "./chunk-FRNAR33O.js";
9
+ } from "./chunk-XZYFZ6A5.js";
10
10
  import {
11
11
  SignatureV4MultiRegion,
12
12
  XmlNode,
13
13
  XmlShapeDeserializer,
14
14
  XmlText
15
- } from "./chunk-6APIPIJY.js";
15
+ } from "./chunk-QWHBRY2K.js";
16
16
  import {
17
17
  AwsSdkSigV4ASigner,
18
18
  AwsSdkSigV4Signer,
@@ -28,10 +28,10 @@ import {
28
28
  resolveAwsSdkSigV4AConfig,
29
29
  resolveAwsSdkSigV4Config,
30
30
  streamCollector
31
- } from "./chunk-IXCHURVK.js";
31
+ } from "./chunk-DOD2BXHQ.js";
32
32
  import {
33
33
  NodeHttpHandler
34
- } from "./chunk-OWED2GRM.js";
34
+ } from "./chunk-42WQABXM.js";
35
35
  import {
36
36
  BinaryDecisionDiagram,
37
37
  Client,
@@ -67,7 +67,7 @@ import {
67
67
  resolveRetryConfig,
68
68
  resolveUserAgentConfig,
69
69
  setFeature
70
- } from "./chunk-VWPEKOH6.js";
70
+ } from "./chunk-DRK3MEDS.js";
71
71
  import {
72
72
  CredentialsProviderError,
73
73
  ENV_PROFILE,
@@ -98,13 +98,13 @@ import {
98
98
  resolveHttpHandlerRuntimeConfig,
99
99
  resolveRegionConfig,
100
100
  toEndpointV1
101
- } from "./chunk-W73X43R7.js";
101
+ } from "./chunk-3NQQXMDR.js";
102
102
  import {
103
103
  CloudRuntime
104
- } from "./chunk-5NYUCONN.js";
104
+ } from "./chunk-PSZSKEPA.js";
105
105
  import {
106
106
  systemCatalog
107
- } from "./chunk-SLTYVHU7.js";
107
+ } from "./chunk-GUQ3QCJ7.js";
108
108
  import {
109
109
  AwsCrc32,
110
110
  __awaiter,
@@ -116,7 +116,7 @@ import {
116
116
  numToUint8,
117
117
  resolveEventStreamSerdeConfig,
118
118
  uint32ArrayFrom
119
- } from "./chunk-O6ZZF52V.js";
119
+ } from "./chunk-ONKEYSMY.js";
120
120
  import {
121
121
  Hash,
122
122
  NumericValue,
@@ -134,32 +134,39 @@ import {
134
134
  toBase64,
135
135
  toUint8Array,
136
136
  toUtf8
137
- } from "./chunk-JYGD5WSN.js";
137
+ } from "./chunk-ZWLDLTJP.js";
138
138
  import {
139
139
  DockerRuntime,
140
+ daemonConnectionFrom,
140
141
  isHostPathSource,
141
- scopedVolumeName
142
- } from "./chunk-SJIFLLX4.js";
142
+ resolveExecExitCode,
143
+ scopedVolumeName,
144
+ startAttachStream,
145
+ startExecStream
146
+ } from "./chunk-3FYIXVX5.js";
143
147
  import {
144
148
  BareRuntime
145
- } from "./chunk-VDMQJHPI.js";
149
+ } from "./chunk-IXM5245F.js";
146
150
  import {
147
151
  LocalExecutor
148
- } from "./chunk-TYVME3XD.js";
152
+ } from "./chunk-6FH4AXVY.js";
149
153
  import {
150
154
  EDGE_CONTAINER_NAME,
151
155
  edgeCrashReason,
152
156
  invalidateEdgeContainer,
153
157
  sanitizeEdgeVhosts
154
- } from "./chunk-2D3I5G6P.js";
158
+ } from "./chunk-VZ2ZHPS2.js";
155
159
  import {
156
160
  EDGE_CONTAINER_MOUNTS,
157
- EDGE_HOST_STATE_DIR
158
- } from "./chunk-VFINNTUL.js";
161
+ EDGE_HOST_STATE_DIR,
162
+ OPENRESTY_DEFAULT_PATHS
163
+ } from "./chunk-OGY63DGD.js";
159
164
  import {
160
165
  DEFAULT_IMAGE_REGISTRY,
161
- safeErrorMessage
162
- } from "./chunk-6APULWSU.js";
166
+ isAwsS3Endpoint,
167
+ safeErrorMessage,
168
+ withTimeout
169
+ } from "./chunk-OTLSJYAC.js";
163
170
 
164
171
  // src/lib/edge-import.ts
165
172
  import chalk from "chalk";
@@ -168,8 +175,42 @@ import ora from "ora";
168
175
  // ../../packages/adapters/src/runtime/image-transfer.ts
169
176
  import { Transform } from "stream";
170
177
 
171
- // ../../packages/adapters/src/runtime/route-registration.ts
172
- import { posix as pathPosix } from "path";
178
+ // ../../packages/adapters/src/infra/edge-baked-conf.ts
179
+ var LOGS_DIR = OPENRESTY_DEFAULT_PATHS.pidPath.replace(/\/[^/]+$/, "");
180
+
181
+ // ../../packages/adapters/src/infra/mail-container.ts
182
+ var MAIL_HOST_STATE_DIR = "/var/lib/openship/mail";
183
+ var MAIL_CONTAINER_MOUNTS = [
184
+ { host: `${MAIL_HOST_STATE_DIR}/vmail`, container: "/var/vmail" },
185
+ // The Postfix queue. A recreate on an ephemeral layer would drop in-flight and
186
+ // deferred mail without this — keep it on the host.
187
+ { host: `${MAIL_HOST_STATE_DIR}/spool`, container: "/var/spool/postfix" },
188
+ // DKIM private keys. NEVER baked, never regenerated on pull — a fresh selector
189
+ // breaks DMARC until DNS repropagates.
190
+ { host: `${MAIL_HOST_STATE_DIR}/dkim`, container: "/var/lib/dkim" },
191
+ // Mutable daemon config the admin layer edits (sasl_passwd, relayhost maps,
192
+ // amavis 50-user). Seeded from baked defaults on first boot.
193
+ { host: `${MAIL_HOST_STATE_DIR}/config/postfix`, container: "/etc/postfix", seed: true },
194
+ { host: `${MAIL_HOST_STATE_DIR}/config/dovecot`, container: "/etc/dovecot", seed: true },
195
+ { host: `${MAIL_HOST_STATE_DIR}/config/amavis`, container: "/etc/amavis/conf.d", seed: true },
196
+ // ClamAV signatures — bind-mounted so a pull doesn't force a multi-hundred-MB
197
+ // freshclam re-download and delay readiness.
198
+ { host: `${MAIL_HOST_STATE_DIR}/clamav`, container: "/var/lib/clamav" },
199
+ { host: "/etc/letsencrypt", container: "/etc/letsencrypt", readonly: true }
200
+ ];
201
+ var MAIL_DB_HOST_DATA_DIR = `${MAIL_HOST_STATE_DIR}/pgdata`;
202
+ var MAIL_DB_CONTAINER_DATA_DIR = "/var/lib/postgresql/data";
203
+ var MAIL_DB_PGDATA = `${MAIL_DB_CONTAINER_DATA_DIR}/pgdata`;
204
+ var MAIL_HOST_PATHS = {
205
+ saslPasswd: `${MAIL_HOST_STATE_DIR}/config/postfix/sasl_passwd`,
206
+ senderRelayhost: `${MAIL_HOST_STATE_DIR}/config/postfix/sender_relayhost`,
207
+ amavisUserConf: `${MAIL_HOST_STATE_DIR}/config/amavis/50-user`
208
+ };
209
+
210
+ // ../../packages/adapters/src/system/mail/ensure-container-mail.ts
211
+ var MAIL_DB_IMAGE = process.env.OPENSHIP_MAIL_DB_IMAGE?.trim() || "postgres:16-alpine";
212
+ var ENGINE_ENV_FILE = `${MAIL_HOST_STATE_DIR}/engine.env`;
213
+ var DB_ENV_FILE = `${MAIL_HOST_STATE_DIR}/db.env`;
173
214
 
174
215
  // ../../packages/adapters/src/system/modules/verify.ts
175
216
  import { createHash, createPublicKey, verify as cryptoVerify } from "crypto";
@@ -243,6 +284,25 @@ function decryptCredential(stored) {
243
284
  import { createHash as createHash3 } from "crypto";
244
285
  import { Transform as Transform2 } from "stream";
245
286
 
287
+ // ../../packages/adapters/src/backup/common/source-match.ts
288
+ function matchBackupSource(sources, targetSourceId) {
289
+ const wanted = targetSourceId.trim();
290
+ if (!wanted) return null;
291
+ const exact = sources.find((s2) => s2.id === wanted);
292
+ if (exact) return exact;
293
+ const byPhysical = sources.filter((s2) => s2.source === wanted);
294
+ if (byPhysical.length === 1) return byPhysical[0];
295
+ if (byPhysical.length > 1) return null;
296
+ const stripped = wanted.replace(/-\d+$/, "");
297
+ if (stripped !== wanted) {
298
+ const byStripped = sources.filter(
299
+ (s2) => s2.id === stripped || s2.source === stripped
300
+ );
301
+ if (byStripped.length === 1) return byStripped[0];
302
+ }
303
+ return null;
304
+ }
305
+
246
306
  // ../../packages/adapters/src/backup/volume-transfer.ts
247
307
  import { Transform as Transform3 } from "stream";
248
308
 
@@ -485,8 +545,7 @@ var CustomCommandProducerImpl = class {
485
545
  kind = "custom_command";
486
546
  // No detect — explicit selection only.
487
547
  async *produce(service, executor, opts) {
488
- const cfg = opts;
489
- const produceCommand = cfg.produceCommand ?? opts.command;
548
+ const produceCommand = opts.produceCommand ?? opts.command;
490
549
  if (!produceCommand) {
491
550
  throw new Error("custom_command producer requires `produceCommand` in policy payload config");
492
551
  }
@@ -496,12 +555,15 @@ var CustomCommandProducerImpl = class {
496
555
  produceCommand
497
556
  ]);
498
557
  yield {
499
- name: cfg.artifactName ?? "custom-backup.bin",
558
+ name: opts.artifactName ?? "custom-backup.bin",
500
559
  stream: stdout,
501
560
  payloadKind: "custom_command",
502
561
  metadata: {
503
562
  produceCommand,
504
- restoreCommand: cfg.restoreCommand ?? null
563
+ // The ONLY record of how to put this artifact back. Nothing downstream
564
+ // can reconstruct it, which is why losing it here made every captured
565
+ // run unrestorable.
566
+ restoreCommand: opts.restoreCommand ?? null
505
567
  }
506
568
  };
507
569
  const exit = await awaitExit;
@@ -515,7 +577,7 @@ var CustomCommandProducerImpl = class {
515
577
  const restoreCommand = artifact.metadata.restoreCommand ?? null;
516
578
  if (!restoreCommand) {
517
579
  throw new Error(
518
- "Backup has no restoreCommand recorded \u2014 restore is not possible for this artifact"
580
+ "Backup has no restoreCommand recorded \u2014 restore is not possible for this artifact. Re-run the backup policy to capture a restorable one."
519
581
  );
520
582
  }
521
583
  const body = await artifact.open();
@@ -582,7 +644,8 @@ var VolumeCopyProducerImpl = class {
582
644
  const stream = await artifact.open();
583
645
  await executor.receiveStream(service, volumeId, stream, {
584
646
  compression: artifact.metadata.compression ?? "zstd",
585
- clearTarget: opts.clearTarget ?? true
647
+ clearTarget: opts.clearTarget ?? true,
648
+ signal: opts.signal
586
649
  });
587
650
  }
588
651
  };
@@ -595,6 +658,58 @@ registerProducer(VolumeCopyProducer);
595
658
  // ../../packages/adapters/src/backup/executors/docker.ts
596
659
  import { PassThrough } from "stream";
597
660
  var HELPER_IMAGE = "alpine:3";
661
+ var DEFAULT_HELPER_IDLE_MS = 10 * 60 * 1e3;
662
+ var DEFAULT_HELPER_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
663
+ var EXIT_POLL_INTERVAL_MS = 2e3;
664
+ function createIdleWatchdog(ms, message) {
665
+ let timer;
666
+ let done = false;
667
+ let fire = () => {
668
+ };
669
+ const promise = new Promise((_resolve, reject) => {
670
+ fire = reject;
671
+ });
672
+ const arm = () => {
673
+ timer = setTimeout(() => {
674
+ done = true;
675
+ fire(new Error(message));
676
+ }, ms);
677
+ timer.unref?.();
678
+ };
679
+ arm();
680
+ return {
681
+ touch: () => {
682
+ if (done) return;
683
+ if (timer) clearTimeout(timer);
684
+ arm();
685
+ },
686
+ promise,
687
+ dispose: () => {
688
+ done = true;
689
+ if (timer) clearTimeout(timer);
690
+ }
691
+ };
692
+ }
693
+ function createAbortWatch(signal, message) {
694
+ let fire = () => {
695
+ };
696
+ const promise = new Promise((_resolve, reject) => {
697
+ fire = reject;
698
+ });
699
+ const onAbort = () => fire(new Error(message));
700
+ if (signal.aborted) onAbort();
701
+ else signal.addEventListener("abort", onAbort, { once: true });
702
+ return {
703
+ promise,
704
+ dispose: () => signal.removeEventListener("abort", onAbort)
705
+ };
706
+ }
707
+ function destroyQuietly(stream) {
708
+ try {
709
+ stream?.destroy?.();
710
+ } catch {
711
+ }
712
+ }
598
713
  function shellEscape4(s2) {
599
714
  return `'${s2.replace(/'/g, "'\\''")}'`;
600
715
  }
@@ -668,12 +783,12 @@ var DockerBackupExecutor = class {
668
783
  WorkingDir: opts?.cwd,
669
784
  Env: opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${v2}`) : void 0
670
785
  });
671
- const stream = await exec.start({ hijack: true, stdin: false });
786
+ const stream = await exec.start({ stdin: false });
672
787
  return this.attachDemuxed(this.dockerode, exec.id, stream, opts?.timeoutMs);
673
788
  }
674
789
  async streamPath(service, sourceId, opts) {
675
790
  const sources = await this.listSources(service);
676
- const source = sources.find((s2) => s2.id === sourceId);
791
+ const source = matchBackupSource(sources, sourceId);
677
792
  if (!source) {
678
793
  throw new Error(`Backup source "${sourceId}" not found on service ${service.name}`);
679
794
  }
@@ -691,31 +806,52 @@ var DockerBackupExecutor = class {
691
806
  await this.ensureImage(helperImage);
692
807
  const hostConfig = {
693
808
  Binds: [`${source.source}:/mnt:ro`],
694
- AutoRemove: true
809
+ // OFF, for the reason demuxContainerStream already documents but this line
810
+ // used to contradict: AutoRemove lets the daemon reap the helper before
811
+ // container.wait() answers and before the attach stream has drained, which
812
+ // is a 404 on the exit status or a truncated tar. demuxContainerStream
813
+ // removes it once the archive is fully demuxed.
814
+ AutoRemove: false,
815
+ // The archive streams over `attach()` below — the daemon's default
816
+ // json-file driver would ALSO write every byte of it to
817
+ // /var/lib/docker/containers as a log. That's a second copy of the whole
818
+ // backup, and worse than a copy: json-file escapes binary, so a 4.6 GB
819
+ // volume ballooned to a 12 GB log file here. Backups then spike the
820
+ // host's disk by more than the data they read and only release it when
821
+ // the helper exits. Nothing ever reads these logs.
822
+ LogConfig: { Type: "none", Config: {} }
695
823
  };
696
- const helper = await this.dockerode.createContainer({
697
- Image: helperImage,
698
- Cmd: ["sh", "-c", compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${tarCmd}` : tarCmd],
699
- HostConfig: hostConfig,
700
- AttachStdout: true,
701
- AttachStderr: true,
702
- Tty: false,
703
- // zstd isn't in alpine:3 and is apk-installed at runtime, which needs
704
- // egress; gzip/none use busybox built-ins and stay network-isolated.
705
- NetworkDisabled: compression !== "zstd"
706
- });
707
- const stream = await helper.attach({
708
- stream: true,
709
- stdout: true,
710
- stderr: true
711
- });
712
- await helper.start();
713
- const timeoutMs = opts ? opts.timeoutMs : void 0;
714
- return this.demuxContainerStream(helper, stream, timeoutMs ?? 60 * 60 * 1e3);
824
+ return this.handOffHelper(
825
+ {
826
+ Image: helperImage,
827
+ Cmd: ["sh", "-c", compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${tarCmd}` : tarCmd],
828
+ HostConfig: hostConfig,
829
+ AttachStdout: true,
830
+ AttachStderr: true,
831
+ Tty: false,
832
+ // zstd isn't in alpine:3 and is apk-installed at runtime, which needs
833
+ // egress; gzip/none use busybox built-ins and stay network-isolated.
834
+ NetworkDisabled: compression !== "zstd"
835
+ },
836
+ async (helper) => {
837
+ const stream = await helper.attach({
838
+ stream: true,
839
+ stdout: true,
840
+ stderr: true
841
+ });
842
+ await helper.start();
843
+ return this.demuxContainerStream(helper, stream, {
844
+ idleTimeoutMs: opts?.idleTimeoutMs ?? DEFAULT_HELPER_IDLE_MS,
845
+ timeoutMs: opts?.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS,
846
+ label: `Backup of "${sourceId}" on service ${service.name}`
847
+ });
848
+ }
849
+ );
715
850
  }
716
851
  async receiveStream(service, targetSourceId, body, opts) {
852
+ opts?.signal?.throwIfAborted();
717
853
  const sources = await this.listSources(service);
718
- const source = sources.find((s2) => s2.id === targetSourceId);
854
+ const source = matchBackupSource(sources, targetSourceId);
719
855
  if (!source) {
720
856
  throw new Error(`Restore target "${targetSourceId}" not found on service ${service.name}`);
721
857
  }
@@ -727,49 +863,188 @@ var DockerBackupExecutor = class {
727
863
  await this.ensureImage(helperImage);
728
864
  const clearCmd = opts?.clearTarget ? `find /mnt -mindepth 1 -delete 2>/dev/null || true; ` : "";
729
865
  const untarCmd = compression === "zstd" ? `${clearCmd}zstd -d -c | tar -x -C /mnt` : `${clearCmd}tar -x${compressionFlag(compression)}f - -C /mnt`;
730
- const helper = await this.dockerode.createContainer({
731
- Image: helperImage,
732
- Cmd: [
733
- "sh",
734
- "-c",
735
- compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${untarCmd}` : untarCmd
736
- ],
737
- HostConfig: { Binds: [`${source.source}:/mnt`], AutoRemove: true },
738
- AttachStdin: true,
739
- AttachStdout: true,
740
- AttachStderr: true,
741
- OpenStdin: true,
742
- StdinOnce: true,
743
- Tty: false,
744
- // zstd isn't in alpine:3 and is apk-installed at runtime, which needs
745
- // egress; gzip/none use busybox built-ins and stay network-isolated.
746
- NetworkDisabled: compression !== "zstd"
747
- });
748
- const stream = await helper.attach({
749
- stream: true,
750
- hijack: true,
751
- stdin: true,
752
- stdout: true,
753
- stderr: true
754
- });
755
- await helper.start();
756
- let bytesWritten = 0;
757
- body.on("data", (chunk) => {
758
- bytesWritten += chunk.byteLength;
759
- });
760
- const errChunks = [];
761
- stream.on("data", (c2) => {
762
- if (errChunks.length < 32) errChunks.push(c2);
763
- });
764
- body.pipe(stream);
765
- const waitResult = await helper.wait();
766
- if (waitResult.StatusCode !== 0) {
767
- const detail = Buffer.concat(errChunks).toString("utf8").replace(/[^\x20-\x7e\n]+/g, " ").trim().slice(-500);
768
- throw new Error(
769
- `Restore helper exited with code ${waitResult.StatusCode}${detail ? `: ${detail}` : ""}`
866
+ return this.withHelper(
867
+ {
868
+ Image: helperImage,
869
+ Cmd: [
870
+ "sh",
871
+ "-c",
872
+ compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${untarCmd}` : untarCmd
873
+ ],
874
+ // AutoRemove is OFF deliberately, and this is the fix for the `404 no such
875
+ // container` in #434: with it on, the daemon could reap the helper before
876
+ // /containers/{id}/wait answered, and the exit status was then unknowable.
877
+ // withHelper reaps instead — which also plugs a leak AutoRemove never
878
+ // covered, since it only fires for a container that actually started.
879
+ HostConfig: { Binds: [`${source.source}:/mnt`], AutoRemove: false },
880
+ AttachStdin: true,
881
+ AttachStdout: true,
882
+ AttachStderr: true,
883
+ OpenStdin: true,
884
+ StdinOnce: true,
885
+ Tty: false,
886
+ // zstd isn't in alpine:3 and is apk-installed at runtime, which needs
887
+ // egress; gzip/none use busybox built-ins and stay network-isolated.
888
+ NetworkDisabled: compression !== "zstd"
889
+ },
890
+ async (helper) => {
891
+ let stream;
892
+ const idleMs = opts?.idleTimeoutMs ?? DEFAULT_HELPER_IDLE_MS;
893
+ const watchdog = createIdleWatchdog(
894
+ idleMs,
895
+ `Restore of "${targetSourceId}" on service ${service.name} moved no data for ${Math.round(idleMs / 1e3)}s and was abandoned. The extract helper has been removed; the target may hold partial data.`
896
+ );
897
+ const abortWatch = opts?.signal ? createAbortWatch(
898
+ opts.signal,
899
+ `Restore of "${targetSourceId}" on service ${service.name} was cancelled mid-extract. The target holds partial data.`
900
+ ) : void 0;
901
+ try {
902
+ stream = await startAttachStream(daemonConnectionFrom(this.dockerode), helper.id, {
903
+ stdin: true,
904
+ stdout: true,
905
+ stderr: true
906
+ });
907
+ await helper.start();
908
+ let bytesWritten = 0;
909
+ let bodyEnded = false;
910
+ body.on("data", (chunk) => {
911
+ bytesWritten += chunk.byteLength;
912
+ watchdog.touch();
913
+ });
914
+ const errChunks = [];
915
+ stream.on("data", (c2) => {
916
+ watchdog.touch();
917
+ if (errChunks.length < 32) errChunks.push(c2);
918
+ });
919
+ body.on("end", () => {
920
+ bodyEnded = true;
921
+ watchdog.touch();
922
+ });
923
+ body.pipe(stream);
924
+ const waitResult = await this.awaitHelperExit(helper, watchdog, () => bodyEnded, {
925
+ timeoutMs: opts?.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS,
926
+ label: `Restore of "${targetSourceId}" on service ${service.name}`,
927
+ note: "The target may hold partial data.",
928
+ cancelled: abortWatch?.promise
929
+ });
930
+ if (waitResult.StatusCode !== 0) {
931
+ const detail = Buffer.concat(errChunks).toString("utf8").replace(/[^\x20-\x7e\n]+/g, " ").trim().slice(-500);
932
+ throw new Error(
933
+ `Restore helper exited with code ${waitResult.StatusCode}${detail ? `: ${detail}` : ""}`
934
+ );
935
+ }
936
+ return { bytesWritten };
937
+ } finally {
938
+ watchdog.dispose();
939
+ abortWatch?.dispose();
940
+ destroyQuietly(stream);
941
+ destroyQuietly(body);
942
+ }
943
+ }
944
+ );
945
+ }
946
+ /**
947
+ * Create a helper, run `fn`, reap the helper however `fn` ends.
948
+ *
949
+ * Every helper here is disposable and every one of them must be removed by us:
950
+ * `AutoRemove` is off on purpose (it races /wait, and it only ever fires for a
951
+ * container that actually started, so a throw between create and start leaked
952
+ * one permanently — the leak behind #434). Four call sites each had their own
953
+ * correct copy of that reap, which is exactly how one of them came to be the
954
+ * one that didn't.
955
+ *
956
+ * NOT for helpers whose output outlives the call: streamPath hands its
957
+ * container to demuxContainerStream, which must reap only after the archive
958
+ * has drained. `handOffHelper` covers that shape.
959
+ */
960
+ async withHelper(spec, fn) {
961
+ const helper = await this.dockerode.createContainer(spec);
962
+ try {
963
+ return await fn(helper);
964
+ } finally {
965
+ await helper.remove({ force: true }).catch(() => {
966
+ });
967
+ }
968
+ }
969
+ /**
970
+ * Create a helper whose lifetime outlives this call: reap it only if `setup`
971
+ * throws, then hand ownership to whatever `setup` returned. Without this, a
972
+ * failure between createContainer and the hand-off leaks the container
973
+ * forever — nothing downstream knows it exists yet.
974
+ */
975
+ async handOffHelper(spec, setup) {
976
+ const helper = await this.dockerode.createContainer(spec);
977
+ try {
978
+ return await setup(helper);
979
+ } catch (err) {
980
+ await helper.remove({ force: true }).catch(() => {
981
+ });
982
+ throw err;
983
+ }
984
+ }
985
+ /**
986
+ * Wait for a helper to exit without trusting any single signal.
987
+ *
988
+ * `helper.wait()` alone is what hung in #434: /containers/{id}/wait is a
989
+ * long-poll with no timeout on either the modem or our transport, so a lost
990
+ * response is indistinguishable from a running container — forever. Three
991
+ * backstops race it:
992
+ *
993
+ * 1. An exit poll that asks the daemon directly. This is what turns the
994
+ * common hang into a few seconds. It's the same "don't trust the attach
995
+ * stream's terminal signal" reasoning already documented in
996
+ * demuxContainerStream; receiveStream was the path without a backstop.
997
+ * 2. The caller's idle watchdog (fed by real traffic, not by this function).
998
+ * 3. An absolute ceiling, so even a helper that somehow keeps trickling
999
+ * bytes cannot run unbounded.
1000
+ *
1001
+ * The poll only starts once the body has been fully written: before that a
1002
+ * not-yet-running helper is normal, and inspecting on a loop would be noise.
1003
+ * It is gated on the body rather than on the attach stream closing because
1004
+ * over an SSH-tunneled attach that close is not reliably delivered — the very
1005
+ * reason this backstop has to exist.
1006
+ */
1007
+ async awaitHelperExit(helper, watchdog, bodyEnded, opts) {
1008
+ let polling = true;
1009
+ const pollExit = async () => {
1010
+ while (polling) {
1011
+ await new Promise((r2) => {
1012
+ const t8 = setTimeout(r2, EXIT_POLL_INTERVAL_MS);
1013
+ t8.unref?.();
1014
+ });
1015
+ if (!polling || !bodyEnded()) continue;
1016
+ let state;
1017
+ try {
1018
+ state = (await helper.inspect()).State ?? {};
1019
+ } catch (err) {
1020
+ if (err?.statusCode === 404) {
1021
+ throw new Error(
1022
+ `${opts.label}: the helper disappeared before its exit status could be read.${opts.note ? ` ${opts.note}` : ""}`
1023
+ );
1024
+ }
1025
+ continue;
1026
+ }
1027
+ if (state.Running === false && state.Status !== "created" && typeof state.ExitCode === "number") {
1028
+ return { StatusCode: state.ExitCode };
1029
+ }
1030
+ }
1031
+ return new Promise(() => {
1032
+ });
1033
+ };
1034
+ try {
1035
+ return await withTimeout(
1036
+ Promise.race([
1037
+ helper.wait(),
1038
+ pollExit(),
1039
+ watchdog.promise,
1040
+ ...opts.cancelled ? [opts.cancelled] : []
1041
+ ]),
1042
+ opts.timeoutMs,
1043
+ `${opts.label} exceeded its ${Math.round(opts.timeoutMs / 1e3)}s ceiling and was abandoned.${opts.note ? ` ${opts.note}` : ""}`
770
1044
  );
1045
+ } finally {
1046
+ polling = false;
771
1047
  }
772
- return { bytesWritten };
773
1048
  }
774
1049
  /**
775
1050
  * SAME-DAEMON volume→volume copy in ONE helper container that mounts both
@@ -783,8 +1058,8 @@ var DockerBackupExecutor = class {
783
1058
  * network-isolated; source is mounted read-only.
784
1059
  */
785
1060
  async copyVolumeLocal(srcService, srcSourceId, dstService, dstSourceId, opts) {
786
- const src = (await this.listSources(srcService)).find((s2) => s2.id === srcSourceId);
787
- const dst = (await this.listSources(dstService)).find((s2) => s2.id === dstSourceId);
1061
+ const src = matchBackupSource(await this.listSources(srcService), srcSourceId);
1062
+ const dst = matchBackupSource(await this.listSources(dstService), dstSourceId);
788
1063
  if (!src) throw new Error(`Copy source "${srcSourceId}" not found on ${srcService.name}`);
789
1064
  if (!dst) throw new Error(`Copy target "${dstSourceId}" not found on ${dstService.name}`);
790
1065
  if (src.type === "tmpfs" || dst.type === "tmpfs") {
@@ -793,31 +1068,30 @@ var DockerBackupExecutor = class {
793
1068
  await this.ensureImage(HELPER_IMAGE);
794
1069
  const clearCmd = opts?.clearTarget ? "find /to -mindepth 1 -delete 2>/dev/null || true; " : "";
795
1070
  const copyCmd = `${clearCmd}{ tar -C /from -cf - . ; echo $? > /tmp/src.rc ; } | tar -C /to -xf - ; rc=$? ; [ "$(cat /tmp/src.rc 2>/dev/null)" = 0 ] && [ "$rc" = 0 ] && du -sk /to 2>/dev/null | cut -f1`;
796
- const helper = await this.dockerode.createContainer({
797
- Image: HELPER_IMAGE,
798
- Cmd: ["sh", "-c", copyCmd],
799
- HostConfig: {
800
- Binds: [`${src.source}:/from:ro`, `${dst.source}:/to`]
1071
+ return this.withHelper(
1072
+ {
1073
+ Image: HELPER_IMAGE,
1074
+ Cmd: ["sh", "-c", copyCmd],
1075
+ HostConfig: {
1076
+ Binds: [`${src.source}:/from:ro`, `${dst.source}:/to`]
1077
+ },
1078
+ Tty: true,
1079
+ // merged raw stdout so the trailing `du` number reads cleanly
1080
+ NetworkDisabled: true
801
1081
  },
802
- Tty: true,
803
- // merged raw stdout so the trailing `du` number reads cleanly
804
- NetworkDisabled: true
805
- });
806
- try {
807
- await helper.start();
808
- const res = await helper.wait();
809
- const out = await helper.logs({ follow: false, stdout: true, stderr: true }).then((b2) => b2.toString().trim()).catch(() => "");
810
- if (res.StatusCode !== 0) {
811
- throw new Error(
812
- `Local volume copy failed (${srcSourceId}\u2192${dstSourceId}): ${out.slice(0, 500) || `exit ${res.StatusCode}`}`
813
- );
1082
+ async (helper) => {
1083
+ await helper.start();
1084
+ const res = await helper.wait();
1085
+ const out = await helper.logs({ follow: false, stdout: true, stderr: true }).then((b2) => b2.toString().trim()).catch(() => "");
1086
+ if (res.StatusCode !== 0) {
1087
+ throw new Error(
1088
+ `Local volume copy failed (${srcSourceId}\u2192${dstSourceId}): ${out.slice(0, 500) || `exit ${res.StatusCode}`}`
1089
+ );
1090
+ }
1091
+ const kb = parseInt(out.split(/\s+/).pop() || "0", 10);
1092
+ return { bytesWritten: Number.isFinite(kb) ? kb * 1024 : 0 };
814
1093
  }
815
- const kb = parseInt(out.split(/\s+/).pop() || "0", 10);
816
- return { bytesWritten: Number.isFinite(kb) ? kb * 1024 : 0 };
817
- } finally {
818
- await helper.remove({ force: true }).catch(() => {
819
- });
820
- }
1094
+ );
821
1095
  }
822
1096
  /**
823
1097
  * Does the named volume behind `sourceId` already exist on this daemon, and
@@ -827,7 +1101,7 @@ var DockerBackupExecutor = class {
827
1101
  * so we err on the side of NOT clobbering.
828
1102
  */
829
1103
  async probeVolume(service, sourceId) {
830
- const source = (await this.listSources(service)).find((s2) => s2.id === sourceId);
1104
+ const source = matchBackupSource(await this.listSources(service), sourceId);
831
1105
  if (!source || source.type !== "volume" || !source.source) {
832
1106
  return { exists: false, empty: true };
833
1107
  }
@@ -837,26 +1111,25 @@ var DockerBackupExecutor = class {
837
1111
  return { exists: false, empty: true };
838
1112
  }
839
1113
  await this.ensureImage(HELPER_IMAGE);
840
- const helper = await this.dockerode.createContainer({
841
- Image: HELPER_IMAGE,
842
- Cmd: [
843
- "sh",
844
- "-c",
845
- 'if [ -z "$(ls -A /probe 2>/dev/null)" ]; then echo VOLEMPTY; else echo VOLDATA; fi'
846
- ],
847
- HostConfig: { Binds: [`${source.source}:/probe:ro`] },
848
- Tty: true,
849
- NetworkDisabled: true
850
- });
851
- try {
852
- await helper.start();
853
- await helper.wait();
854
- const out = await helper.logs({ follow: false, stdout: true, stderr: true }).then((b2) => b2.toString()).catch(() => "");
855
- return { exists: true, empty: out.includes("VOLEMPTY") };
856
- } finally {
857
- await helper.remove({ force: true }).catch(() => {
858
- });
859
- }
1114
+ return this.withHelper(
1115
+ {
1116
+ Image: HELPER_IMAGE,
1117
+ Cmd: [
1118
+ "sh",
1119
+ "-c",
1120
+ 'if [ -z "$(ls -A /probe 2>/dev/null)" ]; then echo VOLEMPTY; else echo VOLDATA; fi'
1121
+ ],
1122
+ HostConfig: { Binds: [`${source.source}:/probe:ro`] },
1123
+ Tty: true,
1124
+ NetworkDisabled: true
1125
+ },
1126
+ async (helper) => {
1127
+ await helper.start();
1128
+ await helper.wait();
1129
+ const out = await helper.logs({ follow: false, stdout: true, stderr: true }).then((b2) => b2.toString()).catch(() => "");
1130
+ return { exists: true, empty: out.includes("VOLEMPTY") };
1131
+ }
1132
+ );
860
1133
  }
861
1134
  async pipeIntoCommand(service, cmd, body, opts) {
862
1135
  if (!service.containerId) {
@@ -874,7 +1147,10 @@ var DockerBackupExecutor = class {
874
1147
  WorkingDir: opts?.cwd,
875
1148
  Env: opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${v2}`) : void 0
876
1149
  });
877
- const stream = await exec.start({ hijack: true, stdin: true });
1150
+ const stream = await startExecStream(daemonConnectionFrom(this.dockerode), exec.id, {
1151
+ tty: false,
1152
+ stdin: true
1153
+ });
878
1154
  const stderrChunks = [];
879
1155
  const { PassThrough: PassThrough3 } = await import("stream");
880
1156
  const stdoutSink = new PassThrough3();
@@ -906,9 +1182,8 @@ var DockerBackupExecutor = class {
906
1182
  stream.on("end", async () => {
907
1183
  if (timer) clearTimeout(timer);
908
1184
  try {
909
- const info = await this.dockerode.getExec(exec.id).inspect();
910
1185
  resolve4({
911
- code: info.ExitCode ?? 0,
1186
+ code: await resolveExecExitCode(exec, exec.id),
912
1187
  stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
913
1188
  });
914
1189
  } catch (err) {
@@ -971,9 +1246,8 @@ var DockerBackupExecutor = class {
971
1246
  stream.on("end", async () => {
972
1247
  if (timer) clearTimeout(timer);
973
1248
  try {
974
- const info = await docker.getExec(execId).inspect();
975
1249
  resolve4({
976
- code: info.ExitCode ?? 0,
1250
+ code: await resolveExecExitCode(docker.getExec(execId), execId),
977
1251
  stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
978
1252
  });
979
1253
  } catch (err) {
@@ -987,7 +1261,7 @@ var DockerBackupExecutor = class {
987
1261
  });
988
1262
  return { stdout, awaitExit };
989
1263
  }
990
- demuxContainerStream(container, stream, timeoutMs) {
1264
+ demuxContainerStream(container, stream, opts) {
991
1265
  const stdout = new PassThrough();
992
1266
  const stderrChunks = [];
993
1267
  const stderrSink = new PassThrough();
@@ -995,29 +1269,49 @@ var DockerBackupExecutor = class {
995
1269
  if (stderrChunks.length < 16) stderrChunks.push(chunk);
996
1270
  });
997
1271
  container.modem.demuxStream(stream, stdout, stderrSink);
1272
+ let sinksEnded = false;
1273
+ let exited = false;
1274
+ const reapIfDone = () => {
1275
+ if (!sinksEnded || !exited) return;
1276
+ void container.remove({ force: true }).catch(() => {
1277
+ });
1278
+ };
998
1279
  const endSinks = () => {
999
1280
  stdout.end();
1000
1281
  stderrSink.end();
1282
+ sinksEnded = true;
1283
+ reapIfDone();
1001
1284
  };
1002
1285
  stream.on("end", endSinks);
1003
1286
  stream.on("close", endSinks);
1004
- const awaitExit = new Promise((resolve4, reject) => {
1005
- const timer = timeoutMs ? setTimeout(() => {
1006
- stdout.destroy(new Error(`helper container timed out after ${timeoutMs}ms`));
1007
- reject(new Error(`helper container timed out after ${timeoutMs}ms`));
1008
- }, timeoutMs) : null;
1009
- container.wait().then((res) => {
1010
- if (timer) clearTimeout(timer);
1287
+ const watchdog = createIdleWatchdog(
1288
+ opts.idleTimeoutMs,
1289
+ `${opts.label} produced no data for ${Math.round(opts.idleTimeoutMs / 1e3)}s and was abandoned. Nothing was written; the archive is incomplete and was not kept.`
1290
+ );
1291
+ stream.on("data", () => watchdog.touch());
1292
+ const awaitExit = (async () => {
1293
+ try {
1294
+ const res = await this.awaitHelperExit(container, watchdog, () => sinksEnded, {
1295
+ timeoutMs: opts.timeoutMs,
1296
+ label: opts.label,
1297
+ note: "Nothing was written; the archive is incomplete and was not kept."
1298
+ });
1299
+ exited = true;
1011
1300
  setTimeout(endSinks, 3e3);
1012
- resolve4({
1301
+ reapIfDone();
1302
+ return {
1013
1303
  code: res.StatusCode,
1014
1304
  stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
1305
+ };
1306
+ } catch (err) {
1307
+ stdout.destroy(err);
1308
+ void container.remove({ force: true }).catch(() => {
1015
1309
  });
1016
- }).catch((err) => {
1017
- if (timer) clearTimeout(timer);
1018
- reject(err);
1019
- });
1020
- });
1310
+ throw err;
1311
+ } finally {
1312
+ watchdog.dispose();
1313
+ }
1314
+ })();
1021
1315
  return { stdout, awaitExit };
1022
1316
  }
1023
1317
  };
@@ -1259,7 +1553,7 @@ var BareBackupExecutor = class {
1259
1553
  ]);
1260
1554
  const path = sourceId;
1261
1555
  const tarCmd = compression === "zstd" ? `tar -c -C ${shellEscape6(path)} ${excludeArgs.join(" ")} . | zstd -c -3` : compression === "gzip" ? `tar -cz -C ${shellEscape6(path)} ${excludeArgs.join(" ")} .` : `tar -c -C ${shellEscape6(path)} ${excludeArgs.join(" ")} .`;
1262
- return this.execStream(service, ["sh", "-c", tarCmd]);
1556
+ return this.execStream(service, ["sh", "-c", tarCmd], { timeoutMs: opts?.timeoutMs });
1263
1557
  }
1264
1558
  async receiveStream(service, targetSourceId, body, opts) {
1265
1559
  const compression = opts?.compression ?? "zstd";
@@ -1370,7 +1664,7 @@ var LocalDestinationImpl = class {
1370
1664
  return { ok: false, reason: message };
1371
1665
  }
1372
1666
  }
1373
- async put(key, body, _opts) {
1667
+ async put(key, body, opts) {
1374
1668
  const targetPath = this.resolveKey(key);
1375
1669
  await fs2.mkdir(dirname(targetPath), { recursive: true });
1376
1670
  const tmpPath = `${targetPath}.tmp-${randomBytes3(4).toString("hex")}`;
@@ -1383,15 +1677,23 @@ var LocalDestinationImpl = class {
1383
1677
  cb(null, chunk);
1384
1678
  }
1385
1679
  });
1680
+ let digest;
1386
1681
  try {
1387
1682
  await pipelineP(body, counterAndHasher, createWriteStream2(tmpPath));
1683
+ digest = hash.digest("hex");
1684
+ const expected = opts.sha256?.trim().replace(/^sha256:/i, "").toLowerCase();
1685
+ if (expected && expected !== digest) {
1686
+ throw new Error(
1687
+ `sha256 mismatch writing ${key}: caller declared ${expected}, bytes hashed to ${digest}`
1688
+ );
1689
+ }
1388
1690
  await fs2.rename(tmpPath, targetPath);
1389
1691
  } catch (err) {
1390
1692
  await fs2.unlink(tmpPath).catch(() => {
1391
1693
  });
1392
1694
  throw err;
1393
1695
  }
1394
- return { bytesWritten, etag: hash.digest("hex") };
1696
+ return { bytesWritten, etag: digest };
1395
1697
  }
1396
1698
  async get(key) {
1397
1699
  const path = this.resolveKey(key);
@@ -10976,10 +11278,10 @@ var package_default = {
10976
11278
  // ../../node_modules/@aws-sdk/credential-provider-node/dist-es/remoteProvider.js
10977
11279
  var ENV_IMDS_DISABLED = "AWS_EC2_METADATA_DISABLED";
10978
11280
  var remoteProvider = async (init) => {
10979
- const { ENV_CMDS_FULL_URI, ENV_CMDS_RELATIVE_URI, fromContainerMetadata, fromInstanceMetadata } = await import("./dist-es-VNKHQVIB.js");
11281
+ const { ENV_CMDS_FULL_URI, ENV_CMDS_RELATIVE_URI, fromContainerMetadata, fromInstanceMetadata } = await import("./dist-es-IOH6G4GM.js");
10980
11282
  if (process.env[ENV_CMDS_RELATIVE_URI] || process.env[ENV_CMDS_FULL_URI]) {
10981
11283
  init.logger?.debug("@aws-sdk/credential-provider-node - remoteProvider::fromHttp/fromContainerMetadata");
10982
- const { fromHttp } = await import("./dist-es-5DY7K32L.js");
11284
+ const { fromHttp } = await import("./dist-es-UYDDOVZ3.js");
10983
11285
  return chain(fromHttp(init), fromContainerMetadata(init));
10984
11286
  }
10985
11287
  if (process.env[ENV_IMDS_DISABLED] && process.env[ENV_IMDS_DISABLED] !== "false") {
@@ -11091,22 +11393,22 @@ var defaultProvider = (init = {}) => memoizeChain([
11091
11393
  if (!ssoStartUrl && !ssoAccountId && !ssoRegion && !ssoRoleName && !ssoSession) {
11092
11394
  throw new CredentialsProviderError("Skipping SSO provider in default chain (inputs do not include SSO fields).", { logger: init.logger });
11093
11395
  }
11094
- const { fromSSO } = await import("./dist-es-WV4AMOII.js");
11396
+ const { fromSSO } = await import("./dist-es-XUOR4NQN.js");
11095
11397
  return fromSSO(init)(awsIdentityProperties);
11096
11398
  },
11097
11399
  async (awsIdentityProperties) => {
11098
11400
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromIni");
11099
- const { fromIni } = await import("./dist-es-RYHIPOTN.js");
11401
+ const { fromIni } = await import("./dist-es-JGBPABJI.js");
11100
11402
  return fromIni(init)(awsIdentityProperties);
11101
11403
  },
11102
11404
  async (awsIdentityProperties) => {
11103
11405
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromProcess");
11104
- const { fromProcess } = await import("./dist-es-OSNJBLXB.js");
11406
+ const { fromProcess } = await import("./dist-es-CCVZEFRZ.js");
11105
11407
  return fromProcess(init)(awsIdentityProperties);
11106
11408
  },
11107
11409
  async (awsIdentityProperties) => {
11108
11410
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromTokenFile");
11109
- const { fromTokenFile } = await import("./dist-es-X3B5QYKB.js");
11411
+ const { fromTokenFile } = await import("./dist-es-2YRDXMUU.js");
11110
11412
  return fromTokenFile(init)(awsIdentityProperties);
11111
11413
  },
11112
11414
  async () => {
@@ -12191,7 +12493,7 @@ var S3DestinationImpl = class {
12191
12493
  if (!accessKeyId || !secretAccessKey) {
12192
12494
  throw new Error(`S3Destination "${row.name}" missing access credentials`);
12193
12495
  }
12194
- const isAws = !row.endpoint || row.endpoint.includes(".amazonaws.com") || row.endpoint === "https://s3.amazonaws.com";
12496
+ const isAws = isAwsS3Endpoint(row.endpoint);
12195
12497
  this.client = new S3Client({
12196
12498
  endpoint: row.endpoint ?? void 0,
12197
12499
  region: row.region ?? "auto",
@@ -12668,11 +12970,11 @@ registerDestination("sftp", (row) => new SftpDestinationImpl(row));
12668
12970
  registerDestination("openship_server", (row) => new SftpDestinationImpl(row));
12669
12971
 
12670
12972
  // src/lib/compose.ts
12671
- import { spawnSync as spawnSync3 } from "child_process";
12973
+ import { spawn as spawn2, spawnSync as spawnSync3 } from "child_process";
12672
12974
  import { randomBytes as randomBytes5 } from "crypto";
12673
- import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
12975
+ import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
12674
12976
  import { homedir as homedir2, userInfo } from "os";
12675
- import { join as join6 } from "path";
12977
+ import { join as join7 } from "path";
12676
12978
 
12677
12979
  // src/lib/paths.ts
12678
12980
  import { homedir } from "os";
@@ -12681,17 +12983,193 @@ var DEFAULT_HOME = join3(homedir(), ".openship");
12681
12983
  var override = process.env.OPENSHIP_HOME?.trim();
12682
12984
  var OS_DIR = override ? resolve2(override) : DEFAULT_HOME;
12683
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
+ });
13008
+ }
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
+ }
13019
+ }
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
+ });
13030
+ }
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
+ }
12684
13160
 
12685
13161
  // src/lib/source-install.ts
12686
13162
  import { spawnSync as spawnSync2 } from "child_process";
12687
- import { existsSync as existsSync2, readFileSync } from "fs";
12688
- import { join as join5 } from "path";
13163
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
13164
+ import { join as join6 } from "path";
12689
13165
 
12690
13166
  // src/lib/from-source.ts
12691
13167
  import { spawn, spawnSync } from "child_process";
12692
- import { existsSync, mkdirSync } from "fs";
12693
- import { join as join4, resolve as resolve3 } from "path";
13168
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
13169
+ import { join as join5, resolve as resolve3 } from "path";
12694
13170
  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");
12695
13173
  function has(cmd) {
12696
13174
  try {
12697
13175
  return spawnSync(cmd, ["--version"], { stdio: "ignore" }).status === 0;
@@ -12720,7 +13198,7 @@ function shortSha(cwd) {
12720
13198
  return r2.status === 0 ? (r2.stdout ?? "").trim() || "unknown" : "unknown";
12721
13199
  }
12722
13200
  function isMonorepo(dir) {
12723
- return existsSync(join4(dir, "package.json")) && existsSync(join4(dir, "apps/api/package.json")) && existsSync(join4(dir, "apps/dashboard/package.json"));
13201
+ return existsSync2(join5(dir, "package.json")) && existsSync2(join5(dir, "apps/api/package.json")) && existsSync2(join5(dir, "apps/dashboard/package.json"));
12724
13202
  }
12725
13203
  async function prepareFromSource(opts) {
12726
13204
  if (!has("bun")) {
@@ -12744,9 +13222,9 @@ async function prepareFromSource(opts) {
12744
13222
  }
12745
13223
  ref = (opts.ref || "main").trim();
12746
13224
  const repoUrl = opts.repo || DEFAULT_REPO;
12747
- sourceDir = join4(OS_DIR, "src");
12748
- mkdirSync(OS_DIR, { recursive: true });
12749
- if (!existsSync(join4(sourceDir, ".git"))) {
13225
+ sourceDir = SOURCE_CHECKOUT_DIR;
13226
+ mkdirSync2(OS_DIR, { recursive: true });
13227
+ if (!existsSync2(join5(sourceDir, ".git"))) {
12750
13228
  console.log(` Cloning ${repoUrl} \u2192 ${sourceDir}`);
12751
13229
  await run("git", ["clone", repoUrl, sourceDir], OS_DIR);
12752
13230
  }
@@ -12761,29 +13239,29 @@ async function prepareFromSource(opts) {
12761
13239
  console.log(` Source: ${sourceDir} @ ${ref} (${sha})`);
12762
13240
  console.log(" Installing workspace dependencies (bun install)\u2026");
12763
13241
  await run("bun", ["install"], sourceDir);
12764
- const distDir = join4(OS_DIR, "from-source-dist");
13242
+ const distDir = SOURCE_DIST_DIR;
12765
13243
  console.log(" Building release dist (compiles the dashboard \u2014 needs RAM/CPU)\u2026");
12766
13244
  await run(
12767
13245
  "bun",
12768
- ["run", join4(sourceDir, "apps/api/scripts/build-release.ts")],
13246
+ ["run", join5(sourceDir, "apps/api/scripts/build-release.ts")],
12769
13247
  sourceDir,
12770
13248
  { DIST_DIR: distDir, NODE_ENV: "production", CLOUD_MODE: "false", OPENSHIP_TARGET: "local" }
12771
13249
  );
12772
13250
  console.log(" Installing runtime dependencies in the dist\u2026");
12773
13251
  await run("bun", ["install", "--production", "--frozen-lockfile"], distDir);
12774
- const apiDir = join4(distDir, "api");
12775
- const dashboardDir = join4(distDir, "dashboard");
12776
- if (!existsSync(join4(apiDir, "src/index.ts"))) {
13252
+ const apiDir = join5(distDir, "api");
13253
+ const dashboardDir = join5(distDir, "dashboard");
13254
+ if (!existsSync2(join5(apiDir, "src/index.ts"))) {
12777
13255
  throw new Error(`Build produced no API at ${apiDir}/src/index.ts \u2014 build-release layout drift?`);
12778
13256
  }
12779
- if (!existsSync(join4(dashboardDir, "apps/dashboard/server.js"))) {
13257
+ if (!existsSync2(join5(dashboardDir, "apps/dashboard/server.js"))) {
12780
13258
  throw new Error(`Build produced no dashboard at ${dashboardDir}/apps/dashboard/server.js.`);
12781
13259
  }
12782
13260
  return { apiDir, dashboardDir, ref, sha, sourceDir };
12783
13261
  }
12784
13262
 
12785
13263
  // src/lib/source-install.ts
12786
- var MARKER = join5(OS_DIR, "source-install.json");
13264
+ var MARKER = join6(OS_DIR, "source-install.json");
12787
13265
  function parseSourceInstall(raw) {
12788
13266
  try {
12789
13267
  const obj = JSON.parse(raw);
@@ -12794,9 +13272,9 @@ function parseSourceInstall(raw) {
12794
13272
  }
12795
13273
  }
12796
13274
  function readSourceInstall() {
12797
- if (!existsSync2(MARKER)) return null;
13275
+ if (!existsSync3(MARKER)) return null;
12798
13276
  try {
12799
- return parseSourceInstall(readFileSync(MARKER, "utf8"));
13277
+ return parseSourceInstall(readFileSync2(MARKER, "utf8"));
12800
13278
  } catch {
12801
13279
  return null;
12802
13280
  }
@@ -12817,7 +13295,7 @@ async function rebuildFromSource(info) {
12817
13295
  if (!has("git")) {
12818
13296
  throw new Error("`git` is required to update the source checkout but wasn't found on PATH.");
12819
13297
  }
12820
- if (!existsSync2(join5(info.dir, ".git"))) {
13298
+ if (!existsSync3(join6(info.dir, ".git"))) {
12821
13299
  throw new Error(
12822
13300
  `Source checkout ${info.dir} is missing (no .git) \u2014 reinstall from source with scripts/install-source.sh.`
12823
13301
  );
@@ -12826,7 +13304,7 @@ async function rebuildFromSource(info) {
12826
13304
  await run("git", ["checkout", info.ref], info.dir);
12827
13305
  await run("git", ["pull", "--ff-only", "origin", info.ref], info.dir).catch(() => {
12828
13306
  });
12829
- const cliDir = join5(info.dir, "apps/cli");
13307
+ const cliDir = join6(info.dir, "apps/cli");
12830
13308
  await run("bun", ["install"], info.dir);
12831
13309
  await run("bun", ["run", "build"], cliDir);
12832
13310
  await run("bun", ["run", "build/stage-dashboard.ts"], cliDir);
@@ -12839,11 +13317,11 @@ var EDGE_ACME_HOST_DIR = `${EDGE_HOST_STATE_DIR}/acme`;
12839
13317
  function edgeVolumeYaml(indent) {
12840
13318
  return EDGE_CONTAINER_MOUNTS.map((m2) => `${indent}- ${m2.host}:${m2.container}:z`).join("\n");
12841
13319
  }
12842
- var COMPOSE_DIR = join6(OS_DIR, "compose");
12843
- var INSTALL_METHOD_FILE = join6(OS_DIR, "install-method");
12844
- var COMPOSE_FILE = join6(COMPOSE_DIR, "docker-compose.yml");
12845
- var BUILD_FILE = join6(COMPOSE_DIR, "docker-compose.build.yml");
12846
- var ENV_FILE = join6(COMPOSE_DIR, ".env");
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");
12847
13325
  var BUILT_SERVICES = [
12848
13326
  { service: "api", dockerfile: "apps/api/Dockerfile" },
12849
13327
  { service: "dashboard", dockerfile: "apps/dashboard/Dockerfile" },
@@ -12852,24 +13330,45 @@ var BUILT_SERVICES = [
12852
13330
  var ENV_CONSUMING_SERVICES = ["api", "dashboard", "edge"];
12853
13331
  function readInstallMethod() {
12854
13332
  try {
12855
- const v2 = readFileSync2(INSTALL_METHOD_FILE, "utf8").trim();
13333
+ const v2 = readFileSync3(INSTALL_METHOD_FILE, "utf8").trim();
12856
13334
  return v2 === "compose" || v2 === "bare" ? v2 : null;
12857
13335
  } catch {
12858
13336
  return null;
12859
13337
  }
12860
13338
  }
12861
13339
  function writeInstallMethod(method) {
12862
- mkdirSync2(OS_DIR, { recursive: true, mode: 448 });
12863
- writeFileSync(INSTALL_METHOD_FILE, method, { mode: 384 });
13340
+ mkdirSync3(OS_DIR, { recursive: true, mode: 448 });
13341
+ writeFileSync2(INSTALL_METHOD_FILE, method, { mode: 384 });
12864
13342
  }
12865
- function hasDockerCompose() {
12866
- const docker = spawnSync3("docker", ["version"], { stdio: "ignore" });
12867
- if (docker.status !== 0) return false;
12868
- const compose2 = spawnSync3("docker", ["compose", "version"], { stdio: "ignore" });
12869
- return compose2.status === 0;
13343
+ function dockerState() {
13344
+ const ok = (args) => spawnSync3("docker", args, { stdio: "ignore" }).status === 0;
13345
+ if (!ok(["--version"])) return { binary: false, plugin: false, daemon: false };
13346
+ return { binary: true, plugin: ok(["compose", "version"]), daemon: ok(["version"]) };
12870
13347
  }
12871
- function composeIsViableDefault() {
12872
- return process.platform === "linux" && hasDockerCompose();
13348
+ function dockerGap(state = dockerState()) {
13349
+ if (!state.binary) {
13350
+ return { summary: "Docker isn't installed", installable: true };
13351
+ }
13352
+ if (!state.plugin) {
13353
+ return {
13354
+ summary: "Docker is installed but the Compose plugin (`docker compose`) is missing",
13355
+ installable: true
13356
+ };
13357
+ }
13358
+ if (!state.daemon) {
13359
+ const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
13360
+ return {
13361
+ summary: "Docker is installed but its daemon isn't reachable",
13362
+ // Reinstalling changes nothing: a group change only applies to NEW logins,
13363
+ // and a stopped daemon needs starting, not installing.
13364
+ 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`
13366
+ };
13367
+ }
13368
+ return null;
13369
+ }
13370
+ function hasDockerCompose() {
13371
+ return dockerGap() === null;
12873
13372
  }
12874
13373
  function hasCmd(cmd) {
12875
13374
  return spawnSync3(cmd, ["--version"], { stdio: "ignore" }).status === 0;
@@ -12877,22 +13376,67 @@ function hasCmd(cmd) {
12877
13376
  function shQuote(s2) {
12878
13377
  return `'${s2.replace(/'/g, `'\\''`)}'`;
12879
13378
  }
12880
- async function ensureDocker() {
12881
- if (hasDockerCompose()) return true;
12882
- if (process.platform !== "linux") return false;
13379
+ function dockerInstallPreview(state = dockerState()) {
13380
+ const gap = dockerGap(state);
13381
+ if (!gap) return { state, gap: null, wouldInstall: false };
13382
+ if (process.platform !== "linux" || !gap.installable) return { state, gap, wouldInstall: false };
12883
13383
  const plan = systemCatalog.installs.docker({
12884
13384
  os: "linux",
12885
13385
  serviceManager: "systemd"
12886
13386
  });
12887
- if (!plan.supported || !plan.installCommand) return false;
13387
+ if (!plan.supported || !plan.installCommand) return { state, gap, wouldInstall: false };
13388
+ return {
13389
+ state,
13390
+ gap,
13391
+ wouldInstall: true,
13392
+ installCommand: plan.installCommand,
13393
+ ...plan.startCommand ? { startCommand: plan.startCommand } : {}
13394
+ };
13395
+ }
13396
+ async function ensureDocker(opts = {}) {
13397
+ const notice = opts.onNotice ?? ((line) => process.stderr.write(` ${line}
13398
+ `));
13399
+ const { state, gap, wouldInstall, installCommand, startCommand } = dockerInstallPreview();
13400
+ if (!gap) return true;
13401
+ if (process.platform !== "linux") return false;
13402
+ if (!gap.installable) {
13403
+ notice(gap.summary + ".");
13404
+ if (gap.hint) notice(gap.hint);
13405
+ return false;
13406
+ }
13407
+ if (!wouldInstall || !installCommand) return false;
12888
13408
  const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
12889
13409
  const sudo = !asRoot && hasCmd("sudo") ? "sudo " : "";
12890
- const sh = (script) => spawnSync3("sh", ["-c", sudo ? `${sudo}sh -c ${shQuote(script)}` : script], {
12891
- stdio: "inherit"
12892
- }).status ?? 1;
12893
- if (sh(plan.installCommand) !== 0) return false;
12894
- if (plan.startCommand) sh(plan.startCommand);
12895
- return hasDockerCompose();
13410
+ notice("This can take 2-5 minutes on a small VPS (~150 MB of packages) and stays quiet while apt works.");
13411
+ if (state.binary) {
13412
+ notice("Docker's installer will warn that docker already exists and pause ~20s before continuing \u2014 that's expected.");
13413
+ }
13414
+ const sh = (script) => new Promise((resolve4) => {
13415
+ const child = spawn2("sh", ["-c", sudo ? `${sudo}sh -c ${shQuote(script)}` : script], {
13416
+ stdio: "inherit"
13417
+ });
13418
+ const started = Date.now();
13419
+ const tick = setInterval(() => {
13420
+ const s2 = Math.round((Date.now() - started) / 1e3);
13421
+ notice(`still installing Docker \u2014 ${Math.floor(s2 / 60)}m${String(s2 % 60).padStart(2, "0")}s elapsed\u2026`);
13422
+ }, 3e4);
13423
+ const done = (code) => {
13424
+ clearInterval(tick);
13425
+ resolve4(code);
13426
+ };
13427
+ child.on("error", () => done(1));
13428
+ child.on("close", (code) => done(code ?? 1));
13429
+ });
13430
+ if (await sh(installCommand) !== 0) return false;
13431
+ if (startCommand) await sh(startCommand);
13432
+ const after = dockerGap();
13433
+ if (!after) {
13434
+ notice("Docker ready.");
13435
+ return true;
13436
+ }
13437
+ notice(after.summary + ".");
13438
+ if (after.hint) notice(after.hint);
13439
+ return false;
12896
13440
  }
12897
13441
  var COMPOSE_YAML = `# Managed by \`openship up\` \u2014 do not edit; re-run \`openship up\` to regenerate.
12898
13442
  services:
@@ -12900,6 +13444,12 @@ services:
12900
13444
  image: postgres:16-alpine
12901
13445
  restart: unless-stopped
12902
13446
  environment:
13447
+ # Keep the data dir in a subdirectory of the volume so a fresh install never
13448
+ # runs initdb against a bare mount root (which fails on quirky host
13449
+ # filesystems with EPERM \u2014 #350). OPENSHIP_PGDATA is decided ONCE at install
13450
+ # by the CLI (fresh \u2192 subdir, pre-existing volume \u2192 root) and preserved in
13451
+ # .env, so this never moves an existing database.
13452
+ PGDATA: \${OPENSHIP_PGDATA:-/var/lib/postgresql/data/pgdata}
12903
13453
  POSTGRES_USER: \${POSTGRES_USER:-openship}
12904
13454
  POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?missing from .env \u2014 re-run openship up to regenerate it}
12905
13455
  POSTGRES_DB: \${POSTGRES_DB:-openship}
@@ -12926,7 +13476,10 @@ services:
12926
13476
  api:
12927
13477
  image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-api:\${OPENSHIP_VERSION:-latest}
12928
13478
  restart: unless-stopped
12929
- ports: ["\${OPENSHIP_BIND_ADDR:-0.0.0.0}:\${API_PORT:-4000}:\${API_PORT:-4000}"]
13479
+ # Loopback by default \u2014 the host-net edge reaches it over loopback, so nothing
13480
+ # sits on a public interface. OPENSHIP_BIND_ADDR opts into a public/LAN interface
13481
+ # (set by \`openship up\` when a public URL is configured for off-box access).
13482
+ ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${API_PORT:-4000}:\${API_PORT:-4000}"]
12930
13483
  volumes:
12931
13484
  - /var/run/docker.sock:/var/run/docker.sock
12932
13485
  # Routing state shared with the edge, as HOST BIND MOUNTS (generated from
@@ -12961,7 +13514,8 @@ ${edgeVolumeYaml(" ")}
12961
13514
  dashboard:
12962
13515
  image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-dashboard:\${OPENSHIP_VERSION:-latest}
12963
13516
  restart: unless-stopped
12964
- ports: ["\${OPENSHIP_BIND_ADDR:-0.0.0.0}:\${DASHBOARD_PORT:-3001}:\${DASHBOARD_PORT:-3001}"]
13517
+ # Loopback by default (see api note); OPENSHIP_BIND_ADDR opts into a public interface.
13518
+ ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${DASHBOARD_PORT:-3001}:\${DASHBOARD_PORT:-3001}"]
12965
13519
  env_file: [.env]
12966
13520
  environment:
12967
13521
  NODE_ENV: production
@@ -12995,7 +13549,7 @@ function keepSecret(existing, key) {
12995
13549
  function readEnvFile() {
12996
13550
  const out = {};
12997
13551
  try {
12998
- for (const line of readFileSync2(ENV_FILE, "utf8").split("\n")) {
13552
+ for (const line of readFileSync3(ENV_FILE, "utf8").split("\n")) {
12999
13553
  const m2 = line.match(/^([A-Z0-9_]+)=(.*)$/);
13000
13554
  if (m2) out[m2[1]] = m2[2];
13001
13555
  }
@@ -13012,13 +13566,23 @@ function rewriteHostAuthorizedKeys(existing, pub) {
13012
13566
  const kept = existing.split("\n").filter((line) => line.trim() && !line.includes(HOST_KEY_COMMENT));
13013
13567
  return [...kept, hostKeyAuthLine(pub)].join("\n") + "\n";
13014
13568
  }
13015
- function provisionHostSshChannel() {
13016
- if (process.platform !== "linux") return null;
13569
+ function plannedHostChannel(hostControl) {
13570
+ if (!hostControl || process.platform !== "linux") return null;
13571
+ let user;
13572
+ try {
13573
+ user = userInfo().username;
13574
+ } catch {
13575
+ user = process.env.USER || process.env.LOGNAME || "root";
13576
+ }
13577
+ return { user, keyPath: join7(COMPOSE_DIR, "host-ssh", "id_ed25519") };
13578
+ }
13579
+ function provisionHostSshChannel(hostControl) {
13580
+ const target = plannedHostChannel(hostControl);
13581
+ if (!target) return null;
13582
+ const { user, keyPath } = target;
13017
13583
  try {
13018
- const sshDir = join6(COMPOSE_DIR, "host-ssh");
13019
- mkdirSync2(sshDir, { recursive: true, mode: 448 });
13020
- const keyPath = join6(sshDir, "id_ed25519");
13021
- if (!existsSync3(keyPath)) {
13584
+ mkdirSync3(join7(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 448 });
13585
+ if (!existsSync4(keyPath)) {
13022
13586
  const g2 = spawnSync3(
13023
13587
  "ssh-keygen",
13024
13588
  ["-t", "ed25519", "-N", "", "-q", "-f", keyPath, "-C", HOST_KEY_COMMENT],
@@ -13026,21 +13590,14 @@ function provisionHostSshChannel() {
13026
13590
  );
13027
13591
  if (g2.status !== 0) return null;
13028
13592
  }
13029
- const pub = readFileSync2(`${keyPath}.pub`, "utf8").trim();
13593
+ const pub = readFileSync3(`${keyPath}.pub`, "utf8").trim();
13030
13594
  if (!pub) return null;
13031
- const user = (() => {
13032
- try {
13033
- return userInfo().username;
13034
- } catch {
13035
- return process.env.USER || process.env.LOGNAME || "root";
13036
- }
13037
- })();
13038
- const userSshDir = join6(homedir2(), ".ssh");
13039
- mkdirSync2(userSshDir, { recursive: true, mode: 448 });
13040
- const authKeys = join6(userSshDir, "authorized_keys");
13041
- const existing = existsSync3(authKeys) ? readFileSync2(authKeys, "utf8") : "";
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") : "";
13042
13599
  const next = rewriteHostAuthorizedKeys(existing, pub);
13043
- if (next !== existing) writeFileSync(authKeys, next, { mode: 384 });
13600
+ if (next !== existing) writeFileSync2(authKeys, next, { mode: 384 });
13044
13601
  chmodSync(authKeys, 384);
13045
13602
  return { user, keyPath };
13046
13603
  } catch {
@@ -13051,6 +13608,11 @@ function composeProjectName(prev) {
13051
13608
  if (prev.COMPOSE_PROJECT_NAME) return prev.COMPOSE_PROJECT_NAME;
13052
13609
  return Object.keys(prev).length > 0 ? "compose" : "openship";
13053
13610
  }
13611
+ 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
+ }
13054
13616
  function orphanedStackContainers(project) {
13055
13617
  const r2 = spawnSync3(
13056
13618
  "docker",
@@ -13150,7 +13712,7 @@ function migrateLegacyEdgeVolumes(project) {
13150
13712
  const existing = new Set(ls.stdout.split("\n").map((l2) => l2.trim()).filter(Boolean));
13151
13713
  for (const { volume, host } of pairs) {
13152
13714
  if (!existing.has(volume)) continue;
13153
- mkdirSync2(host, { recursive: true });
13715
+ mkdirSync3(host, { recursive: true });
13154
13716
  const alreadyThere = spawnSync3("sh", ["-c", `ls -A ${JSON.stringify(host)} 2>/dev/null | head -1`], {
13155
13717
  encoding: "utf8"
13156
13718
  });
@@ -13200,11 +13762,30 @@ function keepConfig(prev, key, explicit) {
13200
13762
  const carried = prev[key]?.trim();
13201
13763
  return carried || void 0;
13202
13764
  }
13765
+ var ACME_ENV_KEYS = [
13766
+ "OPENSHIP_ACME_EMAIL",
13767
+ "OPENSHIP_ACME_DIRECTORY_URL",
13768
+ "OPENSHIP_ACME_EAB_KID",
13769
+ "OPENSHIP_ACME_EAB_HMAC_KEY",
13770
+ "OPENSHIP_ACME_KEY_TYPE",
13771
+ "OPENSHIP_ACME_CA_BUNDLE",
13772
+ "OPENSHIP_ACME_TOS_AGREED"
13773
+ ];
13774
+ function renderAcmeEnv(prev) {
13775
+ return ACME_ENV_KEYS.flatMap((key) => {
13776
+ const value = process.env[key]?.trim() || prev[key]?.trim();
13777
+ if (!value) return [];
13778
+ if (/[\r\n]/.test(value)) throw new Error(`${key} must be a single-line value`);
13779
+ return [`${key}=${value}`];
13780
+ });
13781
+ }
13203
13782
  function resolveEnvConfig(prev, opts) {
13204
13783
  const publicUrl = keepConfig(prev, "OPENSHIP_PUBLIC_URL", opts.publicUrl);
13784
+ const bindAddr = keepConfig(prev, "OPENSHIP_BIND_ADDR") ?? (publicUrl ? "0.0.0.0" : void 0);
13205
13785
  return {
13206
- apiPort: keepConfig(prev, "API_PORT", opts.apiPort) ?? "4000",
13207
- dashPort: keepConfig(prev, "DASHBOARD_PORT", opts.dashboardPort) ?? "3001",
13786
+ apiPort: keepConfig(prev, "API_PORT", opts.apiPort) ?? String(DEFAULT_API_PORT),
13787
+ dashPort: keepConfig(prev, "DASHBOARD_PORT", opts.dashboardPort) ?? String(DEFAULT_DASHBOARD_PORT),
13788
+ ...bindAddr ? { bindAddr } : {},
13208
13789
  ...publicUrl ? { publicUrl } : {},
13209
13790
  // A public URL always implies a proxy in front; otherwise keep whatever the
13210
13791
  // install was configured with.
@@ -13222,6 +13803,50 @@ function resolveEnvConfig(prev, opts) {
13222
13803
  hostControl: opts.noHostControl === void 0 ? prev.OPENSHIP_HOST_CONTROL !== "false" : !opts.noHostControl
13223
13804
  };
13224
13805
  }
13806
+ function toPort(value) {
13807
+ const n2 = Number(value);
13808
+ return Number.isInteger(n2) && n2 > 0 && n2 <= 65535 ? n2 : void 0;
13809
+ }
13810
+ function composeEnvPorts() {
13811
+ const env = readEnvFile();
13812
+ return { api: toPort(env.API_PORT), dashboard: toPort(env.DASHBOARD_PORT) };
13813
+ }
13814
+ function composeBindAddr() {
13815
+ return readEnvFile().OPENSHIP_BIND_ADDR?.trim() || "127.0.0.1";
13816
+ }
13817
+ function composeTrustedOriginUrls() {
13818
+ const env = readEnvFile();
13819
+ return [env.OPENSHIP_PUBLIC_URL, env.OPENSHIP_EXTRA_TRUSTED_ORIGINS].filter(
13820
+ (v2) => !!v2?.trim()
13821
+ );
13822
+ }
13823
+ function composeHeldPorts() {
13824
+ const r2 = spawnSync3(
13825
+ "docker",
13826
+ ["ps", "--format", '{{.Label "com.docker.compose.project.config_files"}} {{.Ports}}'],
13827
+ { encoding: "utf8" }
13828
+ );
13829
+ if (r2.status !== 0 || !r2.stdout) return [];
13830
+ const ports = /* @__PURE__ */ new Set();
13831
+ for (const line of r2.stdout.split("\n")) {
13832
+ const [configFiles = "", published = ""] = line.split(" ");
13833
+ if (!configFiles.split(",").some((f2) => f2.trim() === COMPOSE_FILE)) continue;
13834
+ for (const m2 of published.matchAll(/:(\d+)->/g)) ports.add(Number(m2[1]));
13835
+ }
13836
+ return [...ports];
13837
+ }
13838
+ async function resolveComposePorts(prefs, opts = {}) {
13839
+ return resolvePorts(
13840
+ {
13841
+ api: toPort(prefs.api),
13842
+ dashboard: toPort(prefs.dashboard),
13843
+ previous: composeEnvPorts(),
13844
+ bindAddr: composeBindAddr(),
13845
+ reclaimable: composeHeldPorts()
13846
+ },
13847
+ opts
13848
+ );
13849
+ }
13225
13850
  function renderEnv(opts, host, cfg) {
13226
13851
  const prev = readEnvFile();
13227
13852
  const lines = [
@@ -13235,13 +13860,24 @@ function renderEnv(opts, host, cfg) {
13235
13860
  // (hides the local row). Written explicitly so the policy is visible in .env.
13236
13861
  `OPENSHIP_HOST_CONTROL=${cfg.hostControl ? "true" : "false"}`,
13237
13862
  `OPENSHIP_IMAGE_REGISTRY=${cfg.registry}`,
13238
- `OPENSHIP_VERSION=${opts.version || (true ? "0.4.8" : "latest")}`,
13863
+ `OPENSHIP_VERSION=${opts.version || (true ? "0.6.0" : "latest")}`,
13239
13864
  `POSTGRES_PASSWORD=${keepSecret(prev, "POSTGRES_PASSWORD")}`,
13865
+ // Pinned once (see resolvePgData): fresh install → subdir, existing volume → root.
13866
+ `OPENSHIP_PGDATA=${resolvePgData(prev)}`,
13240
13867
  `BETTER_AUTH_SECRET=${keepSecret(prev, "BETTER_AUTH_SECRET")}`,
13241
13868
  `INTERNAL_TOKEN=${keepSecret(prev, "INTERNAL_TOKEN")}`,
13242
13869
  `API_PORT=${cfg.apiPort}`,
13243
- `DASHBOARD_PORT=${cfg.dashPort}`
13870
+ `DASHBOARD_PORT=${cfg.dashPort}`,
13871
+ // The api's OWN view of the dashboard port: the self-app boot reconcile points
13872
+ // the operator's domain at the dashboard through this (self-deploy.ts), and it
13873
+ // silently defaults to 3001 when unset. Ports are dynamic now, so leaving it
13874
+ // out publishes a domain routed to a port nothing is listening on.
13875
+ `OPENSHIP_DASHBOARD_PORT=${cfg.dashPort}`,
13876
+ // Alternate CA/EAB values are operator configuration (including one secret),
13877
+ // so a routine `openship up`/upgrade must not silently discard them.
13878
+ ...renderAcmeEnv(prev)
13244
13879
  ];
13880
+ if (cfg.bindAddr) lines.push(`OPENSHIP_BIND_ADDR=${cfg.bindAddr}`);
13245
13881
  if (cfg.publicUrl) lines.push(`OPENSHIP_PUBLIC_URL=${cfg.publicUrl}`);
13246
13882
  if (cfg.extraTrustedOrigins) {
13247
13883
  lines.push(`OPENSHIP_EXTRA_TRUSTED_ORIGINS=${cfg.extraTrustedOrigins}`);
@@ -13261,7 +13897,7 @@ function renderEnv(opts, host, cfg) {
13261
13897
  function sourceBuildDir() {
13262
13898
  const marker = readSourceInstall();
13263
13899
  if (!marker?.dir) return null;
13264
- const hasAll = BUILT_SERVICES.every((s2) => existsSync3(join6(marker.dir, s2.dockerfile)));
13900
+ const hasAll = BUILT_SERVICES.every((s2) => existsSync4(join7(marker.dir, s2.dockerfile)));
13265
13901
  return hasAll ? marker.dir : null;
13266
13902
  }
13267
13903
  function renderBuildOverride(repoDir) {
@@ -13277,22 +13913,56 @@ services:
13277
13913
  ${services}`;
13278
13914
  }
13279
13915
  function materialize(opts) {
13280
- mkdirSync2(COMPOSE_DIR, { recursive: true, mode: 448 });
13916
+ mkdirSync3(COMPOSE_DIR, { recursive: true, mode: 448 });
13281
13917
  const prev = readEnvFile();
13282
13918
  const cfg = resolveEnvConfig(prev, opts);
13283
- const host = cfg.hostControl ? provisionHostSshChannel() : null;
13919
+ const host = provisionHostSshChannel(cfg.hostControl);
13284
13920
  let before = "";
13285
13921
  try {
13286
- before = readFileSync2(ENV_FILE, "utf8");
13922
+ before = readFileSync3(ENV_FILE, "utf8");
13287
13923
  } catch {
13288
13924
  }
13289
13925
  const rendered = renderEnv(opts, host, cfg);
13290
- writeFileSync(COMPOSE_FILE, COMPOSE_YAML);
13291
- writeFileSync(ENV_FILE, rendered, { mode: 384 });
13926
+ writeFileSync2(COMPOSE_FILE, COMPOSE_YAML);
13927
+ writeFileSync2(ENV_FILE, rendered, { mode: 384 });
13292
13928
  const buildDir = opts.build === false ? null : sourceBuildDir();
13293
- if (buildDir) writeFileSync(BUILD_FILE, renderBuildOverride(buildDir));
13929
+ if (buildDir) writeFileSync2(BUILD_FILE, renderBuildOverride(buildDir));
13294
13930
  return { buildDir, cfg, envChanged: before !== "" && rendered !== before };
13295
13931
  }
13932
+ var SECRET_ENV_KEY = /(PASSWORD|SECRET|TOKEN|HMAC_KEY)$/;
13933
+ function composePlan(opts) {
13934
+ const prev = readEnvFile();
13935
+ const cfg = resolveEnvConfig(prev, opts);
13936
+ const buildDir = opts.build === false ? null : sourceBuildDir();
13937
+ const hostChannel = plannedHostChannel(cfg.hostControl);
13938
+ const settings = [];
13939
+ const newSecrets = [];
13940
+ for (const line of renderEnv(opts, hostChannel, cfg).split("\n")) {
13941
+ const at2 = line.indexOf("=");
13942
+ if (at2 < 1 || line.startsWith("#")) continue;
13943
+ const key = line.slice(0, at2);
13944
+ if (!SECRET_ENV_KEY.test(key)) {
13945
+ settings.push({ key, value: line.slice(at2 + 1) });
13946
+ continue;
13947
+ }
13948
+ const minted = !prev[key];
13949
+ if (minted) newSecrets.push(key);
13950
+ settings.push({ key, value: minted ? "<generated on this run>" : "<preserved>" });
13951
+ }
13952
+ return {
13953
+ composeFile: COMPOSE_FILE,
13954
+ envFile: ENV_FILE,
13955
+ installMethodFile: INSTALL_METHOD_FILE,
13956
+ ...buildDir ? { buildFile: BUILD_FILE } : {},
13957
+ yaml: COMPOSE_YAML,
13958
+ settings,
13959
+ newSecrets,
13960
+ existing: Object.keys(prev).length > 0,
13961
+ mountDirs: EDGE_CONTAINER_MOUNTS.map((m2) => m2.host),
13962
+ buildDir,
13963
+ hostChannel
13964
+ };
13965
+ }
13296
13966
  function dbVolumeExists(project) {
13297
13967
  const r2 = spawnSync3("docker", ["volume", "inspect", `${project}_postgres_data`], {
13298
13968
  stdio: "ignore"
@@ -13315,6 +13985,23 @@ function composePrefetch(opts) {
13315
13985
  }
13316
13986
  return compose(["pull"]) === 0;
13317
13987
  }
13988
+ function ensureEdgeMountDirs() {
13989
+ const failed = [];
13990
+ for (const { host } of EDGE_CONTAINER_MOUNTS) {
13991
+ try {
13992
+ mkdirSync3(host, { recursive: true });
13993
+ } catch (err) {
13994
+ if (err.code !== "EEXIST") failed.push(host);
13995
+ }
13996
+ }
13997
+ return failed;
13998
+ }
13999
+ function isRootlessDocker() {
14000
+ const r2 = spawnSync3("docker", ["info", "-f", "{{println .SecurityOptions}}"], {
14001
+ encoding: "utf8"
14002
+ });
14003
+ return r2.status === 0 && /rootless/i.test(r2.stdout ?? "");
14004
+ }
13318
14005
  async function composeUp(opts) {
13319
14006
  const { buildDir, cfg, envChanged } = materialize(opts);
13320
14007
  const apiPort = cfg.apiPort;
@@ -13328,6 +14015,21 @@ async function composeUp(opts) {
13328
14015
  removeOrphanedStack(project);
13329
14016
  migrateLegacyEdgeVolumes(project);
13330
14017
  warnOrphanedVolumes(project);
14018
+ const unmakeableMounts = ensureEdgeMountDirs();
14019
+ if (unmakeableMounts.length && isRootlessDocker()) {
14020
+ const dirs = unmakeableMounts.join(" ");
14021
+ console.error(
14022
+ `
14023
+ Rootless Docker can't create the edge's host directories under root-owned paths:
14024
+ ${unmakeableMounts.join("\n ")}
14025
+
14026
+ Create them once (owned by your user), then re-run \`openship up --compose\`:
14027
+ sudo mkdir -p ${dirs}
14028
+ sudo chown -R "$(id -un)" ${dirs}
14029
+ `
14030
+ );
14031
+ return { ok: false, apiPort, dashPort };
14032
+ }
13331
14033
  if (dbVolumeExists(project)) {
13332
14034
  reconcileDbPassword(env.POSTGRES_USER || "openship", env.POSTGRES_PASSWORD ?? "");
13333
14035
  }
@@ -13365,12 +14067,12 @@ function onEdgeContainerChanged() {
13365
14067
  invalidateEdgeContainer();
13366
14068
  }
13367
14069
  function composeDown() {
13368
- if (!existsSync3(COMPOSE_FILE)) return false;
14070
+ if (!existsSync4(COMPOSE_FILE)) return false;
13369
14071
  return compose(["down"]) === 0;
13370
14072
  }
13371
14073
  function composeUninstall(opts = {}) {
13372
14074
  const removedImages = [];
13373
- const ok = existsSync3(COMPOSE_FILE) ? compose(["down", "-v", "--remove-orphans"]) === 0 : false;
14075
+ const ok = existsSync4(COMPOSE_FILE) ? compose(["down", "-v", "--remove-orphans"]) === 0 : false;
13374
14076
  if (opts.removeImages) {
13375
14077
  const env = readEnvFile();
13376
14078
  const registry = env.OPENSHIP_IMAGE_REGISTRY || DEFAULT_IMAGE_REGISTRY;
@@ -13385,7 +14087,7 @@ function composeUninstall(opts = {}) {
13385
14087
  return { ok, removedImages };
13386
14088
  }
13387
14089
  async function composeUpdate(version) {
13388
- if (!existsSync3(COMPOSE_FILE)) return false;
14090
+ if (!existsSync4(COMPOSE_FILE)) return false;
13389
14091
  return (await composeUp(version ? { version } : {})).ok;
13390
14092
  }
13391
14093
  function composePs() {
@@ -13421,7 +14123,16 @@ async function waitForApiHealth(port, tries) {
13421
14123
  }
13422
14124
  return false;
13423
14125
  }
13424
- async function importMigratedSites(apiPort, sites, certPems) {
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) {
13425
14136
  const token = composeInternalToken();
13426
14137
  if (!token) {
13427
14138
  const error = "couldn't read the stack's internal token";
@@ -13437,11 +14148,16 @@ async function importMigratedSites(apiPort, sites, certPems) {
13437
14148
  spinner.warn(`${error} \u2014 migrated sites not imported. Re-run \`openship up\` to retry.`);
13438
14149
  return { ok: false, registered: [], error };
13439
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
+ }
13440
14156
  try {
13441
14157
  const r2 = await fetch(`http://127.0.0.1:${apiPort}/api/system/edge/import-sites`, {
13442
14158
  method: "POST",
13443
14159
  headers: { "Content-Type": "application/json", "X-Internal-Token": token },
13444
- body: JSON.stringify({ sites, certPems }),
14160
+ body: JSON.stringify({ sites, certPems, staticRootOverrides }),
13445
14161
  signal: AbortSignal.timeout(12e4)
13446
14162
  });
13447
14163
  const data = await r2.json().catch(() => ({}));
@@ -13496,16 +14212,36 @@ async function importMigratedSites(apiPort, sites, certPems) {
13496
14212
  export {
13497
14213
  OS_DIR,
13498
14214
  IS_ALT_HOME,
14215
+ DATA_DIR,
14216
+ LOG_DIR,
14217
+ AUTH_SECRET_FILE,
14218
+ INTERNAL_TOKEN_FILE,
14219
+ PORTS_FILE,
14220
+ saveInstanceUrl,
14221
+ readInstanceUrl,
14222
+ readStoredPorts,
14223
+ storedApiPort,
14224
+ storedDashboardPort,
14225
+ portMoveNotice,
14226
+ resolvePorts,
14227
+ DEFAULT_REPO,
14228
+ SOURCE_CHECKOUT_DIR,
14229
+ SOURCE_DIST_DIR,
14230
+ has,
13499
14231
  shortSha,
13500
14232
  prepareFromSource,
13501
14233
  readSourceInstall,
13502
14234
  remoteSha,
13503
14235
  rebuildFromSource,
13504
14236
  readInstallMethod,
14237
+ dockerGap,
13505
14238
  hasDockerCompose,
13506
- composeIsViableDefault,
14239
+ dockerInstallPreview,
13507
14240
  ensureDocker,
14241
+ composeTrustedOriginUrls,
14242
+ resolveComposePorts,
13508
14243
  sourceBuildDir,
14244
+ composePlan,
13509
14245
  composePrefetch,
13510
14246
  composeUp,
13511
14247
  composeDown,
@@ -13517,5 +14253,6 @@ export {
13517
14253
  composeRestart,
13518
14254
  composeInternalToken,
13519
14255
  waitForApiHealth,
14256
+ waitForEdgeRunning,
13520
14257
  importMigratedSites
13521
14258
  };