openship 0.6.1 → 0.6.6

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 (67) hide show
  1. package/README.md +7 -2
  2. package/dist/{bare-IWS2QHJ3.js → bare-OXJVW3AK.js} +11 -9
  3. package/dist/{chunk-VZ2ZHPS2.js → chunk-2YM3NI2E.js} +3 -9
  4. package/dist/chunk-4I2BM2DT.js +68 -0
  5. package/dist/{chunk-2NZSUXOB.js → chunk-5DMWSTOM.js} +310 -1459
  6. package/dist/{chunk-3FYIXVX5.js → chunk-77QDF6UD.js} +581 -122
  7. package/dist/{chunk-IOT5ZX23.js → chunk-A4GXYE4L.js} +449 -383
  8. package/dist/chunk-CBLD2HS5.js +428 -0
  9. package/dist/chunk-ELGRMZOW.js +25 -0
  10. package/dist/{chunk-TWJCLD4W.js → chunk-FE7ZZQSE.js} +5 -5
  11. package/dist/{chunk-3NZICQHF.js → chunk-HKRQXPTI.js} +31 -8
  12. package/dist/{chunk-IXM5245F.js → chunk-LFE5KQ7Y.js} +553 -538
  13. package/dist/{chunk-VDDMADOF.js → chunk-LH2PLRQO.js} +16 -2
  14. package/dist/{chunk-EMXI4UCU.js → chunk-LUKYPVJ4.js} +29 -10
  15. package/dist/{chunk-UC7YIEHT.js → chunk-MLYSEZQB.js} +352 -47
  16. package/dist/chunk-NK5N5LXN.js +1681 -0
  17. package/dist/{chunk-RRJJGVKE.js → chunk-OBPKYXQL.js} +75 -17
  18. package/dist/{chunk-6ODUBKUB.js → chunk-PCPVTH6M.js} +78 -88
  19. package/dist/chunk-Q7WGTQQ5.js +88 -0
  20. package/dist/{chunk-EW6KB2J7.js → chunk-QVW76WDL.js} +440 -74
  21. package/dist/{chunk-YIPMDGG6.js → chunk-RSR7PFIQ.js} +79 -23
  22. package/dist/{chunk-3LDWWY7U.js → chunk-SM26HDS2.js} +41 -7
  23. package/dist/{chunk-OTLSJYAC.js → chunk-UJ6JZM65.js} +5185 -341
  24. package/dist/chunk-UVNMQM7W.js +444 -0
  25. package/dist/chunk-VTGPEC6B.js +308 -0
  26. package/dist/chunk-WVAML4H6.js +2145 -0
  27. package/dist/{chunk-JIMUYEKN.js → chunk-YIMOCZBZ.js} +1 -1
  28. package/dist/chunk-YMMQRE5J.js +292 -0
  29. package/dist/cloud-75VIT4KV.js +37 -0
  30. package/dist/{detect-ALF4GXRX.js → detect-OIE5V3VZ.js} +3 -3
  31. package/dist/{docker-QECQJ5P4.js → docker-VTR2GK5Y.js} +16 -8
  32. package/dist/{docker-edge-executor-62N64MGS.js → docker-edge-executor-22IBXNYJ.js} +5 -5
  33. package/dist/{edge-import-SAYTRJLO.js → edge-import-WJUB4M7F.js} +27 -23
  34. package/dist/edge-preflight-GBNCPNVU.js +42 -0
  35. package/dist/{ensure-container-edge-CFSCPGZK.js → ensure-container-edge-KPI2CVXJ.js} +9 -8
  36. package/dist/{executor-PT6UEJ5M.js → executor-7LXXZPBR.js} +15 -5
  37. package/dist/host-channel-preflight-KE6OONYK.js +50 -0
  38. package/dist/index.js +3783 -4221
  39. package/dist/{nginx-YOEEFBTE.js → nginx-KUFMLMAC.js} +13 -5
  40. package/dist/node-bootstrap.js +241 -0
  41. package/dist/node-entry.js +52 -1
  42. package/dist/{openresty-lua-WGBO2M3Z.js → openresty-lua-ATTH35BE.js} +8 -2
  43. package/dist/privilege-I4W3ZNZK.js +19 -0
  44. package/dist/server/engine/samples/amavisd/amavisd.conf +25 -0
  45. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +4 -1
  46. package/dist/server/engine/samples/postfix/master.cf +14 -0
  47. package/dist/server/index.js +77659 -61909
  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/0105_mail_inbound_rules.sql +80 -0
  56. package/dist/server/migrations/0106_mcp_call_tracking.sql +23 -0
  57. package/dist/server/migrations/0107_build_minute_metering_indexes.sql +20 -0
  58. package/dist/server/migrations/0108_credential.sql +68 -0
  59. package/dist/server/migrations/meta/_journal.json +77 -0
  60. package/dist/{setup-M2ZDGH3O.js → setup-XGLGUUFW.js} +10 -9
  61. package/dist/ssh-client-JBMA7SN4.js +28 -0
  62. package/dist/up-K4BV7466.js +57 -0
  63. package/package.json +4 -2
  64. package/dist/chunk-IW2UKBVF.js +0 -144
  65. package/dist/chunk-ZCFVHA25.js +0 -89
  66. package/dist/cloud-M2YLWEXO.js +0 -31
  67. /package/dist/{dist-es-JGBPABJI.js → dist-es-AXFRS7PS.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-RRJJGVKE.js";
105
- import {
106
- systemCatalog
107
- } from "./chunk-IOT5ZX23.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,
@@ -135,6 +132,12 @@ import {
135
132
  toUint8Array,
136
133
  toUtf8
137
134
  } from "./chunk-ZWLDLTJP.js";
135
+ import {
136
+ CloudRuntime
137
+ } from "./chunk-OBPKYXQL.js";
138
+ import {
139
+ OPENRESTY_DEFAULT_PATHS
140
+ } from "./chunk-RSR7PFIQ.js";
138
141
  import {
139
142
  DockerRuntime,
140
143
  daemonConnectionFrom,
@@ -143,34 +146,29 @@ import {
143
146
  scopedVolumeName,
144
147
  startAttachStream,
145
148
  startExecStream
146
- } from "./chunk-3FYIXVX5.js";
149
+ } from "./chunk-77QDF6UD.js";
147
150
  import {
148
151
  BareRuntime
149
- } from "./chunk-IXM5245F.js";
152
+ } from "./chunk-LFE5KQ7Y.js";
150
153
  import {
151
- LocalExecutor
152
- } from "./chunk-6FH4AXVY.js";
154
+ createExecutor
155
+ } from "./chunk-MLYSEZQB.js";
153
156
  import {
154
- EDGE_CONTAINER_NAME,
155
- edgeCrashReason,
156
- invalidateEdgeContainer,
157
- sanitizeEdgeVhosts
158
- } from "./chunk-VZ2ZHPS2.js";
157
+ envOps
158
+ } from "./chunk-YMMQRE5J.js";
159
159
  import {
160
- EDGE_CONTAINER_MOUNTS,
161
- EDGE_HOST_STATE_DIR,
162
- OPENRESTY_DEFAULT_PATHS
163
- } from "./chunk-YIPMDGG6.js";
160
+ ENVIRONMENT_PROBE_SCRIPT,
161
+ ENVIRONMENT_PROBE_TIMEOUT_MS,
162
+ cacheEnvironment,
163
+ parseEnvironmentProbe,
164
+ profileIsFresh
165
+ } from "./chunk-UVNMQM7W.js";
164
166
  import {
165
- DEFAULT_IMAGE_REGISTRY,
167
+ FAMILY_BY_DISTRO,
166
168
  isAwsS3Endpoint,
167
169
  safeErrorMessage,
168
170
  withTimeout
169
- } from "./chunk-OTLSJYAC.js";
170
-
171
- // src/lib/edge-import.ts
172
- import chalk from "chalk";
173
- import ora from "ora";
171
+ } from "./chunk-UJ6JZM65.js";
174
172
 
175
173
  // ../../packages/adapters/src/runtime/image-transfer.ts
176
174
  import { Transform } from "stream";
@@ -194,8 +192,10 @@ var MAIL_CONTAINER_MOUNTS = [
194
192
  { host: `${MAIL_HOST_STATE_DIR}/config/dovecot`, container: "/etc/dovecot", seed: true },
195
193
  { host: `${MAIL_HOST_STATE_DIR}/config/amavis`, container: "/etc/amavis/conf.d", seed: true },
196
194
  // 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" },
195
+ // freshclam re-download and delay readiness. `seed: true` because that same mount
196
+ // HIDES the database baked into the image: with nothing copying it across, clamd
197
+ // finds an empty database directory and exits (issue #565).
198
+ { host: `${MAIL_HOST_STATE_DIR}/clamav`, container: "/var/lib/clamav", seed: true },
199
199
  { host: "/etc/letsencrypt", container: "/etc/letsencrypt", readonly: true }
200
200
  ];
201
201
  var MAIL_DB_HOST_DATA_DIR = `${MAIL_HOST_STATE_DIR}/pgdata`;
@@ -204,6 +204,11 @@ var MAIL_DB_PGDATA = `${MAIL_DB_CONTAINER_DATA_DIR}/pgdata`;
204
204
  var MAIL_HOST_PATHS = {
205
205
  saslPasswd: `${MAIL_HOST_STATE_DIR}/config/postfix/sasl_passwd`,
206
206
  senderRelayhost: `${MAIL_HOST_STATE_DIR}/config/postfix/sender_relayhost`,
207
+ // Per-nexthop TLS policy. Only split ("selected"-scope) relaying writes it: the
208
+ // relay hop must be `encrypt` so SASL credentials never cross a plaintext
209
+ // connection, while direct-to-MX delivery for the un-relayed domains stays
210
+ // opportunistic. A GLOBAL smtp_tls_security_level can't express both.
211
+ relayTlsPolicy: `${MAIL_HOST_STATE_DIR}/config/postfix/openship_tls_policy`,
207
212
  amavisUserConf: `${MAIL_HOST_STATE_DIR}/config/amavis/50-user`
208
213
  };
209
214
 
@@ -212,6 +217,63 @@ var MAIL_DB_IMAGE = process.env.OPENSHIP_MAIL_DB_IMAGE?.trim() || "postgres:16-a
212
217
  var ENGINE_ENV_FILE = `${MAIL_HOST_STATE_DIR}/engine.env`;
213
218
  var DB_ENV_FILE = `${MAIL_HOST_STATE_DIR}/db.env`;
214
219
 
220
+ // ../../packages/adapters/src/system/environment-local.ts
221
+ import { spawnSync } from "child_process";
222
+ var cached = null;
223
+ function unmeasurable(reason) {
224
+ return {
225
+ ...parseEnvironmentProbe({ text: "", failed: true, unanswered: true }),
226
+ unsupportedReason: reason
227
+ };
228
+ }
229
+ function probeLocalHost() {
230
+ if (process.platform === "win32") {
231
+ return unmeasurable(
232
+ "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."
233
+ );
234
+ }
235
+ const result = spawnSync("sh", ["-c", ENVIRONMENT_PROBE_SCRIPT], {
236
+ encoding: "utf8",
237
+ timeout: ENVIRONMENT_PROBE_TIMEOUT_MS,
238
+ // `sh -c` and not the login shell: `wrapLocalBuildCommand`'s `-lc` exists so user
239
+ // builds see a normal profile, but a host probe wants the plain POSIX behaviour the
240
+ // remote path gets, without a dotfile's `echo` in the middle of the output.
241
+ windowsHide: true
242
+ });
243
+ if (result.error) {
244
+ return unmeasurable(
245
+ `Openship could not run a shell on this machine to detect it: ${safeErrorMessage(result.error)}`
246
+ );
247
+ }
248
+ if (result.signal || typeof result.status !== "number") {
249
+ return unmeasurable(
250
+ `Openship could not measure this machine: the detection probe was killed${result.signal ? ` by ${result.signal}` : ""} before it finished.`
251
+ );
252
+ }
253
+ const stdout = result.stdout ?? "";
254
+ return parseEnvironmentProbe({
255
+ // The script always exits 0, so a non-zero status means something ran instead of it
256
+ // — the same signal the SSH path uses, and stderr is what said so.
257
+ text: stdout.trim() ? stdout : result.stderr ?? "",
258
+ failed: result.status !== 0,
259
+ // Every way of not answering is refused above, so the shell exited on its own and
260
+ // whatever it said is this machine's.
261
+ unanswered: false
262
+ });
263
+ }
264
+ function resolveLocalEnvironmentSync() {
265
+ if (cached && profileIsFresh(cached.measuredAt)) return cached.profile;
266
+ const profile = probeLocalHost();
267
+ cached = { measuredAt: Date.now(), profile };
268
+ cacheEnvironment(createExecutor(), profile);
269
+ return profile;
270
+ }
271
+
272
+ // ../../packages/adapters/src/system/modules/types.ts
273
+ var TARGETABLE_FAMILIES = new Set(
274
+ Object.values(FAMILY_BY_DISTRO)
275
+ );
276
+
215
277
  // ../../packages/adapters/src/system/modules/verify.ts
216
278
  import { createHash, createPublicKey, verify as cryptoVerify } from "crypto";
217
279
 
@@ -219,10 +281,6 @@ import { createHash, createPublicKey, verify as cryptoVerify } from "crypto";
219
281
  var CATALOG_BASE_URL = process.env.OPENSHIP_MODULE_CATALOG_BASE_URL?.trim() || "https://raw.githubusercontent.com/openship/native-modules";
220
282
  var CATALOG_REF = process.env.OPENSHIP_MODULE_CATALOG_REF?.trim() || "main";
221
283
 
222
- // ../../packages/adapters/src/system/reachability.ts
223
- import { connect } from "net";
224
- import { request } from "http";
225
-
226
284
  // ../../packages/adapters/src/backup/registry.ts
227
285
  var executorFactories = /* @__PURE__ */ new Map();
228
286
  function registerExecutor(runtimeName, factory) {
@@ -747,12 +805,14 @@ var DockerBackupExecutor = class {
747
805
  try {
748
806
  const data = await this.dockerode.getContainer(service.containerId).inspect();
749
807
  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
- }));
808
+ return mounts.filter((m2) => m2.Type === "volume" || m2.Type === "bind").map(
809
+ (m2, i2) => ({
810
+ id: m2.Name ?? m2.Source ?? `mount-${i2}`,
811
+ target: m2.Destination ?? "",
812
+ source: m2.Name ?? m2.Source ?? "",
813
+ type: m2.Type ?? "volume"
814
+ })
815
+ );
756
816
  } catch {
757
817
  }
758
818
  }
@@ -784,7 +844,9 @@ var DockerBackupExecutor = class {
784
844
  Env: opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${v2}`) : void 0
785
845
  });
786
846
  const stream = await exec.start({ stdin: false });
787
- return this.attachDemuxed(this.dockerode, exec.id, stream, opts?.timeoutMs);
847
+ return this.attachDemuxed(this.dockerode, exec.id, stream, opts, {
848
+ label: `exec in service ${service.name}`
849
+ });
788
850
  }
789
851
  async streamPath(service, sourceId, opts) {
790
852
  const sources = await this.listSources(service);
@@ -796,10 +858,7 @@ var DockerBackupExecutor = class {
796
858
  throw new Error(`Backup source "${sourceId}" is tmpfs \u2014 not backupable`);
797
859
  }
798
860
  const compression = opts?.compression ?? "zstd";
799
- const excludeArgs = (opts?.exclude ?? []).flatMap((p2) => [
800
- "--exclude",
801
- shellEscape4(p2)
802
- ]);
861
+ const excludeArgs = (opts?.exclude ?? []).flatMap((p2) => ["--exclude", shellEscape4(p2)]);
803
862
  const tarFlags = compressionFlag(compression);
804
863
  const tarCmd = compression === "zstd" ? `tar -c${tarFlags} -C /mnt ${excludeArgs.join(" ")} . | zstd -c -3` : `tar -c${tarFlags} -C /mnt ${excludeArgs.join(" ")} .`;
805
864
  const helperImage = compression === "zstd" ? "alpine:3" : HELPER_IMAGE;
@@ -824,7 +883,11 @@ var DockerBackupExecutor = class {
824
883
  return this.handOffHelper(
825
884
  {
826
885
  Image: helperImage,
827
- Cmd: ["sh", "-c", compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${tarCmd}` : tarCmd],
886
+ Cmd: [
887
+ "sh",
888
+ "-c",
889
+ compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${tarCmd}` : tarCmd
890
+ ],
828
891
  HostConfig: hostConfig,
829
892
  AttachStdout: true,
830
893
  AttachStderr: true,
@@ -1170,7 +1233,7 @@ var DockerBackupExecutor = class {
1170
1233
  } catch {
1171
1234
  }
1172
1235
  }, opts.timeoutMs) : null;
1173
- return new Promise((resolve4, reject) => {
1236
+ return new Promise((resolve2, reject) => {
1174
1237
  body.on("error", (err) => {
1175
1238
  if (timer) clearTimeout(timer);
1176
1239
  try {
@@ -1182,7 +1245,7 @@ var DockerBackupExecutor = class {
1182
1245
  stream.on("end", async () => {
1183
1246
  if (timer) clearTimeout(timer);
1184
1247
  try {
1185
- resolve4({
1248
+ resolve2({
1186
1249
  code: await resolveExecExitCode(exec, exec.id),
1187
1250
  stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
1188
1251
  });
@@ -1230,7 +1293,10 @@ var DockerBackupExecutor = class {
1230
1293
  }
1231
1294
  /** dockerode `exec.start` returns a multiplexed stream — stdout +
1232
1295
  * stderr interleaved with frame headers. demux into clean streams. */
1233
- attachDemuxed(docker, execId, stream, timeoutMs) {
1296
+ attachDemuxed(docker, execId, stream, opts, meta) {
1297
+ const label = meta?.label ?? "exec";
1298
+ const idleMs = opts?.idleTimeoutMs ?? DEFAULT_HELPER_IDLE_MS;
1299
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS;
1234
1300
  const stdout = new PassThrough();
1235
1301
  const stderrChunks = [];
1236
1302
  const stderrSink = new PassThrough();
@@ -1238,27 +1304,47 @@ var DockerBackupExecutor = class {
1238
1304
  if (stderrChunks.length < 16) stderrChunks.push(chunk);
1239
1305
  });
1240
1306
  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);
1307
+ let sinksEnded = false;
1308
+ const endSinks = () => {
1309
+ if (sinksEnded) return;
1310
+ sinksEnded = true;
1311
+ stdout.end();
1312
+ stderrSink.end();
1313
+ };
1314
+ stream.on("end", endSinks);
1315
+ stream.on("close", endSinks);
1316
+ const watchdog = createIdleWatchdog(
1317
+ idleMs,
1318
+ `${label} produced no data for ${Math.round(idleMs / 1e3)}s and was abandoned.`
1319
+ );
1320
+ stream.on("data", () => watchdog.touch());
1321
+ const awaitExit = (async () => {
1322
+ const onEnd = new Promise((resolve2, reject) => {
1323
+ stream.on("end", async () => {
1324
+ try {
1325
+ resolve2({
1326
+ code: await resolveExecExitCode(docker.getExec(execId), execId),
1327
+ stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
1328
+ });
1329
+ } catch (err) {
1330
+ reject(err);
1331
+ }
1332
+ });
1333
+ stream.on("error", (err) => reject(err));
1260
1334
  });
1261
- });
1335
+ try {
1336
+ return await withTimeout(
1337
+ Promise.race([onEnd, watchdog.promise]),
1338
+ timeoutMs,
1339
+ `${label} exceeded its ${Math.round(timeoutMs / 1e3)}s ceiling and was abandoned.`
1340
+ );
1341
+ } catch (err) {
1342
+ stdout.destroy(err);
1343
+ throw err;
1344
+ } finally {
1345
+ watchdog.dispose();
1346
+ }
1347
+ })();
1262
1348
  return { stdout, awaitExit };
1263
1349
  }
1264
1350
  demuxContainerStream(container, stream, opts) {
@@ -1359,7 +1445,7 @@ var CloudBackupExecutor = class {
1359
1445
  const stdout = new Readable2({ read() {
1360
1446
  } });
1361
1447
  let stderrBuf = "";
1362
- const awaitExit = new Promise((resolve4, reject) => {
1448
+ const awaitExit = new Promise((resolve2, reject) => {
1363
1449
  const timer = opts?.timeoutMs ? setTimeout(() => {
1364
1450
  stdout.destroy(new Error(`exec timed out after ${opts.timeoutMs}ms`));
1365
1451
  reject(new Error(`exec timed out after ${opts.timeoutMs}ms`));
@@ -1392,7 +1478,7 @@ var CloudBackupExecutor = class {
1392
1478
  }
1393
1479
  stdout.push(null);
1394
1480
  if (timer) clearTimeout(timer);
1395
- resolve4({ code: exitCode, stderr: stderrBuf });
1481
+ resolve2({ code: exitCode, stderr: stderrBuf });
1396
1482
  } catch (err) {
1397
1483
  if (timer) clearTimeout(timer);
1398
1484
  stdout.destroy(err);
@@ -2370,8 +2456,8 @@ var flexibleChecksumsMiddleware = (config, middlewareConfig) => (next, context)
2370
2456
  if (hasHeaderWithPrefix("x-amz-checksum-", args.request.headers)) {
2371
2457
  return next(args);
2372
2458
  }
2373
- const { request: request2, input } = args;
2374
- const { body: requestBody, headers } = request2;
2459
+ const { request, input } = args;
2460
+ const { body: requestBody, headers } = request;
2375
2461
  const { base64Encoder, streamHasher } = config;
2376
2462
  const { requestChecksumRequired, requestAlgorithmMember } = middlewareConfig;
2377
2463
  const requestChecksumCalculation = await config.requestChecksumCalculation();
@@ -2442,7 +2528,7 @@ var flexibleChecksumsMiddleware = (config, middlewareConfig) => (next, context)
2442
2528
  const result = await next({
2443
2529
  ...args,
2444
2530
  request: {
2445
- ...request2,
2531
+ ...request,
2446
2532
  headers: updatedHeaders,
2447
2533
  body: updatedBody
2448
2534
  }
@@ -2638,9 +2724,9 @@ var CONTENT_LENGTH_HEADER = "content-length";
2638
2724
  var DECODED_CONTENT_LENGTH_HEADER = "x-amz-decoded-content-length";
2639
2725
  function checkContentLengthHeader() {
2640
2726
  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)) {
2727
+ const { request } = args;
2728
+ if (HttpRequest.isInstance(request)) {
2729
+ if (!(CONTENT_LENGTH_HEADER in request.headers) && !(DECODED_CONTENT_LENGTH_HEADER in request.headers)) {
2644
2730
  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
2731
  if (typeof context?.logger?.warn === "function" && !(context.logger instanceof NoOpLogger)) {
2646
2732
  context.logger.warn(message);
@@ -2956,8 +3042,8 @@ var getS3ExpressPlugin = (options) => ({
2956
3042
  });
2957
3043
 
2958
3044
  // ../../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, {});
3045
+ var signS3Express = async (s3ExpressIdentity, signingOptions, request, sigV4MultiRegionSigner) => {
3046
+ const signedRequest = await sigV4MultiRegionSigner.signWithCredentials(request, s3ExpressIdentity, {});
2961
3047
  if (signedRequest.headers["X-Amz-Security-Token"] || signedRequest.headers["x-amz-security-token"]) {
2962
3048
  throw new Error("X-Amz-Security-Token must not be set for s3-express requests.");
2963
3049
  }
@@ -2980,15 +3066,15 @@ var s3ExpressHttpSigningMiddleware = (config) => (next, context) => async (args)
2980
3066
  throw new Error(`No HttpAuthScheme was selected: unable to sign request`);
2981
3067
  }
2982
3068
  const { httpAuthOption: { signingProperties = {} }, identity, signer } = scheme;
2983
- let request2;
3069
+ let request;
2984
3070
  if (context.s3ExpressIdentity) {
2985
- request2 = await signS3Express(context.s3ExpressIdentity, signingProperties, args.request, await config.signer());
3071
+ request = await signS3Express(context.s3ExpressIdentity, signingProperties, args.request, await config.signer());
2986
3072
  } else {
2987
- request2 = await signer.sign(args.request, identity, signingProperties);
3073
+ request = await signer.sign(args.request, identity, signingProperties);
2988
3074
  }
2989
3075
  const output = await next({
2990
3076
  ...args,
2991
- request: request2
3077
+ request
2992
3078
  }).catch((signer.errorHandler || defaultErrorHandler)(signingProperties));
2993
3079
  (signer.successHandler || defaultSuccessHandler)(output.response, signingProperties);
2994
3080
  return output;
@@ -3060,9 +3146,9 @@ var getThrow200ExceptionsPlugin = (config) => ({
3060
3146
  var validate = (str) => typeof str === "string" && str.indexOf("arn:") === 0 && str.split(":").length >= 6;
3061
3147
 
3062
3148
  // ../../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;
3149
+ function formatUrl(request) {
3150
+ const { port, query } = request;
3151
+ let { protocol, path, hostname } = request;
3066
3152
  if (protocol && protocol.slice(-1) !== ":") {
3067
3153
  protocol += ":";
3068
3154
  }
@@ -3077,14 +3163,14 @@ function formatUrl(request2) {
3077
3163
  queryString = `?${queryString}`;
3078
3164
  }
3079
3165
  let auth = "";
3080
- if (request2.username != null || request2.password != null) {
3081
- const username = request2.username ?? "";
3082
- const password = request2.password ?? "";
3166
+ if (request.username != null || request.password != null) {
3167
+ const username = request.username ?? "";
3168
+ const password = request.password ?? "";
3083
3169
  auth = `${username}:${password}@`;
3084
3170
  }
3085
3171
  let fragment = "";
3086
- if (request2.fragment) {
3087
- fragment = `#${request2.fragment}`;
3172
+ if (request.fragment) {
3173
+ fragment = `#${request.fragment}`;
3088
3174
  }
3089
3175
  return `${protocol}//${auth}${hostname}${path}${queryString}${fragment}`;
3090
3176
  }
@@ -3477,18 +3563,18 @@ var AwsRestXmlProtocol = class extends HttpBindingProtocol {
3477
3563
  return "aws.protocols#restXml";
3478
3564
  }
3479
3565
  async serializeRequest(operationSchema, input, context) {
3480
- const request2 = await super.serializeRequest(operationSchema, input, context);
3566
+ const request = await super.serializeRequest(operationSchema, input, context);
3481
3567
  const inputSchema = NormalizedSchema.of(operationSchema.input);
3482
- if (!request2.headers["content-type"]) {
3568
+ if (!request.headers["content-type"]) {
3483
3569
  const contentType = this.mixin.resolveRestContentType(this.getDefaultContentType(), inputSchema);
3484
3570
  if (contentType) {
3485
- request2.headers["content-type"] = contentType;
3571
+ request.headers["content-type"] = contentType;
3486
3572
  }
3487
3573
  }
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;
3574
+ if (typeof request.body === "string" && request.headers["content-type"] === this.getDefaultContentType() && !request.body.startsWith("<?xml ") && !this.hasUnstructuredPayloadBinding(inputSchema)) {
3575
+ request.body = '<?xml version="1.0" encoding="UTF-8"?>' + request.body;
3490
3576
  }
3491
- return request2;
3577
+ return request;
3492
3578
  }
3493
3579
  async deserializeResponse(operationSchema, context, response) {
3494
3580
  return super.deserializeResponse(operationSchema, context, response);
@@ -3541,7 +3627,7 @@ var AwsRestXmlProtocol = class extends HttpBindingProtocol {
3541
3627
  // ../../node_modules/@aws-sdk/middleware-sdk-s3/dist-es/submodules/s3/protocol/S3RestXmlProtocol.js
3542
3628
  var S3RestXmlProtocol = class extends AwsRestXmlProtocol {
3543
3629
  async serializeRequest(operationSchema, input, context) {
3544
- const request2 = await super.serializeRequest(operationSchema, input, context);
3630
+ const request = await super.serializeRequest(operationSchema, input, context);
3545
3631
  const ns = NormalizedSchema.of(operationSchema.input);
3546
3632
  const staticStructureSchema = ns.getSchema();
3547
3633
  let bucketMemberIndex = 0;
@@ -3559,7 +3645,7 @@ var S3RestXmlProtocol = class extends AwsRestXmlProtocol {
3559
3645
  }
3560
3646
  }
3561
3647
  }
3562
- return request2;
3648
+ return request;
3563
3649
  }
3564
3650
  };
3565
3651
 
@@ -3575,12 +3661,12 @@ var NODE_USE_ARN_REGION_CONFIG_OPTIONS = {
3575
3661
  // ../../node_modules/@aws-sdk/middleware-sdk-s3/dist-es/submodules/s3/middleware-expect-continue/middleware-expect-continue.js
3576
3662
  function addExpectContinueMiddleware(options) {
3577
3663
  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") {
3664
+ const { request } = args;
3665
+ if (options.expectContinueHeader !== false && HttpRequest.isInstance(request) && request.body && options.runtime === "node" && options.requestHandler?.constructor?.name !== "FetchHttpHandler") {
3580
3666
  let sendHeader = true;
3581
3667
  if (typeof options.expectContinueHeader === "number") {
3582
3668
  try {
3583
- const bodyLength = Number(request2.headers?.["content-length"]) ?? options.bodyLengthChecker?.(request2.body) ?? Infinity;
3669
+ const bodyLength = Number(request.headers?.["content-length"]) ?? options.bodyLengthChecker?.(request.body) ?? Infinity;
3584
3670
  sendHeader = bodyLength >= options.expectContinueHeader;
3585
3671
  } catch (e2) {
3586
3672
  }
@@ -3588,12 +3674,12 @@ function addExpectContinueMiddleware(options) {
3588
3674
  sendHeader = !!options.expectContinueHeader;
3589
3675
  }
3590
3676
  if (sendHeader) {
3591
- request2.headers.Expect = "100-continue";
3677
+ request.headers.Expect = "100-continue";
3592
3678
  }
3593
3679
  }
3594
3680
  return next({
3595
3681
  ...args,
3596
- request: request2
3682
+ request
3597
3683
  });
3598
3684
  };
3599
3685
  }
@@ -11398,7 +11484,7 @@ var defaultProvider = (init = {}) => memoizeChain([
11398
11484
  },
11399
11485
  async (awsIdentityProperties) => {
11400
11486
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromIni");
11401
- const { fromIni } = await import("./dist-es-JGBPABJI.js");
11487
+ const { fromIni } = await import("./dist-es-AXFRS7PS.js");
11402
11488
  return fromIni(init)(awsIdentityProperties);
11403
11489
  },
11404
11490
  async (awsIdentityProperties) => {
@@ -11450,14 +11536,14 @@ var readableStreamHasher = (hashCtor, readableStream) => {
11450
11536
  const hash = new hashCtor();
11451
11537
  const hashCalculator = new HashCalculator(hash);
11452
11538
  readableStream.pipe(hashCalculator);
11453
- return new Promise((resolve4, reject) => {
11539
+ return new Promise((resolve2, reject) => {
11454
11540
  readableStream.on("error", (err) => {
11455
11541
  hashCalculator.end();
11456
11542
  reject(err);
11457
11543
  });
11458
11544
  hashCalculator.on("error", reject);
11459
11545
  hashCalculator.on("finish", () => {
11460
- hash.digest().then(resolve4).catch(reject);
11546
+ hash.digest().then(resolve2).catch(reject);
11461
11547
  });
11462
11548
  });
11463
11549
  };
@@ -11890,8 +11976,8 @@ var BYTE_LENGTH_SOURCE;
11890
11976
  BYTE_LENGTH_SOURCE2["START_END_DIFF"] = "the numeric difference between Body.start and Body.end";
11891
11977
  BYTE_LENGTH_SOURCE2["LSTAT"] = "the size of the file given by Body.path on disk as reported by lstatSync";
11892
11978
  })(BYTE_LENGTH_SOURCE || (BYTE_LENGTH_SOURCE = {}));
11893
- var byteLengthSource = (input, override2) => {
11894
- if (override2 != null) {
11979
+ var byteLengthSource = (input, override) => {
11980
+ if (override != null) {
11895
11981
  return BYTE_LENGTH_SOURCE.CONTENT_LENGTH;
11896
11982
  }
11897
11983
  if (input == null) {
@@ -12187,8 +12273,8 @@ var Upload = class _Upload extends EventEmitter {
12187
12273
  const requestHandler = this.client.config.requestHandler;
12188
12274
  const eventEmitter = requestHandler instanceof EventEmitter ? requestHandler : null;
12189
12275
  let lastSeenBytes = 0;
12190
- const uploadEventListener = (event, request2) => {
12191
- const requestPartSize = Number(request2.query["partNumber"]) || -1;
12276
+ const uploadEventListener = (event, request) => {
12277
+ const requestPartSize = Number(request.query["partNumber"]) || -1;
12192
12278
  if (requestPartSize !== dataPart.partNumber) {
12193
12279
  return;
12194
12280
  }
@@ -12313,7 +12399,7 @@ to input.params.ContentLength in bytes.
12313
12399
  }
12314
12400
  }
12315
12401
  async __abortTimeout(abortSignal) {
12316
- return new Promise((resolve4, reject) => {
12402
+ return new Promise((resolve2, reject) => {
12317
12403
  abortSignal.onabort = () => {
12318
12404
  const abortError = new Error("Upload aborted.");
12319
12405
  abortError.name = "AbortError";
@@ -12426,13 +12512,13 @@ var getSignedUrl = async (client, command, options = {}) => {
12426
12512
  s3Presigner = new S3RequestPresigner(client.config);
12427
12513
  }
12428
12514
  const presignInterceptMiddleware = (next, context) => async (args) => {
12429
- const { request: request2 } = args;
12430
- if (!HttpRequest.isInstance(request2)) {
12515
+ const { request } = args;
12516
+ if (!HttpRequest.isInstance(request)) {
12431
12517
  throw new Error("Request to be presigned is not an valid HTTP request.");
12432
12518
  }
12433
- delete request2.headers["amz-sdk-invocation-id"];
12434
- delete request2.headers["amz-sdk-request"];
12435
- delete request2.headers["x-amz-user-agent"];
12519
+ delete request.headers["amz-sdk-invocation-id"];
12520
+ delete request.headers["amz-sdk-request"];
12521
+ delete request.headers["x-amz-user-agent"];
12436
12522
  let presigned2;
12437
12523
  const presignerOptions = {
12438
12524
  ...options,
@@ -12440,9 +12526,9 @@ var getSignedUrl = async (client, command, options = {}) => {
12440
12526
  signingService: options.signingService ?? context["signing_service"]
12441
12527
  };
12442
12528
  if (context.s3ExpressIdentity) {
12443
- presigned2 = await s3Presigner.presignWithCredentials(request2, context.s3ExpressIdentity, presignerOptions);
12529
+ presigned2 = await s3Presigner.presignWithCredentials(request, context.s3ExpressIdentity, presignerOptions);
12444
12530
  } else {
12445
- presigned2 = await s3Presigner.presign(request2, presignerOptions);
12531
+ presigned2 = await s3Presigner.presign(request, presignerOptions);
12446
12532
  }
12447
12533
  return {
12448
12534
  response: {},
@@ -12680,6 +12766,8 @@ var CAPS3 = /* @__PURE__ */ new Set([
12680
12766
  "streamingPut",
12681
12767
  "streamingGet"
12682
12768
  ]);
12769
+ var UPLOAD_STALL_TIMEOUT_MS = 10 * 60 * 1e3;
12770
+ var UPLOAD_STALL_PROBE_MS = 30 * 1e3;
12683
12771
  var SftpDestinationImpl = class {
12684
12772
  kind;
12685
12773
  capabilities = CAPS3;
@@ -12727,7 +12815,7 @@ var SftpDestinationImpl = class {
12727
12815
  // ── Connection helper ────────────────────────────────────────────────
12728
12816
  async withSftp(fn) {
12729
12817
  const client = new Client2();
12730
- return new Promise((resolve4, reject) => {
12818
+ return new Promise((resolve2, reject) => {
12731
12819
  const cleanup = () => {
12732
12820
  try {
12733
12821
  client.end();
@@ -12744,7 +12832,7 @@ var SftpDestinationImpl = class {
12744
12832
  fn(sftp).then(
12745
12833
  (val) => {
12746
12834
  cleanup();
12747
- resolve4(val);
12835
+ resolve2(val);
12748
12836
  },
12749
12837
  (e2) => {
12750
12838
  cleanup();
@@ -12764,12 +12852,12 @@ var SftpDestinationImpl = class {
12764
12852
  let current = dir.startsWith("/") ? "" : ".";
12765
12853
  for (const part of parts) {
12766
12854
  current = current === "" ? `/${part}` : posix2.join(current, part);
12767
- await new Promise((resolve4, reject) => {
12855
+ await new Promise((resolve2, reject) => {
12768
12856
  sftp.mkdir(current, (err) => {
12769
- if (!err) return resolve4();
12857
+ if (!err) return resolve2();
12770
12858
  sftp.stat(current, (statErr, stats) => {
12771
12859
  if (statErr) return reject(err);
12772
- if (stats.isDirectory()) return resolve4();
12860
+ if (stats.isDirectory()) return resolve2();
12773
12861
  reject(new Error(`${current} exists but is not a directory`));
12774
12862
  });
12775
12863
  });
@@ -12783,14 +12871,14 @@ var SftpDestinationImpl = class {
12783
12871
  await this.withSftp(async (sftp) => {
12784
12872
  await this.ensureDir(sftp, this.rootPath);
12785
12873
  const probePath = posix2.join(this.rootPath, probeName);
12786
- await new Promise((resolve4, reject) => {
12874
+ await new Promise((resolve2, reject) => {
12787
12875
  const ws = sftp.createWriteStream(probePath);
12788
12876
  ws.on("error", reject);
12789
- ws.on("close", () => resolve4());
12877
+ ws.on("close", () => resolve2());
12790
12878
  ws.end("ok");
12791
12879
  });
12792
12880
  await new Promise(
12793
- (resolve4, reject) => sftp.unlink(probePath, (err) => err ? reject(err) : resolve4())
12881
+ (resolve2, reject) => sftp.unlink(probePath, (err) => err ? reject(err) : resolve2())
12794
12882
  );
12795
12883
  });
12796
12884
  return { ok: true };
@@ -12807,27 +12895,49 @@ var SftpDestinationImpl = class {
12807
12895
  let bytesWritten = 0;
12808
12896
  await this.withSftp(async (sftp) => {
12809
12897
  await this.ensureDir(sftp, posix2.dirname(target));
12810
- await new Promise((resolve4, reject) => {
12898
+ await new Promise((resolve2, reject) => {
12811
12899
  const ws = sftp.createWriteStream(tmp);
12812
- ws.on("error", reject);
12813
- ws.on("close", () => resolve4());
12900
+ let lastProgressAt = Date.now();
12901
+ let settled = false;
12902
+ let watchdog;
12903
+ const finish = (err) => {
12904
+ if (settled) return;
12905
+ settled = true;
12906
+ if (watchdog) clearInterval(watchdog);
12907
+ if (err) {
12908
+ ws.destroy();
12909
+ body.destroy();
12910
+ reject(err);
12911
+ } else {
12912
+ resolve2();
12913
+ }
12914
+ };
12915
+ watchdog = setInterval(() => {
12916
+ if (settled || Date.now() - lastProgressAt <= UPLOAD_STALL_TIMEOUT_MS) return;
12917
+ finish(
12918
+ new Error(
12919
+ `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.`
12920
+ )
12921
+ );
12922
+ }, UPLOAD_STALL_PROBE_MS);
12923
+ watchdog.unref?.();
12924
+ ws.on("error", (err) => finish(err));
12925
+ ws.on("close", () => finish());
12814
12926
  body.on("data", (chunk) => {
12815
12927
  bytesWritten += chunk.byteLength;
12928
+ lastProgressAt = Date.now();
12816
12929
  });
12817
- body.on("error", (err) => {
12818
- ws.destroy();
12819
- reject(err);
12820
- });
12930
+ body.on("error", (err) => finish(err));
12821
12931
  body.pipe(ws);
12822
12932
  });
12823
- await new Promise((resolve4, reject) => {
12933
+ await new Promise((resolve2, reject) => {
12824
12934
  sftp.rename(tmp, target, (err) => {
12825
- if (!err) return resolve4();
12935
+ if (!err) return resolve2();
12826
12936
  sftp.unlink(target, () => {
12827
12937
  sftp.rename(
12828
12938
  tmp,
12829
12939
  target,
12830
- (err2) => err2 ? reject(err2) : resolve4()
12940
+ (err2) => err2 ? reject(err2) : resolve2()
12831
12941
  );
12832
12942
  });
12833
12943
  });
@@ -12874,14 +12984,14 @@ var SftpDestinationImpl = class {
12874
12984
  const target = this.fullPath(key);
12875
12985
  try {
12876
12986
  return await this.withSftp(
12877
- (sftp) => new Promise((resolve4, reject) => {
12987
+ (sftp) => new Promise((resolve2, reject) => {
12878
12988
  sftp.stat(target, (err, stats) => {
12879
12989
  if (err) {
12880
12990
  const code = err.code;
12881
- if (code === 2) return resolve4(null);
12991
+ if (code === 2) return resolve2(null);
12882
12992
  return reject(err);
12883
12993
  }
12884
- resolve4({
12994
+ resolve2({
12885
12995
  sizeBytes: stats.size,
12886
12996
  uploadedAt: new Date(stats.mtime * 1e3)
12887
12997
  });
@@ -12898,14 +13008,14 @@ var SftpDestinationImpl = class {
12898
13008
  const entries = [];
12899
13009
  await this.withSftp(async (sftp) => {
12900
13010
  const walk = async (dir, relBase) => {
12901
- const dirents = await new Promise((resolve4, reject) => {
13011
+ const dirents = await new Promise((resolve2, reject) => {
12902
13012
  sftp.readdir(dir, (err, list) => {
12903
13013
  if (err) {
12904
13014
  const code = err.code;
12905
- if (code === 2) return resolve4([]);
13015
+ if (code === 2) return resolve2([]);
12906
13016
  return reject(err);
12907
13017
  }
12908
- resolve4(list);
13018
+ resolve2(list);
12909
13019
  });
12910
13020
  });
12911
13021
  for (const dirent of dirents) {
@@ -12931,10 +13041,10 @@ var SftpDestinationImpl = class {
12931
13041
  const target = this.fullPath(key);
12932
13042
  await this.withSftp(
12933
13043
  (sftp) => new Promise(
12934
- (resolve4, reject) => sftp.unlink(target, (err) => {
12935
- if (!err) return resolve4();
13044
+ (resolve2, reject) => sftp.unlink(target, (err) => {
13045
+ if (!err) return resolve2();
12936
13046
  const code = err.code;
12937
- if (code === 2) return resolve4();
13047
+ if (code === 2) return resolve2();
12938
13048
  reject(err);
12939
13049
  })
12940
13050
  )
@@ -12946,11 +13056,11 @@ var SftpDestinationImpl = class {
12946
13056
  const failed = [];
12947
13057
  await this.withSftp(async (sftp) => {
12948
13058
  for (const key of keys) {
12949
- await new Promise((resolve4) => {
13059
+ await new Promise((resolve2) => {
12950
13060
  sftp.unlink(this.fullPath(key), (err) => {
12951
13061
  if (!err) {
12952
13062
  deleted.push(key);
12953
- return resolve4();
13063
+ return resolve2();
12954
13064
  }
12955
13065
  const code = err.code;
12956
13066
  if (code === 2) {
@@ -12958,7 +13068,7 @@ var SftpDestinationImpl = class {
12958
13068
  } else {
12959
13069
  failed.push({ key, error: err.message });
12960
13070
  }
12961
- resolve4();
13071
+ resolve2();
12962
13072
  });
12963
13073
  });
12964
13074
  }
@@ -12969,1311 +13079,52 @@ var SftpDestinationImpl = class {
12969
13079
  registerDestination("sftp", (row) => new SftpDestinationImpl(row));
12970
13080
  registerDestination("openship_server", (row) => new SftpDestinationImpl(row));
12971
13081
 
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
- });
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
- }
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
-
13166
- // src/lib/from-source.ts
13167
- import { spawn, spawnSync } from "child_process";
13168
- import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
13169
- import { join as join5, resolve as resolve3 } from "path";
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");
13173
- function has(cmd) {
13174
- try {
13175
- return spawnSync(cmd, ["--version"], { stdio: "ignore" }).status === 0;
13176
- } catch {
13177
- return false;
13178
- }
13179
- }
13180
- function run(cmd, args, cwd, env) {
13181
- return new Promise((res, rej) => {
13182
- const child = spawn(cmd, args, {
13183
- cwd,
13184
- stdio: "inherit",
13185
- // Windows resolves the bun/git shims (.cmd/.exe) only through a shell.
13186
- shell: process.platform === "win32",
13187
- env: env ? { ...process.env, ...env } : process.env
13188
- });
13189
- child.on("error", rej);
13190
- child.on(
13191
- "exit",
13192
- (code) => code === 0 ? res() : rej(new Error(`\`${cmd} ${args.join(" ")}\` (cwd=${cwd}) exited ${code ?? "?"}`))
13193
- );
13194
- });
13195
- }
13196
- function shortSha(cwd) {
13197
- const r2 = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd, encoding: "utf8" });
13198
- return r2.status === 0 ? (r2.stdout ?? "").trim() || "unknown" : "unknown";
13199
- }
13200
- function isMonorepo(dir) {
13201
- return existsSync2(join5(dir, "package.json")) && existsSync2(join5(dir, "apps/api/package.json")) && existsSync2(join5(dir, "apps/dashboard/package.json"));
13202
- }
13203
- async function prepareFromSource(opts) {
13204
- if (!has("bun")) {
13205
- throw new Error(
13206
- "`bun` is required to build from source but wasn't found on PATH \u2014 install it: https://bun.sh"
13207
- );
13208
- }
13209
- let sourceDir;
13210
- let ref;
13211
- if (opts.source) {
13212
- sourceDir = resolve3(opts.source);
13213
- if (!isMonorepo(sourceDir)) {
13214
- throw new Error(
13215
- `--source ${sourceDir} doesn't look like an Openship checkout (missing package.json / apps/api / apps/dashboard).`
13216
- );
13217
- }
13218
- ref = "local";
13219
- } else {
13220
- if (!has("git")) {
13221
- throw new Error("`git` is required to clone the source but wasn't found on PATH.");
13222
- }
13223
- ref = (opts.ref || "main").trim();
13224
- const repoUrl = opts.repo || DEFAULT_REPO;
13225
- sourceDir = SOURCE_CHECKOUT_DIR;
13226
- mkdirSync2(OS_DIR, { recursive: true });
13227
- if (!existsSync2(join5(sourceDir, ".git"))) {
13228
- console.log(` Cloning ${repoUrl} \u2192 ${sourceDir}`);
13229
- await run("git", ["clone", repoUrl, sourceDir], OS_DIR);
13230
- }
13231
- console.log(` Fetching + checking out ${ref}`);
13232
- await run("git", ["fetch", "origin", ref, "--tags"], sourceDir);
13233
- await run("git", ["checkout", ref], sourceDir);
13234
- await run("git", ["pull", "--ff-only", "origin", ref], sourceDir).catch(() => {
13235
- console.log(" (pinned ref \u2014 not fast-forwarding)");
13236
- });
13237
- }
13238
- const sha = shortSha(sourceDir);
13239
- console.log(` Source: ${sourceDir} @ ${ref} (${sha})`);
13240
- console.log(" Installing workspace dependencies (bun install)\u2026");
13241
- await run("bun", ["install"], sourceDir);
13242
- const distDir = SOURCE_DIST_DIR;
13243
- console.log(" Building release dist (compiles the dashboard \u2014 needs RAM/CPU)\u2026");
13244
- await run(
13245
- "bun",
13246
- ["run", join5(sourceDir, "apps/api/scripts/build-release.ts")],
13247
- sourceDir,
13248
- { DIST_DIR: distDir, NODE_ENV: "production", CLOUD_MODE: "false", OPENSHIP_TARGET: "local" }
13249
- );
13250
- console.log(" Installing runtime dependencies in the dist\u2026");
13251
- 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"))) {
13255
- throw new Error(`Build produced no API at ${apiDir}/src/index.ts \u2014 build-release layout drift?`);
13256
- }
13257
- if (!existsSync2(join5(dashboardDir, "apps/dashboard/server.js"))) {
13258
- throw new Error(`Build produced no dashboard at ${dashboardDir}/apps/dashboard/server.js.`);
13259
- }
13260
- return { apiDir, dashboardDir, ref, sha, sourceDir };
13261
- }
13262
-
13263
- // src/lib/source-install.ts
13264
- var MARKER = join6(OS_DIR, "source-install.json");
13265
- function parseSourceInstall(raw) {
13266
- try {
13267
- const obj = JSON.parse(raw);
13268
- if (!obj || typeof obj.dir !== "string" || !obj.dir) return null;
13269
- return { repo: obj.repo || DEFAULT_REPO, ref: obj.ref || "main", dir: obj.dir };
13270
- } catch {
13271
- return null;
13272
- }
13273
- }
13274
- function readSourceInstall() {
13275
- if (!existsSync3(MARKER)) return null;
13276
- try {
13277
- return parseSourceInstall(readFileSync2(MARKER, "utf8"));
13278
- } catch {
13279
- return null;
13280
- }
13082
+ // src/lib/this-host.ts
13083
+ function thisHost() {
13084
+ return envOps(resolveLocalEnvironmentSync());
13281
13085
  }
13282
- function parseRemoteSha(stdout) {
13283
- const sha = stdout.split("\n")[0]?.trim().split(/\s+/)[0];
13284
- return sha && /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 7) : null;
13086
+ function pasteable(op) {
13087
+ if (!op.supported) return null;
13088
+ const sudo = thisHost().profile.isRoot ? "" : "sudo ";
13089
+ return op.value.map((step) => `${sudo}${step}`).join(" && ");
13285
13090
  }
13286
- function remoteSha(repo, ref) {
13287
- const r2 = spawnSync2("git", ["ls-remote", repo, ref], { encoding: "utf8" });
13288
- if (r2.status !== 0 || !r2.stdout) return null;
13289
- return parseRemoteSha(r2.stdout);
13290
- }
13291
- async function rebuildFromSource(info) {
13292
- if (!has("bun")) {
13293
- throw new Error("`bun` is required to rebuild from source but wasn't found on PATH \u2014 https://bun.sh");
13294
- }
13295
- if (!has("git")) {
13296
- throw new Error("`git` is required to update the source checkout but wasn't found on PATH.");
13297
- }
13298
- if (!existsSync3(join6(info.dir, ".git"))) {
13299
- throw new Error(
13300
- `Source checkout ${info.dir} is missing (no .git) \u2014 reinstall from source with scripts/install-source.sh.`
13301
- );
13302
- }
13303
- await run("git", ["fetch", "origin", info.ref, "--tags"], info.dir);
13304
- await run("git", ["checkout", info.ref], info.dir);
13305
- await run("git", ["pull", "--ff-only", "origin", info.ref], info.dir).catch(() => {
13306
- });
13307
- const cliDir = join6(info.dir, "apps/cli");
13308
- await run("bun", ["install"], info.dir);
13309
- await run("bun", ["run", "build"], cliDir);
13310
- await run("bun", ["run", "build/stage-dashboard.ts"], cliDir);
13311
- return shortSha(info.dir);
13091
+ function startUnitHint(unit) {
13092
+ return pasteable(thisHost().serviceEnableStart(unit));
13312
13093
  }
13313
13094
 
13314
- // src/lib/compose.ts
13315
- var EDGE_SITES_HOST_DIR = `${EDGE_HOST_STATE_DIR}/sites-enabled`;
13316
- var EDGE_ACME_HOST_DIR = `${EDGE_HOST_STATE_DIR}/acme`;
13317
- function edgeVolumeYaml(indent) {
13318
- return EDGE_CONTAINER_MOUNTS.map((m2) => `${indent}- ${m2.host}:${m2.container}:z`).join("\n");
13095
+ // src/lib/compose-env.ts
13096
+ import { existsSync, readFileSync } from "fs";
13097
+ import { join as join3 } from "path";
13098
+ var COMPOSE_DIR = join3(OS_DIR, "compose");
13099
+ var COMPOSE_FILE = join3(COMPOSE_DIR, "docker-compose.yml");
13100
+ var COMPOSE_ENV_FILE = join3(COMPOSE_DIR, ".env");
13101
+ function composeInstallExists() {
13102
+ return existsSync(COMPOSE_FILE);
13319
13103
  }
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");
13325
- var BUILT_SERVICES = [
13326
- { service: "api", dockerfile: "apps/api/Dockerfile" },
13327
- { service: "dashboard", dockerfile: "apps/dashboard/Dockerfile" },
13328
- { service: "edge", dockerfile: "apps/edge/Dockerfile" }
13329
- ];
13330
- var ENV_CONSUMING_SERVICES = ["api", "dashboard", "edge"];
13331
- function readInstallMethod() {
13104
+ function readComposeEnvFile() {
13105
+ let text;
13332
13106
  try {
13333
- const v2 = readFileSync3(INSTALL_METHOD_FILE, "utf8").trim();
13334
- return v2 === "compose" || v2 === "bare" ? v2 : null;
13335
- } catch {
13336
- return null;
13337
- }
13338
- }
13339
- function writeInstallMethod(method) {
13340
- mkdirSync3(OS_DIR, { recursive: true, mode: 448 });
13341
- writeFileSync2(INSTALL_METHOD_FILE, method, { mode: 384 });
13342
- }
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"]) };
13347
- }
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
- };
13107
+ text = readFileSync(COMPOSE_ENV_FILE, "utf8");
13108
+ } catch (err) {
13109
+ const code = err.code;
13110
+ return { env: {}, unreadable: code === "ENOENT" ? null : err.message };
13367
13111
  }
13368
- return null;
13369
- }
13370
- function hasDockerCompose() {
13371
- return dockerGap() === null;
13372
- }
13373
- function hasCmd(cmd) {
13374
- return spawnSync3(cmd, ["--version"], { stdio: "ignore" }).status === 0;
13375
- }
13376
- function shQuote(s2) {
13377
- return `'${s2.replace(/'/g, `'\\''`)}'`;
13378
- }
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 };
13383
- const plan = systemCatalog.installs.docker({
13384
- os: "linux",
13385
- serviceManager: "systemd"
13386
- });
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;
13408
- const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
13409
- const sudo = !asRoot && hasCmd("sudo") ? "sudo " : "";
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;
13440
- }
13441
- var COMPOSE_YAML = `# Managed by \`openship up\` \u2014 do not edit; re-run \`openship up\` to regenerate.
13442
- services:
13443
- postgres:
13444
- image: postgres:16-alpine
13445
- restart: unless-stopped
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}
13453
- POSTGRES_USER: \${POSTGRES_USER:-openship}
13454
- POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?missing from .env \u2014 re-run openship up to regenerate it}
13455
- POSTGRES_DB: \${POSTGRES_DB:-openship}
13456
- expose: ["5432"]
13457
- volumes: [postgres_data:/var/lib/postgresql/data]
13458
- healthcheck:
13459
- test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER:-openship} -d \${POSTGRES_DB:-openship}"]
13460
- interval: 5s
13461
- timeout: 3s
13462
- retries: 12
13463
-
13464
- redis:
13465
- image: redis:7-alpine
13466
- restart: unless-stopped
13467
- command: ["redis-server", "--appendonly", "yes"]
13468
- expose: ["6379"]
13469
- volumes: [redis_data:/data]
13470
- healthcheck:
13471
- test: ["CMD", "redis-cli", "ping"]
13472
- interval: 5s
13473
- timeout: 3s
13474
- retries: 12
13475
-
13476
- api:
13477
- image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-api:\${OPENSHIP_VERSION:-latest}
13478
- restart: unless-stopped
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}"]
13483
- volumes:
13484
- - /var/run/docker.sock:/var/run/docker.sock
13485
- # Routing state shared with the edge, as HOST BIND MOUNTS (generated from
13486
- # EDGE_CONTAINER_MOUNTS): the vhost tree, /etc/letsencrypt, the ACME webroot
13487
- # and the static doc-roots the API writes and the edge serves. Named volumes
13488
- # hid all of it from every host-side reader (migrate's proxy scan, cert carry,
13489
- # cert reuse, mail cert symlinks), each of which then silently saw "nothing".
13490
- ${edgeVolumeYaml(" ")}
13491
- # Host-op SSH key (createHostExecutor \u2192 host.docker.internal). /dev/null
13492
- # when the host channel isn't provisioned \u2192 OPENSHIP_HOST_SSH_HOST stays
13493
- # unset and the API falls back to LocalExecutor.
13494
- - \${OPENSHIP_HOST_KEY_PATH:-/dev/null}:/run/secrets/openship_host_key:ro
13495
- extra_hosts: ["host.docker.internal:host-gateway"]
13496
- env_file: [.env]
13497
- environment:
13498
- NODE_ENV: production
13499
- PORT: "\${API_PORT:-4000}"
13500
- DATABASE_URL: postgresql://\${POSTGRES_USER:-openship}:\${POSTGRES_PASSWORD:?missing from .env \u2014 re-run openship up to regenerate it}@postgres:5432/\${POSTGRES_DB:-openship}
13501
- REDIS_URL: redis://redis:6379
13502
- OPENSHIP_EDGE_MODE: docker
13503
- OPENSHIP_EDGE_CONTAINER: openship-edge
13504
- depends_on:
13505
- postgres: { condition: service_healthy }
13506
- redis: { condition: service_healthy }
13507
- healthcheck:
13508
- test: ["CMD-SHELL", "bun -e \\"fetch('http://127.0.0.1:\${API_PORT:-4000}/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\\""]
13509
- interval: 10s
13510
- timeout: 5s
13511
- retries: 12
13512
- start_period: 40s
13513
-
13514
- dashboard:
13515
- image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-dashboard:\${OPENSHIP_VERSION:-latest}
13516
- restart: unless-stopped
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}"]
13519
- env_file: [.env]
13520
- environment:
13521
- NODE_ENV: production
13522
- PORT: "\${DASHBOARD_PORT:-3001}"
13523
- INTERNAL_API_URL: http://api:\${API_PORT:-4000}
13524
- depends_on:
13525
- api: { condition: service_healthy }
13526
-
13527
- edge:
13528
- image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-edge:\${OPENSHIP_VERSION:-latest}
13529
- # PINNED, and it must stay pinned: the api reaches the edge by NAME through
13530
- # DockerEdgeExecutor (OPENSHIP_EDGE_CONTAINER above), and "ours" edge
13531
- # detection greps \`docker ps --filter name=openship-edge\`. Without this,
13532
- # compose derives \`<project>-edge-1\` from the directory and every
13533
- # \`docker exec\` into the edge fails with "No such container: openship-edge" \u2014
13534
- # which silently migrated 0 sites after the operator's proxy was stopped.
13535
- # Safe here: the edge is a singleton (host networking, one per box).
13536
- container_name: openship-edge
13537
- restart: unless-stopped
13538
- network_mode: host
13539
- volumes:
13540
- ${edgeVolumeYaml(" ")}
13541
-
13542
- volumes:
13543
- postgres_data:
13544
- redis_data:
13545
- `;
13546
- function keepSecret(existing, key) {
13547
- return existing[key] || randomBytes5(32).toString("hex");
13548
- }
13549
- function readEnvFile() {
13550
- const out = {};
13551
- 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];
13555
- }
13556
- } catch {
13557
- }
13558
- return out;
13559
- }
13560
- var HOST_KEY_COMMENT = "openship-host-executor";
13561
- var HOST_KEY_FROM = "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8,127.0.0.1";
13562
- function hostKeyAuthLine(pub) {
13563
- return `from="${HOST_KEY_FROM}",restrict,pty ${pub}`;
13564
- }
13565
- 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";
13568
- }
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;
13583
- try {
13584
- mkdirSync3(join7(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 448 });
13585
- if (!existsSync4(keyPath)) {
13586
- const g2 = spawnSync3(
13587
- "ssh-keygen",
13588
- ["-t", "ed25519", "-N", "", "-q", "-f", keyPath, "-C", HOST_KEY_COMMENT],
13589
- { stdio: "ignore" }
13590
- );
13591
- if (g2.status !== 0) return null;
13592
- }
13593
- 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;
13605
- }
13606
- }
13607
- function composeProjectName(prev) {
13608
- if (prev.COMPOSE_PROJECT_NAME) return prev.COMPOSE_PROJECT_NAME;
13609
- return Object.keys(prev).length > 0 ? "compose" : "openship";
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
- }
13616
- function orphanedStackContainers(project) {
13617
- const r2 = spawnSync3(
13618
- "docker",
13619
- [
13620
- "ps",
13621
- "-a",
13622
- "--format",
13623
- '{{.Names}} {{.Label "com.docker.compose.project"}} {{.Label "com.docker.compose.project.config_files"}}'
13624
- ],
13625
- { encoding: "utf8" }
13626
- );
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 }));
13635
- }
13636
- function reconcileDbPassword(user, password) {
13637
- if (!password) return;
13638
- if (compose(["up", "-d", "--wait", "postgres"], { quiet: true }) !== 0) return;
13639
- const attempt = (asRole) => spawnSync3(
13640
- "docker",
13641
- [
13642
- "compose",
13643
- "-f",
13644
- COMPOSE_FILE,
13645
- "exec",
13646
- "-T",
13647
- "postgres",
13648
- "psql",
13649
- "-U",
13650
- asRole,
13651
- "-d",
13652
- "postgres",
13653
- "-v",
13654
- "ON_ERROR_STOP=1",
13655
- // Read the statement from STDIN, not `-c`: psql has no bind parameters for
13656
- // ALTER USER, so the password has to be inlined in SQL — and an argv copy
13657
- // would be readable in `ps` for the life of the call. stdin keeps it to this
13658
- // process and the socket. Our generated password is hex, so it cannot break
13659
- // out of the quoted literal either.
13660
- "-f",
13661
- "-"
13662
- ],
13663
- {
13664
- cwd: COMPOSE_DIR,
13665
- encoding: "utf8",
13666
- // Create the role if the volume predates it, else just realign it. Both
13667
- // branches leave exactly the credentials this `.env` will present.
13668
- input: `DO $$ BEGIN
13669
- IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${user}') THEN
13670
- ALTER ROLE "${user}" WITH LOGIN PASSWORD '${password}';
13671
- ELSE
13672
- CREATE ROLE "${user}" WITH LOGIN SUPERUSER PASSWORD '${password}';
13673
- END IF;
13674
- END $$;
13675
- `
13676
- }
13677
- );
13678
- let r2 = attempt(user);
13679
- if (r2.status !== 0) r2 = attempt("postgres");
13680
- if (r2.status !== 0) {
13681
- console.log(
13682
- `
13683
- ! Could not realign the database password (${(r2.stderr ?? "").trim() || "psql failed"}).
13684
- The api will fail with "password authentication failed for user "${user}"" because the
13685
- existing data volume was initialized with a different password.
13686
-
13687
- Fix it one of two ways:
13688
- \u2022 restore the .env that created the volume, or
13689
- \u2022 start fresh (DESTROYS DB DATA): openship uninstall then openship up
13690
- `
13691
- );
13692
- }
13693
- }
13694
- function removeOrphanedStack(project) {
13695
- const orphans = orphanedStackContainers(project);
13696
- if (orphans.length === 0) return;
13697
- const projects = [...new Set(orphans.map((o2) => o2.project))].join(", ");
13698
- console.log(
13699
- ` Removing ${orphans.length} container(s) from a previous stack (project "${projects}") \u2014 they would hold the ports this stack needs. Volumes are kept.`
13700
- );
13701
- spawnSync3("docker", ["rm", "-f", ...orphans.map((o2) => o2.name)], { stdio: "ignore" });
13702
- }
13703
- var EDGE_SWEEP_FORMAT = '{{.Names}} {{.Label "com.docker.compose.project"}} {{.Label "com.docker.compose.project.config_files"}}';
13704
- function edgeNameNeedsReclaim(psStdout, project) {
13705
- const row = psStdout.split("\n").map((l2) => l2.trim()).find((l2) => l2.split(" ")[0] === "openship-edge");
13706
- if (!row) return false;
13707
- const [, proj = "", configFiles = ""] = row.split(" ");
13708
- const ours = proj === project && configFiles.split(",").some((f2) => f2.trim() === COMPOSE_FILE);
13709
- return !ours;
13710
- }
13711
- function reconcileEdgeContainerName(project) {
13712
- const r2 = spawnSync3("docker", ["ps", "-a", "--format", EDGE_SWEEP_FORMAT], {
13713
- encoding: "utf8"
13714
- });
13715
- if (r2.status !== 0 || !r2.stdout) return;
13716
- if (!edgeNameNeedsReclaim(r2.stdout, project)) return;
13717
- console.log(
13718
- " Replacing an unmanaged `openship-edge` container so compose can own it (config is on the host, so no routes are lost)."
13719
- );
13720
- spawnSync3("docker", ["rm", "-f", "openship-edge"], { stdio: "ignore" });
13721
- }
13722
- function migrateLegacyEdgeVolumes(project) {
13723
- const pairs = [
13724
- { volume: `${project}_openship_sites`, host: EDGE_SITES_HOST_DIR },
13725
- { volume: `${project}_openship_certs`, host: "/etc/letsencrypt" },
13726
- { volume: `${project}_openship_acme`, host: EDGE_ACME_HOST_DIR },
13727
- { volume: `${project}_openship_static`, host: "/opt/openship/static" }
13728
- ];
13729
- const ls = spawnSync3("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
13730
- if (ls.status !== 0 || !ls.stdout) return;
13731
- const existing = new Set(ls.stdout.split("\n").map((l2) => l2.trim()).filter(Boolean));
13732
- for (const { volume, host } of pairs) {
13733
- if (!existing.has(volume)) continue;
13734
- mkdirSync3(host, { recursive: true });
13735
- const alreadyThere = spawnSync3("sh", ["-c", `ls -A ${JSON.stringify(host)} 2>/dev/null | head -1`], {
13736
- encoding: "utf8"
13737
- });
13738
- if (alreadyThere.stdout?.trim()) continue;
13739
- console.log(` Moving edge state from volume ${volume} onto ${host}...`);
13740
- const copy = spawnSync3(
13741
- "docker",
13742
- [
13743
- "run",
13744
- "--rm",
13745
- "-v",
13746
- `${volume}:/from:ro`,
13747
- "-v",
13748
- `${host}:/to`,
13749
- "alpine:3",
13750
- "sh",
13751
- "-c",
13752
- "cp -a /from/. /to/ 2>/dev/null || true"
13753
- ],
13754
- { stdio: "inherit" }
13755
- );
13756
- if (copy.status !== 0) {
13757
- console.log(
13758
- ` Could not copy ${volume} \u2014 the stack will start with an empty ${host}.
13759
- Recover manually with: docker run --rm -v ${volume}:/from -v ${host}:/to alpine cp -a /from/. /to/`
13760
- );
13761
- }
13762
- }
13763
- }
13764
- function warnOrphanedVolumes(project) {
13765
- const r2 = spawnSync3("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
13766
- if (r2.status !== 0 || !r2.stdout) return;
13767
- const others = new Set(
13768
- 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)
13769
- );
13770
- if (others.size === 0) return;
13771
- console.log(
13772
- ` Note: volumes from a previous install (project "${[...others].join(", ")}") are still on disk
13773
- and are NOT mounted by this stack \u2014 its database starts fresh. (Certificates and
13774
- vhosts now live on the host, so those carry over.)
13775
- Remove them with \`docker volume ls | grep _openship_\` once you're sure they aren't needed.`
13776
- );
13777
- }
13778
- function keepConfig(prev, key, explicit) {
13779
- const set = explicit?.trim();
13780
- if (set) return set;
13781
- const carried = prev[key]?.trim();
13782
- return carried || void 0;
13783
- }
13784
- var ACME_ENV_KEYS = [
13785
- "OPENSHIP_ACME_EMAIL",
13786
- "OPENSHIP_ACME_DIRECTORY_URL",
13787
- "OPENSHIP_ACME_EAB_KID",
13788
- "OPENSHIP_ACME_EAB_HMAC_KEY",
13789
- "OPENSHIP_ACME_KEY_TYPE",
13790
- "OPENSHIP_ACME_CA_BUNDLE",
13791
- "OPENSHIP_ACME_TOS_AGREED"
13792
- ];
13793
- function renderAcmeEnv(prev) {
13794
- return ACME_ENV_KEYS.flatMap((key) => {
13795
- const value = process.env[key]?.trim() || prev[key]?.trim();
13796
- if (!value) return [];
13797
- if (/[\r\n]/.test(value)) throw new Error(`${key} must be a single-line value`);
13798
- return [`${key}=${value}`];
13799
- });
13800
- }
13801
- function resolveEnvConfig(prev, opts) {
13802
- const publicUrl = keepConfig(prev, "OPENSHIP_PUBLIC_URL", opts.publicUrl);
13803
- const bindAddr = keepConfig(prev, "OPENSHIP_BIND_ADDR") ?? (publicUrl ? "0.0.0.0" : void 0);
13804
- return {
13805
- apiPort: keepConfig(prev, "API_PORT", opts.apiPort) ?? String(DEFAULT_API_PORT),
13806
- dashPort: keepConfig(prev, "DASHBOARD_PORT", opts.dashboardPort) ?? String(DEFAULT_DASHBOARD_PORT),
13807
- ...bindAddr ? { bindAddr } : {},
13808
- ...publicUrl ? { publicUrl } : {},
13809
- // A public URL always implies a proxy in front; otherwise keep whatever the
13810
- // install was configured with.
13811
- trustProxy: Boolean(opts.trustProxy) || Boolean(publicUrl) || prev.TRUST_PROXY === "true",
13812
- ...keepConfig(prev, "OPENSHIP_EXTRA_TRUSTED_ORIGINS", opts.extraTrustedOrigins) ? {
13813
- extraTrustedOrigins: keepConfig(
13814
- prev,
13815
- "OPENSHIP_EXTRA_TRUSTED_ORIGINS",
13816
- opts.extraTrustedOrigins
13817
- )
13818
- } : {},
13819
- registry: keepConfig(prev, "OPENSHIP_IMAGE_REGISTRY", opts.registry) ?? DEFAULT_IMAGE_REGISTRY,
13820
- // Tri-state: the flag is absent on a plain re-run, so fall back to what the
13821
- // install chose rather than silently re-granting host control.
13822
- hostControl: opts.noHostControl === void 0 ? prev.OPENSHIP_HOST_CONTROL !== "false" : !opts.noHostControl
13823
- };
13824
- }
13825
- function toPort(value) {
13826
- const n2 = Number(value);
13827
- return Number.isInteger(n2) && n2 > 0 && n2 <= 65535 ? n2 : void 0;
13828
- }
13829
- function composeEnvPorts() {
13830
- const env = readEnvFile();
13831
- return { api: toPort(env.API_PORT), dashboard: toPort(env.DASHBOARD_PORT) };
13832
- }
13833
- function composeBindAddr() {
13834
- return readEnvFile().OPENSHIP_BIND_ADDR?.trim() || "127.0.0.1";
13835
- }
13836
- function composeTrustedOriginUrls() {
13837
- const env = readEnvFile();
13838
- return [env.OPENSHIP_PUBLIC_URL, env.OPENSHIP_EXTRA_TRUSTED_ORIGINS].filter(
13839
- (v2) => !!v2?.trim()
13840
- );
13841
- }
13842
- function composeHeldPorts() {
13843
- const r2 = spawnSync3(
13844
- "docker",
13845
- ["ps", "--format", '{{.Label "com.docker.compose.project.config_files"}} {{.Ports}}'],
13846
- { encoding: "utf8" }
13847
- );
13848
- if (r2.status !== 0 || !r2.stdout) return [];
13849
- const ports = /* @__PURE__ */ new Set();
13850
- for (const line of r2.stdout.split("\n")) {
13851
- const [configFiles = "", published = ""] = line.split(" ");
13852
- if (!configFiles.split(",").some((f2) => f2.trim() === COMPOSE_FILE)) continue;
13853
- for (const m2 of published.matchAll(/:(\d+)->/g)) ports.add(Number(m2[1]));
13854
- }
13855
- return [...ports];
13856
- }
13857
- async function resolveComposePorts(prefs, opts = {}) {
13858
- return resolvePorts(
13859
- {
13860
- api: toPort(prefs.api),
13861
- dashboard: toPort(prefs.dashboard),
13862
- previous: composeEnvPorts(),
13863
- bindAddr: composeBindAddr(),
13864
- reclaimable: composeHeldPorts()
13865
- },
13866
- opts
13867
- );
13868
- }
13869
- function renderEnv(opts, host, cfg) {
13870
- const prev = readEnvFile();
13871
- const lines = [
13872
- "# Managed by `openship up`. Secrets are generated once and preserved.",
13873
- // Do NOT edit on a live install — it is the volume prefix (see composeProjectName).
13874
- `COMPOSE_PROJECT_NAME=${composeProjectName(prev)}`,
13875
- "CLOUD_MODE=false",
13876
- "OPENSHIP_TARGET=local",
13877
- "OPENSHIP_REQUIRE_AUTH=true",
13878
- // Read by createHostExecutor (throws when false) and by the servers list
13879
- // (hides the local row). Written explicitly so the policy is visible in .env.
13880
- `OPENSHIP_HOST_CONTROL=${cfg.hostControl ? "true" : "false"}`,
13881
- `OPENSHIP_IMAGE_REGISTRY=${cfg.registry}`,
13882
- `OPENSHIP_VERSION=${opts.version || (true ? "0.6.1" : "latest")}`,
13883
- `POSTGRES_PASSWORD=${keepSecret(prev, "POSTGRES_PASSWORD")}`,
13884
- // Pinned once (see resolvePgData): fresh install → subdir, existing volume → root.
13885
- `OPENSHIP_PGDATA=${resolvePgData(prev)}`,
13886
- `BETTER_AUTH_SECRET=${keepSecret(prev, "BETTER_AUTH_SECRET")}`,
13887
- `INTERNAL_TOKEN=${keepSecret(prev, "INTERNAL_TOKEN")}`,
13888
- `API_PORT=${cfg.apiPort}`,
13889
- `DASHBOARD_PORT=${cfg.dashPort}`,
13890
- // The api's OWN view of the dashboard port: the self-app boot reconcile points
13891
- // the operator's domain at the dashboard through this (self-deploy.ts), and it
13892
- // silently defaults to 3001 when unset. Ports are dynamic now, so leaving it
13893
- // out publishes a domain routed to a port nothing is listening on.
13894
- `OPENSHIP_DASHBOARD_PORT=${cfg.dashPort}`,
13895
- // Alternate CA/EAB values are operator configuration (including one secret),
13896
- // so a routine `openship up`/upgrade must not silently discard them.
13897
- ...renderAcmeEnv(prev)
13898
- ];
13899
- if (cfg.bindAddr) lines.push(`OPENSHIP_BIND_ADDR=${cfg.bindAddr}`);
13900
- if (cfg.publicUrl) lines.push(`OPENSHIP_PUBLIC_URL=${cfg.publicUrl}`);
13901
- if (cfg.extraTrustedOrigins) {
13902
- lines.push(`OPENSHIP_EXTRA_TRUSTED_ORIGINS=${cfg.extraTrustedOrigins}`);
13903
- }
13904
- if (cfg.trustProxy) lines.push("TRUST_PROXY=true");
13905
- if (host) {
13906
- lines.push(
13907
- "OPENSHIP_HOST_SSH_HOST=host.docker.internal",
13908
- `OPENSHIP_HOST_SSH_USER=${host.user}`,
13909
- "OPENSHIP_HOST_SSH_PORT=22",
13910
- "OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key",
13911
- `OPENSHIP_HOST_KEY_PATH=${host.keyPath}`
13912
- );
13913
- }
13914
- return lines.join("\n") + "\n";
13915
- }
13916
- function sourceBuildDir() {
13917
- const marker = readSourceInstall();
13918
- if (!marker?.dir) return null;
13919
- const hasAll = BUILT_SERVICES.every((s2) => existsSync4(join7(marker.dir, s2.dockerfile)));
13920
- return hasAll ? marker.dir : null;
13921
- }
13922
- function renderBuildOverride(repoDir) {
13923
- const services = BUILT_SERVICES.map(
13924
- (s2) => ` ${s2.service}:
13925
- build:
13926
- context: ${repoDir}
13927
- dockerfile: ${s2.dockerfile}
13928
- `
13929
- ).join("");
13930
- return `# Managed by \`openship up\` (from-source install) \u2014 builds instead of pulls.
13931
- services:
13932
- ${services}`;
13933
- }
13934
- function materialize(opts) {
13935
- mkdirSync3(COMPOSE_DIR, { recursive: true, mode: 448 });
13936
- const prev = readEnvFile();
13937
- const cfg = resolveEnvConfig(prev, opts);
13938
- const host = provisionHostSshChannel(cfg.hostControl);
13939
- let before = "";
13940
- try {
13941
- before = readFileSync3(ENV_FILE, "utf8");
13942
- } catch {
13943
- }
13944
- const rendered = renderEnv(opts, host, cfg);
13945
- writeFileSync2(COMPOSE_FILE, COMPOSE_YAML);
13946
- writeFileSync2(ENV_FILE, rendered, { mode: 384 });
13947
- const buildDir = opts.build === false ? null : sourceBuildDir();
13948
- if (buildDir) writeFileSync2(BUILD_FILE, renderBuildOverride(buildDir));
13949
- return { buildDir, cfg, envChanged: before !== "" && rendered !== before };
13950
- }
13951
- var SECRET_ENV_KEY = /(PASSWORD|SECRET|TOKEN|HMAC_KEY)$/;
13952
- function composePlan(opts) {
13953
- const prev = readEnvFile();
13954
- const cfg = resolveEnvConfig(prev, opts);
13955
- const buildDir = opts.build === false ? null : sourceBuildDir();
13956
- const hostChannel = plannedHostChannel(cfg.hostControl);
13957
- const settings = [];
13958
- const newSecrets = [];
13959
- for (const line of renderEnv(opts, hostChannel, cfg).split("\n")) {
13960
- const at2 = line.indexOf("=");
13961
- if (at2 < 1 || line.startsWith("#")) continue;
13962
- const key = line.slice(0, at2);
13963
- if (!SECRET_ENV_KEY.test(key)) {
13964
- settings.push({ key, value: line.slice(at2 + 1) });
13965
- continue;
13966
- }
13967
- const minted = !prev[key];
13968
- if (minted) newSecrets.push(key);
13969
- settings.push({ key, value: minted ? "<generated on this run>" : "<preserved>" });
13970
- }
13971
- return {
13972
- composeFile: COMPOSE_FILE,
13973
- envFile: ENV_FILE,
13974
- installMethodFile: INSTALL_METHOD_FILE,
13975
- ...buildDir ? { buildFile: BUILD_FILE } : {},
13976
- yaml: COMPOSE_YAML,
13977
- settings,
13978
- newSecrets,
13979
- existing: Object.keys(prev).length > 0,
13980
- mountDirs: EDGE_CONTAINER_MOUNTS.map((m2) => m2.host),
13981
- buildDir,
13982
- hostChannel
13983
- };
13984
- }
13985
- function dbVolumeExists(project) {
13986
- const r2 = spawnSync3("docker", ["volume", "inspect", `${project}_postgres_data`], {
13987
- stdio: "ignore"
13988
- });
13989
- return r2.status === 0;
13990
- }
13991
- function compose(args, opts) {
13992
- const files = ["-f", COMPOSE_FILE];
13993
- if (opts?.withBuildOverride) files.push("-f", BUILD_FILE);
13994
- const r2 = spawnSync3("docker", ["compose", ...files, ...args], {
13995
- cwd: COMPOSE_DIR,
13996
- stdio: opts?.quiet ? "ignore" : "inherit"
13997
- });
13998
- return r2.status ?? 1;
13999
- }
14000
- function composePrefetch(opts) {
14001
- const { buildDir } = materialize(opts);
14002
- if (buildDir) {
14003
- return compose(["pull", "postgres", "redis"], { withBuildOverride: true }) === 0 && compose(["build"], { withBuildOverride: true }) === 0;
14004
- }
14005
- return compose(["pull"]) === 0;
14006
- }
14007
- function ensureEdgeMountDirs() {
14008
- const failed = [];
14009
- for (const { host } of EDGE_CONTAINER_MOUNTS) {
14010
- try {
14011
- mkdirSync3(host, { recursive: true });
14012
- } catch (err) {
14013
- if (err.code !== "EEXIST") failed.push(host);
14014
- }
14015
- }
14016
- return failed;
14017
- }
14018
- function isRootlessDocker() {
14019
- const r2 = spawnSync3("docker", ["info", "-f", "{{println .SecurityOptions}}"], {
14020
- encoding: "utf8"
14021
- });
14022
- return r2.status === 0 && /rootless/i.test(r2.stdout ?? "");
14023
- }
14024
- async function composeUp(opts) {
14025
- const { buildDir, cfg, envChanged } = materialize(opts);
14026
- const apiPort = cfg.apiPort;
14027
- const dashPort = cfg.dashPort;
14028
- const up = (extra = {}) => compose(
14029
- envChanged ? ["up", "-d", "--force-recreate", ...ENV_CONSUMING_SERVICES] : ["up", "-d"],
14030
- extra
14031
- );
14032
- const env = readEnvFile();
14033
- const project = env.COMPOSE_PROJECT_NAME || "openship";
14034
- removeOrphanedStack(project);
14035
- reconcileEdgeContainerName(project);
14036
- migrateLegacyEdgeVolumes(project);
14037
- warnOrphanedVolumes(project);
14038
- const unmakeableMounts = ensureEdgeMountDirs();
14039
- if (unmakeableMounts.length && isRootlessDocker()) {
14040
- const dirs = unmakeableMounts.join(" ");
14041
- console.error(
14042
- `
14043
- Rootless Docker can't create the edge's host directories under root-owned paths:
14044
- ${unmakeableMounts.join("\n ")}
14045
-
14046
- Create them once (owned by your user), then re-run \`openship up --compose\`:
14047
- sudo mkdir -p ${dirs}
14048
- sudo chown -R "$(id -un)" ${dirs}
14049
- `
14050
- );
14051
- return { ok: false, apiPort, dashPort };
14052
- }
14053
- if (dbVolumeExists(project)) {
14054
- reconcileDbPassword(env.POSTGRES_USER || "openship", env.POSTGRES_PASSWORD ?? "");
14055
- }
14056
- await sanitizeEdgeVhosts(
14057
- new LocalExecutor(),
14058
- EDGE_SITES_HOST_DIR,
14059
- (l2) => console.log(` ${l2.message}`)
14060
- ).catch(() => {
14061
- });
14062
- if (buildDir) {
14063
- if (!opts.alreadyFetched) {
14064
- if (compose(["pull", "postgres", "redis"], { withBuildOverride: true }) !== 0) {
14065
- return { ok: false, apiPort, dashPort };
14066
- }
14067
- if (compose(["build"], { withBuildOverride: true }) !== 0) {
14068
- return { ok: false, apiPort, dashPort };
14069
- }
14070
- }
14071
- if (up({ withBuildOverride: true }) !== 0) {
14072
- return { ok: false, apiPort, dashPort };
14073
- }
14074
- onEdgeContainerChanged();
14075
- writeInstallMethod("compose");
14076
- return { ok: true, apiPort, dashPort };
14077
- }
14078
- if (!opts.alreadyFetched && compose(["pull"]) !== 0) {
14079
- return { ok: false, apiPort, dashPort };
14080
- }
14081
- if (up() !== 0) return { ok: false, apiPort, dashPort };
14082
- onEdgeContainerChanged();
14083
- writeInstallMethod("compose");
14084
- return { ok: true, apiPort, dashPort };
14085
- }
14086
- function onEdgeContainerChanged() {
14087
- invalidateEdgeContainer();
14088
- }
14089
- function composeDown() {
14090
- if (!existsSync4(COMPOSE_FILE)) return false;
14091
- return compose(["down"]) === 0;
14092
- }
14093
- function composeUninstall(opts = {}) {
14094
- const removedImages = [];
14095
- const ok = existsSync4(COMPOSE_FILE) ? compose(["down", "-v", "--remove-orphans"]) === 0 : false;
14096
- if (opts.removeImages) {
14097
- const env = readEnvFile();
14098
- const registry = env.OPENSHIP_IMAGE_REGISTRY || DEFAULT_IMAGE_REGISTRY;
14099
- const version = env.OPENSHIP_VERSION || "latest";
14100
- for (const { service } of BUILT_SERVICES) {
14101
- const ref = `${registry}/openship-${service}:${version}`;
14102
- if (spawnSync3("docker", ["image", "rm", "-f", ref], { stdio: "ignore" }).status === 0) {
14103
- removedImages.push(ref);
14104
- }
14105
- }
14106
- }
14107
- return { ok, removedImages };
14108
- }
14109
- async function composeUpdate(version) {
14110
- if (!existsSync4(COMPOSE_FILE)) return false;
14111
- return (await composeUp(version ? { version } : {})).ok;
14112
- }
14113
- function composePs() {
14114
- return compose(["ps"]);
14115
- }
14116
- function composeRunning() {
14117
- const r2 = spawnSync3(
14118
- "docker",
14119
- ["compose", "-f", COMPOSE_FILE, "ps", "--status", "running", "-q"],
14120
- { cwd: COMPOSE_DIR, encoding: "utf8" }
14121
- );
14122
- return r2.status === 0 && r2.stdout.trim().length > 0;
14123
- }
14124
- function composeStart() {
14125
- reconcileEdgeContainerName(readEnvFile().COMPOSE_PROJECT_NAME || "openship");
14126
- return compose(["up", "-d"], { quiet: true }) === 0;
14127
- }
14128
- function composeRestart() {
14129
- return compose(["restart"], { quiet: true }) === 0;
14130
- }
14131
- function composeInternalToken() {
14132
- return readEnvFile().INTERNAL_TOKEN ?? null;
14133
- }
14134
-
14135
- // src/lib/edge-import.ts
14136
- async function waitForApiHealth(port, tries) {
14137
- for (let i2 = 0; i2 < tries; i2++) {
14138
- try {
14139
- const r2 = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(2e3) });
14140
- if (r2.ok) return true;
14141
- } catch {
14142
- }
14143
- await new Promise((r2) => setTimeout(r2, 1e3));
14144
- }
14145
- return false;
14146
- }
14147
- async function waitForEdgeRunning(tries) {
14148
- const exec = new LocalExecutor();
14149
- for (let i2 = 0; i2 < tries; i2++) {
14150
- const out = await exec.exec(`docker inspect -f '{{.State.Running}}' ${EDGE_CONTAINER_NAME}`).catch(() => "");
14151
- if (out.trim() === "true") return true;
14152
- await new Promise((r2) => setTimeout(r2, 1e3));
14153
- }
14154
- return false;
14155
- }
14156
- async function importMigratedSites(apiPort, sites, certPems, staticRootOverrides) {
14157
- const token = composeInternalToken();
14158
- if (!token) {
14159
- const error = "couldn't read the stack's internal token";
14160
- console.log(chalk.yellow(` Skipping site import \u2014 ${error}. Re-run to retry.
14161
- `));
14162
- return { ok: false, registered: [], error };
14163
- }
14164
- const spinner = ora(
14165
- `Importing ${sites.length} migrated site${sites.length === 1 ? "" : "s"} into the edge\u2026`
14166
- ).start();
14167
- if (!await waitForApiHealth(apiPort, 60)) {
14168
- const error = "API didn't become healthy in time";
14169
- spinner.warn(`${error} \u2014 migrated sites not imported. Re-run \`openship up\` to retry.`);
14170
- return { ok: false, registered: [], error };
14171
- }
14172
- if (!await waitForEdgeRunning(60)) {
14173
- const error = `the ${EDGE_CONTAINER_NAME} container isn't running yet`;
14174
- spinner.warn(`${error} \u2014 migrated sites not imported. Re-run \`openship up\` to retry.`);
14175
- return { ok: false, registered: [], error };
14176
- }
14177
- try {
14178
- const r2 = await fetch(`http://127.0.0.1:${apiPort}/api/system/edge/import-sites`, {
14179
- method: "POST",
14180
- headers: { "Content-Type": "application/json", "X-Internal-Token": token },
14181
- body: JSON.stringify({ sites, certPems, staticRootOverrides }),
14182
- signal: AbortSignal.timeout(12e4)
14183
- });
14184
- const data = await r2.json().catch(() => ({}));
14185
- if (!r2.ok) {
14186
- const error = data.error ?? `HTTP ${r2.status}`;
14187
- spinner.warn(`Site import failed: ${error}. Re-run to retry.`);
14188
- return { ok: false, registered: [], error };
14189
- }
14190
- const registered = data.registered ?? [];
14191
- const warnings = data.warnings ?? [];
14192
- const missed = sites.length - registered.length;
14193
- if (sites.length > 0 && registered.length === 0) {
14194
- spinner.fail(
14195
- `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.`
14196
- );
14197
- for (const w2 of warnings.slice(0, 8)) console.log(chalk.red(` \u2022 ${w2}`));
14198
- const edgeLog = await edgeCrashReason(new LocalExecutor());
14199
- if (edgeLog) {
14200
- console.log(chalk.red(`
14201
- The edge container is not running. Its log says:`));
14202
- console.log(chalk.red(` ${edgeLog}`));
14203
- }
14204
- console.log(
14205
- chalk.yellow(
14206
- `
14207
- Retry the import with \`openship up\` once the cause above is fixed, or put your
14208
- previous proxy back: docker stop ${EDGE_CONTAINER_NAME} && sudo systemctl enable --now nginx
14209
- `
14210
- )
14211
- );
14212
- return { ok: false, registered, error: warnings[0] ?? "no sites were registered" };
14213
- }
14214
- if (missed > 0) {
14215
- spinner.warn(
14216
- `Migrated ${registered.length}/${sites.length} sites \u2014 ${missed} not served (see below).`
14217
- );
14218
- for (const w2 of warnings.slice(0, 8)) console.log(chalk.yellow(` \u2022 ${w2}`));
14219
- return { ok: false, registered, error: `${missed} site(s) not registered` };
14220
- }
14221
- spinner.succeed(
14222
- `Migrated ${registered.length} site${registered.length === 1 ? "" : "s"} into Openship's edge.`
14223
- );
14224
- for (const w2 of warnings.slice(0, 8)) console.log(chalk.dim(` \u2022 ${w2}`));
14225
- return { ok: true, registered };
14226
- } catch (e2) {
14227
- const error = e2.message;
14228
- spinner.warn(`Site import failed: ${error}. Re-run to retry.`);
14229
- return { ok: false, registered: [], error };
13112
+ const env = {};
13113
+ for (const line of text.split("\n")) {
13114
+ const m2 = line.match(/^([A-Z0-9_]+)=(.*)$/);
13115
+ if (m2) env[m2[1]] = m2[2];
14230
13116
  }
13117
+ return { env, unreadable: null };
14231
13118
  }
14232
13119
 
14233
13120
  export {
14234
- OS_DIR,
14235
- IS_ALT_HOME,
14236
- DATA_DIR,
14237
- LOG_DIR,
14238
- AUTH_SECRET_FILE,
14239
- INTERNAL_TOKEN_FILE,
14240
- PORTS_FILE,
14241
- saveInstanceUrl,
14242
- readInstanceUrl,
14243
- readStoredPorts,
14244
- storedApiPort,
14245
- storedDashboardPort,
14246
- portMoveNotice,
14247
- resolvePorts,
14248
- DEFAULT_REPO,
14249
- SOURCE_CHECKOUT_DIR,
14250
- SOURCE_DIST_DIR,
14251
- has,
14252
- shortSha,
14253
- prepareFromSource,
14254
- readSourceInstall,
14255
- remoteSha,
14256
- rebuildFromSource,
14257
- readInstallMethod,
14258
- dockerGap,
14259
- hasDockerCompose,
14260
- dockerInstallPreview,
14261
- ensureDocker,
14262
- composeTrustedOriginUrls,
14263
- resolveComposePorts,
14264
- sourceBuildDir,
14265
- composePlan,
14266
- composePrefetch,
14267
- composeUp,
14268
- composeDown,
14269
- composeUninstall,
14270
- composeUpdate,
14271
- composePs,
14272
- composeRunning,
14273
- composeStart,
14274
- composeRestart,
14275
- composeInternalToken,
14276
- waitForApiHealth,
14277
- waitForEdgeRunning,
14278
- importMigratedSites
13121
+ resolveLocalEnvironmentSync,
13122
+ thisHost,
13123
+ pasteable,
13124
+ startUnitHint,
13125
+ COMPOSE_DIR,
13126
+ COMPOSE_FILE,
13127
+ COMPOSE_ENV_FILE,
13128
+ composeInstallExists,
13129
+ readComposeEnvFile
14279
13130
  };