openship 0.6.5 → 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 (54) hide show
  1. package/dist/{bare-XQIPDO4R.js → bare-OXJVW3AK.js} +11 -10
  2. package/dist/{chunk-WE7BXW6L.js → chunk-2YM3NI2E.js} +2 -2
  3. package/dist/{chunk-RFWJE7ZN.js → chunk-4I2BM2DT.js} +3 -3
  4. package/dist/{chunk-QLX7NBEA.js → chunk-5DMWSTOM.js} +78 -1836
  5. package/dist/{chunk-VDESJSG6.js → chunk-77QDF6UD.js} +135 -21
  6. package/dist/{chunk-7334NSJL.js → chunk-A4GXYE4L.js} +67 -31
  7. package/dist/{chunk-HPXP6LXZ.js → chunk-CBLD2HS5.js} +3 -3
  8. package/dist/{chunk-6JELW75A.js → chunk-FE7ZZQSE.js} +3 -3
  9. package/dist/{chunk-XVOBL7BC.js → chunk-HKRQXPTI.js} +1 -1
  10. package/dist/{chunk-GRTXMUHA.js → chunk-LFE5KQ7Y.js} +11 -11
  11. package/dist/{chunk-TM2IQPYU.js → chunk-LH2PLRQO.js} +2 -2
  12. package/dist/{chunk-LFCSC4IU.js → chunk-LUKYPVJ4.js} +3 -3
  13. package/dist/{chunk-VHMOQL5Y.js → chunk-MLYSEZQB.js} +115 -19
  14. package/dist/{chunk-7S4USQYF.js → chunk-NK5N5LXN.js} +50 -122
  15. package/dist/{chunk-3ZAGC5NT.js → chunk-OBPKYXQL.js} +17 -6
  16. package/dist/{chunk-5OUWIPOB.js → chunk-PCPVTH6M.js} +12 -84
  17. package/dist/chunk-Q7WGTQQ5.js +88 -0
  18. package/dist/{chunk-VLQ3UIVO.js → chunk-QVW76WDL.js} +52 -26
  19. package/dist/{chunk-PZRRL5PC.js → chunk-RSR7PFIQ.js} +14 -3
  20. package/dist/{chunk-A7EWM2KA.js → chunk-SM26HDS2.js} +1 -1
  21. package/dist/{chunk-LEYSL46B.js → chunk-UJ6JZM65.js} +3426 -632
  22. package/dist/{chunk-WFRXYWK2.js → chunk-UVNMQM7W.js} +1 -1
  23. package/dist/chunk-VTGPEC6B.js +308 -0
  24. package/dist/chunk-WVAML4H6.js +2145 -0
  25. package/dist/{chunk-MSZUCNPV.js → chunk-YIMOCZBZ.js} +1 -1
  26. package/dist/{chunk-EZPPTWJQ.js → chunk-YMMQRE5J.js} +1 -1
  27. package/dist/{cloud-3DODOWTR.js → cloud-75VIT4KV.js} +16 -15
  28. package/dist/{detect-P4CIQBRH.js → detect-OIE5V3VZ.js} +3 -3
  29. package/dist/{docker-3K2NEBD6.js → docker-VTR2GK5Y.js} +7 -6
  30. package/dist/{docker-edge-executor-YZZ6P7IC.js → docker-edge-executor-22IBXNYJ.js} +4 -4
  31. package/dist/{edge-import-MCJNVTZ2.js → edge-import-WJUB4M7F.js} +23 -22
  32. package/dist/{edge-preflight-ES6FH4JK.js → edge-preflight-GBNCPNVU.js} +10 -10
  33. package/dist/{ensure-container-edge-YPITNH45.js → ensure-container-edge-KPI2CVXJ.js} +9 -9
  34. package/dist/{executor-DZGX55K2.js → executor-7LXXZPBR.js} +6 -3
  35. package/dist/{host-channel-preflight-MUXE3KZU.js → host-channel-preflight-KE6OONYK.js} +23 -22
  36. package/dist/index.js +95 -84
  37. package/dist/{nginx-NSAHWGJV.js → nginx-KUFMLMAC.js} +9 -7
  38. package/dist/{openresty-lua-DR4D3EZ6.js → openresty-lua-ATTH35BE.js} +6 -4
  39. package/dist/{privilege-YTVI4SKF.js → privilege-I4W3ZNZK.js} +4 -4
  40. package/dist/server/engine/samples/amavisd/amavisd.conf +25 -0
  41. package/dist/server/engine/samples/postfix/master.cf +14 -0
  42. package/dist/server/index.js +35002 -27533
  43. package/dist/server/migrations/0105_mail_inbound_rules.sql +80 -0
  44. package/dist/server/migrations/0106_mcp_call_tracking.sql +23 -0
  45. package/dist/server/migrations/0107_build_minute_metering_indexes.sql +20 -0
  46. package/dist/server/migrations/0108_credential.sql +68 -0
  47. package/dist/server/migrations/meta/_journal.json +28 -0
  48. package/dist/{setup-WUUZURHS.js → setup-XGLGUUFW.js} +10 -10
  49. package/dist/ssh-client-JBMA7SN4.js +28 -0
  50. package/dist/{up-XEO3SR6Z.js → up-K4BV7466.js} +26 -27
  51. package/package.json +1 -1
  52. package/dist/chunk-6HOA5JT7.js +0 -124
  53. package/dist/chunk-XRNRT37W.js +0 -316
  54. /package/dist/{dist-es-ZP5QDSJ3.js → dist-es-AXFRS7PS.js} +0 -0
@@ -134,231 +134,41 @@ import {
134
134
  } from "./chunk-ZWLDLTJP.js";
135
135
  import {
136
136
  CloudRuntime
137
- } from "./chunk-3ZAGC5NT.js";
137
+ } from "./chunk-OBPKYXQL.js";
138
138
  import {
139
- systemCatalog
140
- } from "./chunk-7334NSJL.js";
139
+ OPENRESTY_DEFAULT_PATHS
140
+ } from "./chunk-RSR7PFIQ.js";
141
141
  import {
142
- DEFAULT_DOCKER_SOCKET_PATH,
143
142
  DockerRuntime,
144
143
  daemonConnectionFrom,
145
144
  isHostPathSource,
146
145
  resolveExecExitCode,
147
- resolveLocalDockerSocketPath,
148
146
  scopedVolumeName,
149
147
  startAttachStream,
150
148
  startExecStream
151
- } from "./chunk-VDESJSG6.js";
149
+ } from "./chunk-77QDF6UD.js";
152
150
  import {
153
151
  BareRuntime
154
- } from "./chunk-GRTXMUHA.js";
152
+ } from "./chunk-LFE5KQ7Y.js";
155
153
  import {
156
154
  createExecutor
157
- } from "./chunk-VHMOQL5Y.js";
158
- import {
159
- LocalExecutor
160
- } from "./chunk-6FH4AXVY.js";
161
- import {
162
- invalidateEdgeContainer,
163
- sanitizeEdgeVhosts
164
- } from "./chunk-WE7BXW6L.js";
165
- import {
166
- EDGE_CONTAINER_MOUNTS,
167
- EDGE_HOST_STATE_DIR,
168
- OPENRESTY_DEFAULT_PATHS
169
- } from "./chunk-PZRRL5PC.js";
155
+ } from "./chunk-MLYSEZQB.js";
170
156
  import {
171
157
  envOps
172
- } from "./chunk-EZPPTWJQ.js";
158
+ } from "./chunk-YMMQRE5J.js";
173
159
  import {
174
160
  ENVIRONMENT_PROBE_SCRIPT,
175
161
  ENVIRONMENT_PROBE_TIMEOUT_MS,
176
162
  cacheEnvironment,
177
163
  parseEnvironmentProbe,
178
164
  profileIsFresh
179
- } from "./chunk-WFRXYWK2.js";
165
+ } from "./chunk-UVNMQM7W.js";
180
166
  import {
181
- DEFAULT_IMAGE_REGISTRY,
182
167
  FAMILY_BY_DISTRO,
183
- HOST_CHANNEL_BLOCKED,
184
- HOST_CHANNEL_RECHECK,
185
- HOST_CHANNEL_UNAFFECTED,
186
- explainHostChannelCause,
187
168
  isAwsS3Endpoint,
188
169
  safeErrorMessage,
189
- withTimeout,
190
- wrapText
191
- } from "./chunk-LEYSL46B.js";
192
-
193
- // src/lib/ports.ts
194
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
195
- import { join } from "path";
196
-
197
- // ../../packages/core/src/ports.ts
198
- import { createServer } from "net";
199
- var DEFAULT_API_PORT = 4e3;
200
- var DEFAULT_DASHBOARD_PORT = 3001;
201
- var LOOPBACK = "127.0.0.1";
202
- var SCAN_SPAN = 20;
203
- var MAX_PORT = 65535;
204
- function isPortFree(port, host = LOOPBACK) {
205
- return new Promise((resolve3) => {
206
- const srv = createServer();
207
- srv.once("error", () => resolve3(false));
208
- srv.listen(port, host, () => srv.close(() => resolve3(true)));
209
- });
210
- }
211
- async function waitPortFree(port, opts = {}) {
212
- const timeoutMs = opts.timeoutMs ?? 6e3;
213
- const intervalMs = opts.intervalMs ?? 250;
214
- const host = opts.host ?? LOOPBACK;
215
- const deadline = Date.now() + timeoutMs;
216
- for (; ; ) {
217
- if (await isPortFree(port, host)) return true;
218
- if (Date.now() >= deadline) return false;
219
- await new Promise((r2) => setTimeout(r2, intervalMs));
220
- }
221
- }
222
- function getFreePort(host = LOOPBACK) {
223
- return new Promise((resolve3, reject) => {
224
- const srv = createServer();
225
- srv.once("error", reject);
226
- srv.listen(0, host, () => {
227
- const addr = srv.address();
228
- const port = addr && typeof addr === "object" ? addr.port : 0;
229
- srv.close(() => port ? resolve3(port) : reject(new Error("no free port")));
230
- });
231
- });
232
- }
233
- async function findPortNear(from, opts = {}) {
234
- const host = opts.host ?? LOOPBACK;
235
- const span = opts.span ?? SCAN_SPAN;
236
- const skip = opts.skip ?? (() => false);
237
- for (let port2 = from; port2 <= Math.min(from + span, MAX_PORT); port2++) {
238
- if (!skip(port2) && await isPortFree(port2, host)) return port2;
239
- }
240
- let port = await getFreePort(host);
241
- for (let tries = 0; tries < 10 && skip(port); tries++) port = await getFreePort(host);
242
- return port;
243
- }
244
- async function resolvePortPair(prefs = {}) {
245
- const host = prefs.bindAddr?.trim() || LOOPBACK;
246
- const ours = new Set(prefs.reclaimable ?? []);
247
- const prefFor = (kind) => prefs[kind] ?? prefs.previous?.[kind] ?? prefs.stored?.[kind] ?? prefs.defaults?.[kind];
248
- const remembered = (kind, pref) => prefs[kind] === void 0 && (prefs.previous?.[kind] === pref || prefs.stored?.[kind] === pref);
249
- const availableForPref = async (port) => ours.has(port) || await isPortFree(port, host);
250
- const apiPref = prefFor("api");
251
- const dashPref = prefFor("dashboard");
252
- let api;
253
- if (apiPref === void 0) api = await getFreePort(host);
254
- else if (await availableForPref(apiPref)) api = apiPref;
255
- else if (remembered("api", apiPref) && await waitPortFree(apiPref, { host })) api = apiPref;
256
- else {
257
- api = await findPortNear(apiPref + 1, {
258
- host,
259
- skip: (p2) => ours.has(p2) || p2 === dashPref
260
- });
261
- }
262
- const skipForDash = (p2) => ours.has(p2) || p2 === api;
263
- let dashboard;
264
- if (dashPref === void 0) {
265
- dashboard = await getFreePort(host);
266
- for (let tries = 0; tries < 10 && dashboard === api; tries++) {
267
- dashboard = await getFreePort(host);
268
- }
269
- } else if (dashPref !== api && await availableForPref(dashPref)) {
270
- dashboard = dashPref;
271
- } else if (dashPref !== api && remembered("dashboard", dashPref) && await waitPortFree(dashPref, { host })) {
272
- dashboard = dashPref;
273
- } else {
274
- dashboard = await findPortNear(dashPref + 1, { host, skip: skipForDash });
275
- }
276
- return {
277
- api,
278
- dashboard,
279
- switched: {
280
- api: apiPref !== void 0 && api !== apiPref,
281
- dashboard: dashPref !== void 0 && dashboard !== dashPref
282
- },
283
- preferred: { api: apiPref ?? api, dashboard: dashPref ?? dashboard }
284
- };
285
- }
286
-
287
- // src/lib/ports.ts
288
- var PORTS_FILE = join(OS_DIR, "ports.json");
289
- var INSTANCE_FILE = join(OS_DIR, "instance.json");
290
- function saveInstanceUrl(publicUrl) {
291
- try {
292
- if (!existsSync(OS_DIR)) mkdirSync(OS_DIR, { recursive: true, mode: 448 });
293
- writeFileSync(INSTANCE_FILE, JSON.stringify({ publicUrl: publicUrl ?? null }));
294
- } catch {
295
- }
296
- }
297
- function readInstanceUrl() {
298
- try {
299
- return JSON.parse(readFileSync(INSTANCE_FILE, "utf8")).publicUrl ?? null;
300
- } catch {
301
- return null;
302
- }
303
- }
304
- function readStoredPorts() {
305
- try {
306
- return JSON.parse(readFileSync(PORTS_FILE, "utf-8"));
307
- } catch {
308
- return {};
309
- }
310
- }
311
- function storedApiPort() {
312
- return readStoredPorts().api ?? DEFAULT_API_PORT;
313
- }
314
- function storedDashboardPort() {
315
- return readStoredPorts().dashboard ?? DEFAULT_DASHBOARD_PORT;
316
- }
317
- function saveStoredPorts(api, dashboard) {
318
- try {
319
- if (!existsSync(OS_DIR)) mkdirSync(OS_DIR, { recursive: true, mode: 448 });
320
- writeFileSync(PORTS_FILE, JSON.stringify({ api, dashboard }));
321
- } catch {
322
- }
323
- }
324
- function portMoveNotice(resolved, trustedOriginUrls = []) {
325
- if (!resolved.switched.api && !resolved.switched.dashboard) return [];
326
- const lines = [
327
- `A preferred port was busy \u2014 using API ${resolved.api}, dashboard ${resolved.dashboard}.`
328
- ];
329
- const stale = [...new Set(stalePortOrigins(resolved.preferred.dashboard, trustedOriginUrls))];
330
- if (stale.length) {
331
- lines.push(
332
- `Heads up: ${stale.join(", ")} still names port ${resolved.preferred.dashboard}, so logins`,
333
- `from that origin will be rejected. Point it at :${resolved.dashboard} (or free port`,
334
- `${resolved.preferred.dashboard} and re-run to move back).`
335
- );
336
- }
337
- return lines;
338
- }
339
- function stalePortOrigins(movedFrom, urls) {
340
- const stale = [];
341
- for (const raw of urls) {
342
- for (const candidate of (raw ?? "").split(",")) {
343
- const value = candidate.trim();
344
- if (!value) continue;
345
- try {
346
- if (new URL(value).port === String(movedFrom)) stale.push(value);
347
- } catch {
348
- }
349
- }
350
- }
351
- return stale;
352
- }
353
- async function resolvePorts(prefs, opts = {}) {
354
- const resolved = await resolvePortPair({
355
- ...prefs,
356
- stored: readStoredPorts(),
357
- defaults: { api: DEFAULT_API_PORT, dashboard: DEFAULT_DASHBOARD_PORT }
358
- });
359
- if (opts.persist !== false) saveStoredPorts(resolved.api, resolved.dashboard);
360
- return resolved;
361
- }
170
+ withTimeout
171
+ } from "./chunk-UJ6JZM65.js";
362
172
 
363
173
  // ../../packages/adapters/src/runtime/image-transfer.ts
364
174
  import { Transform } from "stream";
@@ -382,8 +192,10 @@ var MAIL_CONTAINER_MOUNTS = [
382
192
  { host: `${MAIL_HOST_STATE_DIR}/config/dovecot`, container: "/etc/dovecot", seed: true },
383
193
  { host: `${MAIL_HOST_STATE_DIR}/config/amavis`, container: "/etc/amavis/conf.d", seed: true },
384
194
  // ClamAV signatures — bind-mounted so a pull doesn't force a multi-hundred-MB
385
- // freshclam re-download and delay readiness.
386
- { 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 },
387
199
  { host: "/etc/letsencrypt", container: "/etc/letsencrypt", readonly: true }
388
200
  ];
389
201
  var MAIL_DB_HOST_DATA_DIR = `${MAIL_HOST_STATE_DIR}/pgdata`;
@@ -1421,7 +1233,7 @@ var DockerBackupExecutor = class {
1421
1233
  } catch {
1422
1234
  }
1423
1235
  }, opts.timeoutMs) : null;
1424
- return new Promise((resolve3, reject) => {
1236
+ return new Promise((resolve2, reject) => {
1425
1237
  body.on("error", (err) => {
1426
1238
  if (timer) clearTimeout(timer);
1427
1239
  try {
@@ -1433,7 +1245,7 @@ var DockerBackupExecutor = class {
1433
1245
  stream.on("end", async () => {
1434
1246
  if (timer) clearTimeout(timer);
1435
1247
  try {
1436
- resolve3({
1248
+ resolve2({
1437
1249
  code: await resolveExecExitCode(exec, exec.id),
1438
1250
  stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
1439
1251
  });
@@ -1507,10 +1319,10 @@ var DockerBackupExecutor = class {
1507
1319
  );
1508
1320
  stream.on("data", () => watchdog.touch());
1509
1321
  const awaitExit = (async () => {
1510
- const onEnd = new Promise((resolve3, reject) => {
1322
+ const onEnd = new Promise((resolve2, reject) => {
1511
1323
  stream.on("end", async () => {
1512
1324
  try {
1513
- resolve3({
1325
+ resolve2({
1514
1326
  code: await resolveExecExitCode(docker.getExec(execId), execId),
1515
1327
  stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
1516
1328
  });
@@ -1633,7 +1445,7 @@ var CloudBackupExecutor = class {
1633
1445
  const stdout = new Readable2({ read() {
1634
1446
  } });
1635
1447
  let stderrBuf = "";
1636
- const awaitExit = new Promise((resolve3, reject) => {
1448
+ const awaitExit = new Promise((resolve2, reject) => {
1637
1449
  const timer = opts?.timeoutMs ? setTimeout(() => {
1638
1450
  stdout.destroy(new Error(`exec timed out after ${opts.timeoutMs}ms`));
1639
1451
  reject(new Error(`exec timed out after ${opts.timeoutMs}ms`));
@@ -1666,7 +1478,7 @@ var CloudBackupExecutor = class {
1666
1478
  }
1667
1479
  stdout.push(null);
1668
1480
  if (timer) clearTimeout(timer);
1669
- resolve3({ code: exitCode, stderr: stderrBuf });
1481
+ resolve2({ code: exitCode, stderr: stderrBuf });
1670
1482
  } catch (err) {
1671
1483
  if (timer) clearTimeout(timer);
1672
1484
  stdout.destroy(err);
@@ -1775,7 +1587,7 @@ import { pipeline } from "stream/promises";
1775
1587
  import { createWriteStream, promises as fs } from "fs";
1776
1588
  import { randomBytes as randomBytes2 } from "crypto";
1777
1589
  import { tmpdir } from "os";
1778
- import { join as join2 } from "path";
1590
+ import { join } from "path";
1779
1591
  var BareBackupExecutor = class {
1780
1592
  constructor(runtime) {
1781
1593
  this.runtime = runtime;
@@ -1849,8 +1661,8 @@ var BareBackupExecutor = class {
1849
1661
  async pipeIntoCommand(service, cmd, body, opts) {
1850
1662
  const exec = this.executor();
1851
1663
  const id = randomBytes2(6).toString("hex");
1852
- const localDir = join2(tmpdir(), `openship-bare-restore-${id}`);
1853
- const localFile = join2(localDir, "artifact.bin");
1664
+ const localDir = join(tmpdir(), `openship-bare-restore-${id}`);
1665
+ const localFile = join(localDir, "artifact.bin");
1854
1666
  const remoteDir = `/tmp/openship-bare-restore-${id}`;
1855
1667
  const remoteFile = `${remoteDir}/artifact.bin`;
1856
1668
  await fs.mkdir(localDir, { recursive: true });
@@ -1899,7 +1711,7 @@ registerExecutor("bare", (runtime) => {
1899
1711
  // ../../packages/adapters/src/backup/destinations/local.ts
1900
1712
  import { createHash as createHash4, randomBytes as randomBytes3 } from "crypto";
1901
1713
  import { createReadStream, createWriteStream as createWriteStream2, promises as fs2 } from "fs";
1902
- import { dirname, join as join3, normalize, posix, resolve } from "path";
1714
+ import { dirname, join as join2, normalize, posix, resolve } from "path";
1903
1715
  import { pipeline as pipeline2 } from "stream";
1904
1716
  import { promisify } from "util";
1905
1717
  var pipelineP = promisify(pipeline2);
@@ -1922,13 +1734,13 @@ var LocalDestinationImpl = class {
1922
1734
  if (normalized.startsWith("..") || normalized.includes(`${normalize("../")}`)) {
1923
1735
  throw new Error(`Invalid key (traversal): ${key}`);
1924
1736
  }
1925
- return join3(this.root, normalized);
1737
+ return join2(this.root, normalized);
1926
1738
  }
1927
1739
  async preflight() {
1928
1740
  try {
1929
1741
  await fs2.mkdir(this.root, { recursive: true });
1930
1742
  const probeKey = `.openship-probe-${randomBytes3(6).toString("hex")}`;
1931
- const probePath = join3(this.root, probeKey);
1743
+ const probePath = join2(this.root, probeKey);
1932
1744
  await fs2.writeFile(probePath, "ok");
1933
1745
  await fs2.readFile(probePath);
1934
1746
  await fs2.unlink(probePath);
@@ -2002,7 +1814,7 @@ var LocalDestinationImpl = class {
2002
1814
  for (const dirent of dirents) {
2003
1815
  if (entries.length >= limit) return;
2004
1816
  const childRel = posix.join(relBase, dirent.name);
2005
- const childAbs = join3(dir, dirent.name);
1817
+ const childAbs = join2(dir, dirent.name);
2006
1818
  if (dirent.isDirectory()) {
2007
1819
  await walk(childAbs, childRel);
2008
1820
  } else if (dirent.isFile()) {
@@ -11672,7 +11484,7 @@ var defaultProvider = (init = {}) => memoizeChain([
11672
11484
  },
11673
11485
  async (awsIdentityProperties) => {
11674
11486
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromIni");
11675
- const { fromIni } = await import("./dist-es-ZP5QDSJ3.js");
11487
+ const { fromIni } = await import("./dist-es-AXFRS7PS.js");
11676
11488
  return fromIni(init)(awsIdentityProperties);
11677
11489
  },
11678
11490
  async (awsIdentityProperties) => {
@@ -11724,14 +11536,14 @@ var readableStreamHasher = (hashCtor, readableStream) => {
11724
11536
  const hash = new hashCtor();
11725
11537
  const hashCalculator = new HashCalculator(hash);
11726
11538
  readableStream.pipe(hashCalculator);
11727
- return new Promise((resolve3, reject) => {
11539
+ return new Promise((resolve2, reject) => {
11728
11540
  readableStream.on("error", (err) => {
11729
11541
  hashCalculator.end();
11730
11542
  reject(err);
11731
11543
  });
11732
11544
  hashCalculator.on("error", reject);
11733
11545
  hashCalculator.on("finish", () => {
11734
- hash.digest().then(resolve3).catch(reject);
11546
+ hash.digest().then(resolve2).catch(reject);
11735
11547
  });
11736
11548
  });
11737
11549
  };
@@ -12587,7 +12399,7 @@ to input.params.ContentLength in bytes.
12587
12399
  }
12588
12400
  }
12589
12401
  async __abortTimeout(abortSignal) {
12590
- return new Promise((resolve3, reject) => {
12402
+ return new Promise((resolve2, reject) => {
12591
12403
  abortSignal.onabort = () => {
12592
12404
  const abortError = new Error("Upload aborted.");
12593
12405
  abortError.name = "AbortError";
@@ -13003,7 +12815,7 @@ var SftpDestinationImpl = class {
13003
12815
  // ── Connection helper ────────────────────────────────────────────────
13004
12816
  async withSftp(fn) {
13005
12817
  const client = new Client2();
13006
- return new Promise((resolve3, reject) => {
12818
+ return new Promise((resolve2, reject) => {
13007
12819
  const cleanup = () => {
13008
12820
  try {
13009
12821
  client.end();
@@ -13020,7 +12832,7 @@ var SftpDestinationImpl = class {
13020
12832
  fn(sftp).then(
13021
12833
  (val) => {
13022
12834
  cleanup();
13023
- resolve3(val);
12835
+ resolve2(val);
13024
12836
  },
13025
12837
  (e2) => {
13026
12838
  cleanup();
@@ -13040,12 +12852,12 @@ var SftpDestinationImpl = class {
13040
12852
  let current = dir.startsWith("/") ? "" : ".";
13041
12853
  for (const part of parts) {
13042
12854
  current = current === "" ? `/${part}` : posix2.join(current, part);
13043
- await new Promise((resolve3, reject) => {
12855
+ await new Promise((resolve2, reject) => {
13044
12856
  sftp.mkdir(current, (err) => {
13045
- if (!err) return resolve3();
12857
+ if (!err) return resolve2();
13046
12858
  sftp.stat(current, (statErr, stats) => {
13047
12859
  if (statErr) return reject(err);
13048
- if (stats.isDirectory()) return resolve3();
12860
+ if (stats.isDirectory()) return resolve2();
13049
12861
  reject(new Error(`${current} exists but is not a directory`));
13050
12862
  });
13051
12863
  });
@@ -13059,14 +12871,14 @@ var SftpDestinationImpl = class {
13059
12871
  await this.withSftp(async (sftp) => {
13060
12872
  await this.ensureDir(sftp, this.rootPath);
13061
12873
  const probePath = posix2.join(this.rootPath, probeName);
13062
- await new Promise((resolve3, reject) => {
12874
+ await new Promise((resolve2, reject) => {
13063
12875
  const ws = sftp.createWriteStream(probePath);
13064
12876
  ws.on("error", reject);
13065
- ws.on("close", () => resolve3());
12877
+ ws.on("close", () => resolve2());
13066
12878
  ws.end("ok");
13067
12879
  });
13068
12880
  await new Promise(
13069
- (resolve3, reject) => sftp.unlink(probePath, (err) => err ? reject(err) : resolve3())
12881
+ (resolve2, reject) => sftp.unlink(probePath, (err) => err ? reject(err) : resolve2())
13070
12882
  );
13071
12883
  });
13072
12884
  return { ok: true };
@@ -13083,7 +12895,7 @@ var SftpDestinationImpl = class {
13083
12895
  let bytesWritten = 0;
13084
12896
  await this.withSftp(async (sftp) => {
13085
12897
  await this.ensureDir(sftp, posix2.dirname(target));
13086
- await new Promise((resolve3, reject) => {
12898
+ await new Promise((resolve2, reject) => {
13087
12899
  const ws = sftp.createWriteStream(tmp);
13088
12900
  let lastProgressAt = Date.now();
13089
12901
  let settled = false;
@@ -13097,7 +12909,7 @@ var SftpDestinationImpl = class {
13097
12909
  body.destroy();
13098
12910
  reject(err);
13099
12911
  } else {
13100
- resolve3();
12912
+ resolve2();
13101
12913
  }
13102
12914
  };
13103
12915
  watchdog = setInterval(() => {
@@ -13118,14 +12930,14 @@ var SftpDestinationImpl = class {
13118
12930
  body.on("error", (err) => finish(err));
13119
12931
  body.pipe(ws);
13120
12932
  });
13121
- await new Promise((resolve3, reject) => {
12933
+ await new Promise((resolve2, reject) => {
13122
12934
  sftp.rename(tmp, target, (err) => {
13123
- if (!err) return resolve3();
12935
+ if (!err) return resolve2();
13124
12936
  sftp.unlink(target, () => {
13125
12937
  sftp.rename(
13126
12938
  tmp,
13127
12939
  target,
13128
- (err2) => err2 ? reject(err2) : resolve3()
12940
+ (err2) => err2 ? reject(err2) : resolve2()
13129
12941
  );
13130
12942
  });
13131
12943
  });
@@ -13172,14 +12984,14 @@ var SftpDestinationImpl = class {
13172
12984
  const target = this.fullPath(key);
13173
12985
  try {
13174
12986
  return await this.withSftp(
13175
- (sftp) => new Promise((resolve3, reject) => {
12987
+ (sftp) => new Promise((resolve2, reject) => {
13176
12988
  sftp.stat(target, (err, stats) => {
13177
12989
  if (err) {
13178
12990
  const code = err.code;
13179
- if (code === 2) return resolve3(null);
12991
+ if (code === 2) return resolve2(null);
13180
12992
  return reject(err);
13181
12993
  }
13182
- resolve3({
12994
+ resolve2({
13183
12995
  sizeBytes: stats.size,
13184
12996
  uploadedAt: new Date(stats.mtime * 1e3)
13185
12997
  });
@@ -13196,14 +13008,14 @@ var SftpDestinationImpl = class {
13196
13008
  const entries = [];
13197
13009
  await this.withSftp(async (sftp) => {
13198
13010
  const walk = async (dir, relBase) => {
13199
- const dirents = await new Promise((resolve3, reject) => {
13011
+ const dirents = await new Promise((resolve2, reject) => {
13200
13012
  sftp.readdir(dir, (err, list) => {
13201
13013
  if (err) {
13202
13014
  const code = err.code;
13203
- if (code === 2) return resolve3([]);
13015
+ if (code === 2) return resolve2([]);
13204
13016
  return reject(err);
13205
13017
  }
13206
- resolve3(list);
13018
+ resolve2(list);
13207
13019
  });
13208
13020
  });
13209
13021
  for (const dirent of dirents) {
@@ -13229,10 +13041,10 @@ var SftpDestinationImpl = class {
13229
13041
  const target = this.fullPath(key);
13230
13042
  await this.withSftp(
13231
13043
  (sftp) => new Promise(
13232
- (resolve3, reject) => sftp.unlink(target, (err) => {
13233
- if (!err) return resolve3();
13044
+ (resolve2, reject) => sftp.unlink(target, (err) => {
13045
+ if (!err) return resolve2();
13234
13046
  const code = err.code;
13235
- if (code === 2) return resolve3();
13047
+ if (code === 2) return resolve2();
13236
13048
  reject(err);
13237
13049
  })
13238
13050
  )
@@ -13244,11 +13056,11 @@ var SftpDestinationImpl = class {
13244
13056
  const failed = [];
13245
13057
  await this.withSftp(async (sftp) => {
13246
13058
  for (const key of keys) {
13247
- await new Promise((resolve3) => {
13059
+ await new Promise((resolve2) => {
13248
13060
  sftp.unlink(this.fullPath(key), (err) => {
13249
13061
  if (!err) {
13250
13062
  deleted.push(key);
13251
- return resolve3();
13063
+ return resolve2();
13252
13064
  }
13253
13065
  const code = err.code;
13254
13066
  if (code === 2) {
@@ -13256,7 +13068,7 @@ var SftpDestinationImpl = class {
13256
13068
  } else {
13257
13069
  failed.push({ key, error: err.message });
13258
13070
  }
13259
- resolve3();
13071
+ resolve2();
13260
13072
  });
13261
13073
  });
13262
13074
  }
@@ -13280,1609 +13092,39 @@ function startUnitHint(unit) {
13280
13092
  return pasteable(thisHost().serviceEnableStart(unit));
13281
13093
  }
13282
13094
 
13283
- // src/lib/from-source.ts
13284
- import { spawn, spawnSync as spawnSync2 } from "child_process";
13285
- import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
13286
- import { join as join4, resolve as resolve2 } from "path";
13287
- var DEFAULT_REPO = "https://github.com/oblien/openship.git";
13288
- var SOURCE_CHECKOUT_DIR = join4(OS_DIR, "src");
13289
- var SOURCE_DIST_DIR = join4(OS_DIR, "from-source-dist");
13290
- function has(cmd) {
13291
- try {
13292
- return spawnSync2(cmd, ["--version"], { stdio: "ignore" }).status === 0;
13293
- } catch {
13294
- return false;
13295
- }
13296
- }
13297
- function run(cmd, args, cwd, env) {
13298
- return new Promise((res, rej) => {
13299
- const child = spawn(cmd, args, {
13300
- cwd,
13301
- stdio: "inherit",
13302
- // Windows resolves the bun/git shims (.cmd/.exe) only through a shell.
13303
- shell: process.platform === "win32",
13304
- env: env ? { ...process.env, ...env } : process.env
13305
- });
13306
- child.on("error", rej);
13307
- child.on(
13308
- "exit",
13309
- (code) => code === 0 ? res() : rej(new Error(`\`${cmd} ${args.join(" ")}\` (cwd=${cwd}) exited ${code ?? "?"}`))
13310
- );
13311
- });
13312
- }
13313
- function shortSha(cwd) {
13314
- const r2 = spawnSync2("git", ["rev-parse", "--short", "HEAD"], { cwd, encoding: "utf8" });
13315
- return r2.status === 0 ? (r2.stdout ?? "").trim() || "unknown" : "unknown";
13316
- }
13317
- function isMonorepo(dir) {
13318
- return existsSync2(join4(dir, "package.json")) && existsSync2(join4(dir, "apps/api/package.json")) && existsSync2(join4(dir, "apps/dashboard/package.json"));
13319
- }
13320
- async function prepareFromSource(opts) {
13321
- if (!has("bun")) {
13322
- throw new Error(
13323
- "`bun` is required to build from source but wasn't found on PATH \u2014 install it: https://bun.sh"
13324
- );
13325
- }
13326
- let sourceDir;
13327
- let ref;
13328
- if (opts.source) {
13329
- sourceDir = resolve2(opts.source);
13330
- if (!isMonorepo(sourceDir)) {
13331
- throw new Error(
13332
- `--source ${sourceDir} doesn't look like an Openship checkout (missing package.json / apps/api / apps/dashboard).`
13333
- );
13334
- }
13335
- ref = "local";
13336
- } else {
13337
- if (!has("git")) {
13338
- throw new Error("`git` is required to clone the source but wasn't found on PATH.");
13339
- }
13340
- ref = (opts.ref || "main").trim();
13341
- const repoUrl = opts.repo || DEFAULT_REPO;
13342
- sourceDir = SOURCE_CHECKOUT_DIR;
13343
- mkdirSync2(OS_DIR, { recursive: true });
13344
- if (!existsSync2(join4(sourceDir, ".git"))) {
13345
- console.log(` Cloning ${repoUrl} \u2192 ${sourceDir}`);
13346
- await run("git", ["clone", repoUrl, sourceDir], OS_DIR);
13347
- }
13348
- console.log(` Fetching + checking out ${ref}`);
13349
- await run("git", ["fetch", "origin", ref, "--tags"], sourceDir);
13350
- await run("git", ["checkout", ref], sourceDir);
13351
- await run("git", ["pull", "--ff-only", "origin", ref], sourceDir).catch(() => {
13352
- console.log(" (pinned ref \u2014 not fast-forwarding)");
13353
- });
13354
- }
13355
- const sha = shortSha(sourceDir);
13356
- console.log(` Source: ${sourceDir} @ ${ref} (${sha})`);
13357
- console.log(" Installing workspace dependencies (bun install)\u2026");
13358
- await run("bun", ["install"], sourceDir);
13359
- const distDir = SOURCE_DIST_DIR;
13360
- console.log(" Building release dist (compiles the dashboard \u2014 needs RAM/CPU)\u2026");
13361
- await run(
13362
- "bun",
13363
- ["run", join4(sourceDir, "apps/api/scripts/build-release.ts")],
13364
- sourceDir,
13365
- { DIST_DIR: distDir, NODE_ENV: "production", CLOUD_MODE: "false", OPENSHIP_TARGET: "local" }
13366
- );
13367
- console.log(" Installing runtime dependencies in the dist\u2026");
13368
- await run("bun", ["install", "--production", "--frozen-lockfile"], distDir);
13369
- const apiDir = join4(distDir, "api");
13370
- const dashboardDir = join4(distDir, "dashboard");
13371
- if (!existsSync2(join4(apiDir, "src/index.ts"))) {
13372
- throw new Error(`Build produced no API at ${apiDir}/src/index.ts \u2014 build-release layout drift?`);
13373
- }
13374
- if (!existsSync2(join4(dashboardDir, "apps/dashboard/server.js"))) {
13375
- throw new Error(`Build produced no dashboard at ${dashboardDir}/apps/dashboard/server.js.`);
13376
- }
13377
- return { apiDir, dashboardDir, ref, sha, sourceDir };
13378
- }
13379
-
13380
- // src/lib/compose.ts
13381
- import { spawn as spawn2, spawnSync as spawnSync4 } from "child_process";
13382
- import { randomBytes as randomBytes5 } from "crypto";
13383
- import {
13384
- chmodSync,
13385
- existsSync as existsSync4,
13386
- mkdirSync as mkdirSync3,
13387
- readFileSync as readFileSync3,
13388
- renameSync,
13389
- rmSync,
13390
- writeFileSync as writeFileSync2
13391
- } from "fs";
13392
- import { homedir, userInfo } from "os";
13393
- import { dirname as dirname2, join as join6 } from "path";
13394
-
13395
- // src/lib/source-install.ts
13396
- import { spawnSync as spawnSync3 } from "child_process";
13397
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
13398
- import { join as join5 } from "path";
13399
- var MARKER = join5(OS_DIR, "source-install.json");
13400
- function parseSourceInstall(raw) {
13401
- try {
13402
- const obj = JSON.parse(raw);
13403
- if (!obj || typeof obj.dir !== "string" || !obj.dir) return null;
13404
- return { repo: obj.repo || DEFAULT_REPO, ref: obj.ref || "main", dir: obj.dir };
13405
- } catch {
13406
- return null;
13407
- }
13408
- }
13409
- function readSourceInstall() {
13410
- if (!existsSync3(MARKER)) return null;
13411
- try {
13412
- return parseSourceInstall(readFileSync2(MARKER, "utf8"));
13413
- } catch {
13414
- return null;
13415
- }
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);
13416
13103
  }
13417
- function parseRemoteSha(stdout) {
13418
- const sha = stdout.split("\n")[0]?.trim().split(/\s+/)[0];
13419
- return sha && /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 7) : null;
13420
- }
13421
- function remoteSha(repo, ref) {
13422
- const r2 = spawnSync3("git", ["ls-remote", repo, ref], { encoding: "utf8" });
13423
- if (r2.status !== 0 || !r2.stdout) return null;
13424
- return parseRemoteSha(r2.stdout);
13425
- }
13426
- async function rebuildFromSource(info) {
13427
- if (!has("bun")) {
13428
- throw new Error("`bun` is required to rebuild from source but wasn't found on PATH \u2014 https://bun.sh");
13429
- }
13430
- if (!has("git")) {
13431
- throw new Error("`git` is required to update the source checkout but wasn't found on PATH.");
13432
- }
13433
- if (!existsSync3(join5(info.dir, ".git"))) {
13434
- throw new Error(
13435
- `Source checkout ${info.dir} is missing (no .git) \u2014 reinstall from source with scripts/install-source.sh.`
13436
- );
13437
- }
13438
- await run("git", ["fetch", "origin", info.ref, "--tags"], info.dir);
13439
- await run("git", ["checkout", info.ref], info.dir);
13440
- await run("git", ["pull", "--ff-only", "origin", info.ref], info.dir).catch(() => {
13441
- });
13442
- const cliDir = join5(info.dir, "apps/cli");
13443
- await run("bun", ["install"], info.dir);
13444
- await run("bun", ["run", "build"], cliDir);
13445
- await run("bun", ["run", "build/stage-dashboard.ts"], cliDir);
13446
- return shortSha(info.dir);
13447
- }
13448
-
13449
- // src/lib/compose.ts
13450
- var EDGE_SITES_HOST_DIR = `${EDGE_HOST_STATE_DIR}/sites-enabled`;
13451
- var EDGE_ACME_HOST_DIR = `${EDGE_HOST_STATE_DIR}/acme`;
13452
- function edgeVolumeYaml(indent) {
13453
- return EDGE_CONTAINER_MOUNTS.map((m2) => `${indent}- ${m2.host}:${m2.container}:z`).join("\n");
13454
- }
13455
- var COMPOSE_DIR = join6(OS_DIR, "compose");
13456
- var INSTALL_METHOD_FILE = join6(OS_DIR, "install-method");
13457
- var COMPOSE_FILE = join6(COMPOSE_DIR, "docker-compose.yml");
13458
- var BUILD_FILE = join6(COMPOSE_DIR, "docker-compose.build.yml");
13459
- var ENV_FILE = join6(COMPOSE_DIR, ".env");
13460
- var ENV_BACKUP_FILE = join6(COMPOSE_DIR, ".env.bak");
13461
- var ENV_TMP_FILE = join6(COMPOSE_DIR, ".env.tmp");
13462
- var BUILT_SERVICES = [
13463
- { service: "api", dockerfile: "apps/api/Dockerfile" },
13464
- { service: "dashboard", dockerfile: "apps/dashboard/Dockerfile" },
13465
- { service: "edge", dockerfile: "apps/edge/Dockerfile" }
13466
- ];
13467
- var ENV_CONSUMING_SERVICES = ["api", "dashboard", "edge"];
13468
- function readInstallMethod() {
13469
- try {
13470
- const v2 = readFileSync3(INSTALL_METHOD_FILE, "utf8").trim();
13471
- return v2 === "compose" || v2 === "bare" ? v2 : null;
13472
- } catch {
13473
- return null;
13474
- }
13475
- }
13476
- function writeInstallMethod(method) {
13477
- mkdirSync3(OS_DIR, { recursive: true, mode: 448 });
13478
- writeFileSync2(INSTALL_METHOD_FILE, method, { mode: 384 });
13479
- }
13480
- function dockerState() {
13481
- const ok = (args) => spawnSync4("docker", args, { stdio: "ignore" }).status === 0;
13482
- if (!ok(["--version"])) return { binary: false, plugin: false, daemon: false };
13483
- return { binary: true, plugin: ok(["compose", "version"]), daemon: ok(["version"]) };
13484
- }
13485
- function dockerGap(state = dockerState()) {
13486
- if (!state.binary) {
13487
- return { summary: "Docker isn't installed", installable: true };
13488
- }
13489
- if (!state.plugin) {
13490
- return {
13491
- summary: "Docker is installed but the Compose plugin (`docker compose`) is missing",
13492
- installable: true
13493
- };
13494
- }
13495
- if (!state.daemon) {
13496
- const start = pasteable(thisHost().dockerStart());
13497
- return {
13498
- summary: "Docker is installed but its daemon isn't reachable",
13499
- // Reinstalling changes nothing: a group change only applies to NEW logins,
13500
- // and a stopped daemon needs starting, not installing.
13501
- installable: false,
13502
- hint: thisHost().profile.isRoot ? start ? `Start it with: ${start}` : "Start the Docker daemon \u2014 this host has no service manager Openship recognizes." : `Add your user to the docker group: sudo usermod -aG docker ${userInfo().username} \u2014 then log out and back in (or run: newgrp docker). If the daemon is stopped: ${start ?? "start it the way this host starts services"}`
13503
- };
13504
- }
13505
- return null;
13506
- }
13507
- function hasDockerCompose() {
13508
- return dockerGap() === null;
13509
- }
13510
- function hasCmd(cmd) {
13511
- return spawnSync4(cmd, ["--version"], { stdio: "ignore" }).status === 0;
13512
- }
13513
- function shQuote(s2) {
13514
- return `'${s2.replace(/'/g, `'\\''`)}'`;
13515
- }
13516
- function dockerInstallPreview(state = dockerState()) {
13517
- const gap = dockerGap(state);
13518
- if (!gap) return { state, gap: null, wouldInstall: false };
13519
- if (process.platform !== "linux" || !gap.installable) return { state, gap, wouldInstall: false };
13520
- const plan = systemCatalog.installs.docker(resolveLocalEnvironmentSync());
13521
- if (!plan.supported) return { state, gap, wouldInstall: false, unsupportedReason: plan.reason };
13522
- const start = plan.value.service;
13523
- return {
13524
- state,
13525
- gap,
13526
- wouldInstall: true,
13527
- installCommand: plan.value.installCommand,
13528
- ...start?.supported ? { startCommand: start.value } : {},
13529
- ...start && !start.supported ? { startBlockedReason: start.reason } : {}
13530
- };
13531
- }
13532
- async function ensureDocker(opts = {}) {
13533
- const notice = opts.onNotice ?? ((line) => process.stderr.write(` ${line}
13534
- `));
13535
- const { state, gap, wouldInstall, installCommand, startCommand, unsupportedReason, startBlockedReason } = dockerInstallPreview();
13536
- if (!gap) return true;
13537
- if (process.platform !== "linux") return false;
13538
- if (!gap.installable) {
13539
- notice(gap.summary + ".");
13540
- if (gap.hint) notice(gap.hint);
13541
- return false;
13542
- }
13543
- if (!wouldInstall || !installCommand) {
13544
- if (unsupportedReason) notice(unsupportedReason);
13545
- return false;
13546
- }
13547
- const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
13548
- const sudo = !asRoot && hasCmd("sudo") ? "sudo " : "";
13549
- notice("This can take 2-5 minutes on a small VPS (~150 MB of packages) and stays quiet while apt works.");
13550
- if (state.binary) {
13551
- notice("Docker's installer will warn that docker already exists and pause ~20s before continuing \u2014 that's expected.");
13552
- }
13553
- const sh = (script) => new Promise((resolve3) => {
13554
- const child = spawn2("sh", ["-c", sudo ? `${sudo}sh -c ${shQuote(script)}` : script], {
13555
- stdio: "inherit"
13556
- });
13557
- const started = Date.now();
13558
- const tick = setInterval(() => {
13559
- const s2 = Math.round((Date.now() - started) / 1e3);
13560
- notice(`still installing Docker \u2014 ${Math.floor(s2 / 60)}m${String(s2 % 60).padStart(2, "0")}s elapsed\u2026`);
13561
- }, 3e4);
13562
- const done = (code) => {
13563
- clearInterval(tick);
13564
- resolve3(code);
13565
- };
13566
- child.on("error", () => done(1));
13567
- child.on("close", (code) => done(code ?? 1));
13568
- });
13569
- if (await sh(installCommand) !== 0) return false;
13570
- if (startCommand) await sh(startCommand);
13571
- else if (startBlockedReason) notice(startBlockedReason);
13572
- const after = dockerGap();
13573
- if (!after) {
13574
- notice("Docker ready.");
13575
- return true;
13576
- }
13577
- notice(after.summary + ".");
13578
- if (after.hint) notice(after.hint);
13579
- return false;
13580
- }
13581
- var COMPOSE_YAML = `# Managed by \`openship up\` \u2014 do not edit; re-run \`openship up\` to regenerate.
13582
- services:
13583
- postgres:
13584
- image: postgres:16-alpine
13585
- restart: unless-stopped
13586
- environment:
13587
- # Keep the data dir in a subdirectory of the volume so a fresh install never
13588
- # runs initdb against a bare mount root (which fails on quirky host
13589
- # filesystems with EPERM \u2014 #350). OPENSHIP_PGDATA is decided ONCE at install
13590
- # by the CLI (fresh \u2192 subdir, pre-existing volume \u2192 root) and preserved in
13591
- # .env, so this never moves an existing database.
13592
- PGDATA: \${OPENSHIP_PGDATA:-/var/lib/postgresql/data/pgdata}
13593
- POSTGRES_USER: \${POSTGRES_USER:-openship}
13594
- POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?missing from .env \u2014 re-run openship up to regenerate it}
13595
- POSTGRES_DB: \${POSTGRES_DB:-openship}
13596
- expose: ["5432"]
13597
- volumes: [postgres_data:/var/lib/postgresql/data]
13598
- healthcheck:
13599
- test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER:-openship} -d \${POSTGRES_DB:-openship}"]
13600
- interval: 5s
13601
- timeout: 3s
13602
- retries: 12
13603
-
13604
- redis:
13605
- image: redis:7-alpine
13606
- restart: unless-stopped
13607
- command: ["redis-server", "--appendonly", "yes"]
13608
- expose: ["6379"]
13609
- volumes: [redis_data:/data]
13610
- healthcheck:
13611
- test: ["CMD", "redis-cli", "ping"]
13612
- interval: 5s
13613
- timeout: 3s
13614
- retries: 12
13615
-
13616
- api:
13617
- image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-api:\${OPENSHIP_VERSION:-latest}
13618
- restart: unless-stopped
13619
- # Loopback by default \u2014 the host-net edge reaches it over loopback, so nothing
13620
- # sits on a public interface. OPENSHIP_BIND_ADDR opts into a public/LAN interface
13621
- # (set by \`openship up\` when a public URL is configured for off-box access).
13622
- ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${API_PORT:-4000}:\${API_PORT:-4000}"]
13623
- volumes:
13624
- # The daemon socket, at the path the api's own default expects INSIDE the
13625
- # container. The HOST side is a variable because it MOVES: a rootless daemon
13626
- # keeps its socket under the invoking user's runtime dir, and Docker creates a
13627
- # missing bind source as an empty DIRECTORY instead of failing \u2014 so the rootful
13628
- # path there brings the stack up and then fails every container operation in
13629
- # the transport, naming neither the path nor the cause (#482). \`openship up\`
13630
- # writes OPENSHIP_DOCKER_SOCKET when the detected path isn't this default.
13631
- - \${OPENSHIP_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
13632
- # Routing state shared with the edge, as HOST BIND MOUNTS (generated from
13633
- # EDGE_CONTAINER_MOUNTS): the vhost tree, /etc/letsencrypt, the ACME webroot
13634
- # and the static doc-roots the API writes and the edge serves. Named volumes
13635
- # hid all of it from every host-side reader (migrate's proxy scan, cert carry,
13636
- # cert reuse, mail cert symlinks), each of which then silently saw "nothing".
13637
- ${edgeVolumeYaml(" ")}
13638
- # Host-op SSH key (createHostExecutor \u2192 host.docker.internal). /dev/null
13639
- # when the host channel isn't provisioned \u2192 OPENSHIP_HOST_SSH_HOST stays
13640
- # unset, and host operations REFUSE (they'd otherwise run against this
13641
- # container's filesystem, #509) while ordinary deploys keep working over
13642
- # the socket below.
13643
- - \${OPENSHIP_HOST_KEY_PATH:-/dev/null}:/run/secrets/openship_host_key:ro
13644
- # \`host-gateway\` is resolved by the daemon and is right on a stock rootful
13645
- # install. It is NOT universally right: under rootless Docker it lands in
13646
- # RootlessKit's namespace rather than the host's, so the host's sshd isn't
13647
- # there (#482). OPENSHIP_HOST_GATEWAY re-points the alias (e.g. at the box's
13648
- # LAN address) without patching this file, which \`openship up\` rewrites.
13649
- extra_hosts: ["host.docker.internal:\${OPENSHIP_HOST_GATEWAY:-host-gateway}"]
13650
- env_file: [.env]
13651
- environment:
13652
- NODE_ENV: production
13653
- PORT: "\${API_PORT:-4000}"
13654
- DATABASE_URL: postgresql://\${POSTGRES_USER:-openship}:\${POSTGRES_PASSWORD:?missing from .env \u2014 re-run openship up to regenerate it}@postgres:5432/\${POSTGRES_DB:-openship}
13655
- REDIS_URL: redis://redis:6379
13656
- OPENSHIP_EDGE_MODE: docker
13657
- OPENSHIP_EDGE_CONTAINER: openship-edge
13658
- depends_on:
13659
- postgres: { condition: service_healthy }
13660
- redis: { condition: service_healthy }
13661
- healthcheck:
13662
- 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))\\""]
13663
- interval: 10s
13664
- timeout: 5s
13665
- retries: 12
13666
- start_period: 40s
13667
-
13668
- dashboard:
13669
- image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-dashboard:\${OPENSHIP_VERSION:-latest}
13670
- restart: unless-stopped
13671
- # Loopback by default (see api note); OPENSHIP_BIND_ADDR opts into a public interface.
13672
- ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${DASHBOARD_PORT:-3001}:\${DASHBOARD_PORT:-3001}"]
13673
- env_file: [.env]
13674
- environment:
13675
- NODE_ENV: production
13676
- PORT: "\${DASHBOARD_PORT:-3001}"
13677
- INTERNAL_API_URL: http://api:\${API_PORT:-4000}
13678
- depends_on:
13679
- api: { condition: service_healthy }
13680
-
13681
- edge:
13682
- image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-edge:\${OPENSHIP_VERSION:-latest}
13683
- # PINNED, and it must stay pinned: the api reaches the edge by NAME through
13684
- # DockerEdgeExecutor (OPENSHIP_EDGE_CONTAINER above), and "ours" edge
13685
- # detection greps \`docker ps --filter name=openship-edge\`. Without this,
13686
- # compose derives \`<project>-edge-1\` from the directory and every
13687
- # \`docker exec\` into the edge fails with "No such container: openship-edge" \u2014
13688
- # which silently migrated 0 sites after the operator's proxy was stopped.
13689
- # Safe here: the edge is a singleton (host networking, one per box).
13690
- container_name: openship-edge
13691
- restart: unless-stopped
13692
- network_mode: host
13693
- volumes:
13694
- ${edgeVolumeYaml(" ")}
13695
-
13696
- volumes:
13697
- postgres_data:
13698
- redis_data:
13699
- `;
13700
- function keepSecret(existing, key) {
13701
- return existing[key] || randomBytes5(32).toString("hex");
13702
- }
13703
- var GENERATED_SECRET_KEYS = ["POSTGRES_PASSWORD", "BETTER_AUTH_SECRET", "INTERNAL_TOKEN"];
13704
- function survivingDbVolume(prev) {
13705
- const candidates = /* @__PURE__ */ new Set([prev.COMPOSE_PROJECT_NAME, "openship", "compose"]);
13706
- for (const project of candidates) {
13707
- if (project && dbVolumeExists(project)) return `${project}_postgres_data`;
13708
- }
13709
- return null;
13710
- }
13711
- function secretRotationRisk(prev) {
13712
- const keys = GENERATED_SECRET_KEYS.filter((k2) => !prev[k2]);
13713
- if (keys.length === 0) return null;
13714
- const volume = survivingDbVolume(prev);
13715
- return volume ? { volume, keys: [...keys] } : null;
13716
- }
13717
- function composeSecretRotationRisk(opts = {}) {
13718
- if (opts.resetSecrets) return null;
13719
- return secretRotationRisk(readEnvFile());
13720
- }
13721
- function renderSecretRotationRefusal(rotation, opts = {}) {
13722
- return `
13723
- ${opts.dryRun ? "A real run would REFUSE here" : "Refusing to continue"}: this would regenerate secrets your existing install already has.
13724
-
13725
- missing from ${ENV_FILE}: ${rotation.keys.join(", ")}
13726
- but this install's data is still here: ${rotation.volume}
13727
-
13728
- A new POSTGRES_PASSWORD cannot match that volume \u2014 Postgres only applies the password
13729
- when it first creates the data directory \u2014 and a new BETTER_AUTH_SECRET cannot decrypt
13730
- the environment variables already stored in the database. Nothing has been written yet.
13731
-
13732
- ` + secretRecoveryAdvice(rotation);
13733
- }
13734
- function secretRecoveryAdvice(rotation) {
13735
- const adopted = adoptedStack(projectOfDbVolume(rotation.volume));
13736
- if (adopted) {
13737
- return ` This stack was not created by \`openship up\`: it is already running under the compose
13738
- project name we pin, so this run would ADOPT it.
13739
-
13740
- project: ${adopted.project}
13741
- started from: ${adopted.configFiles || "an unlabelled `docker run`"}
13742
- not from: ${COMPOSE_FILE}
13743
-
13744
- So there is no \`.env\` of ours to restore and no backup of one \u2014 the real values are in
13745
- the api container's own environment. Copy them out of it, then re-run:
13746
-
13747
- echo 'COMPOSE_PROJECT_NAME=${adopted.project}' >> ${ENV_FILE}
13748
- docker inspect ${adopted.apiContainer} --format '{{range .Config.Env}}{{println .}}{{end}}' \\
13749
- | grep -E '^(${rotation.keys.join("|")})=' >> ${ENV_FILE}
13750
-
13751
- COMPOSE_PROJECT_NAME matters as much as the secrets do: it is the volume prefix, so
13752
- without it this stack comes up on empty volumes beside the data above.
13753
-
13754
- Only if that container is gone and the values exist nowhere else \u2014 this DESTROYS them,
13755
- leaving every environment variable stored in the database permanently unreadable:
13756
- openship up --reset-secrets
13757
- `;
13758
- }
13759
- return ` Put the original values back and re-run:
13760
- ` + (existsSync4(ENV_BACKUP_FILE) ? ` cp ${ENV_BACKUP_FILE} ${ENV_FILE} # the .env a previous \`openship up\` replaced
13761
- ` : ` restore ${ENV_FILE} from a backup, or copy the keys above out of one
13762
- `) + ` If the file is there but this user can't read it, re-run as the user that owns it
13763
- (or as root) rather than letting the secrets be regenerated.
13764
-
13765
- To reset instead \u2014 the database password gets realigned, but every stored environment
13766
- variable becomes permanently unreadable:
13767
- openship up --reset-secrets
13768
- `;
13769
- }
13770
- function projectOfDbVolume(volume) {
13771
- return volume.replace(/_postgres_data$/, "");
13772
- }
13773
- function readEnvFile() {
13774
- const out = {};
13104
+ function readComposeEnvFile() {
13775
13105
  let text;
13776
13106
  try {
13777
- text = readFileSync3(ENV_FILE, "utf8");
13107
+ text = readFileSync(COMPOSE_ENV_FILE, "utf8");
13778
13108
  } catch (err) {
13779
- if (err.code !== "ENOENT") {
13780
- console.log(
13781
- ` ! Could not read ${ENV_FILE}: ${err.message}
13782
- Its contents are being treated as absent. If this install already exists, fix the permissions and re-run rather than letting secrets be regenerated.`
13783
- );
13784
- }
13785
- return out;
13109
+ const code = err.code;
13110
+ return { env: {}, unreadable: code === "ENOENT" ? null : err.message };
13786
13111
  }
13112
+ const env = {};
13787
13113
  for (const line of text.split("\n")) {
13788
13114
  const m2 = line.match(/^([A-Z0-9_]+)=(.*)$/);
13789
- if (m2) out[m2[1]] = m2[2];
13790
- }
13791
- return out;
13792
- }
13793
- var HOST_CHANNEL_DEFAULT_HOST = "host.docker.internal";
13794
- var HOST_CHANNEL_DEFAULT_PORT = 22;
13795
- var HOST_KEY_COMMENT = "openship-host-executor";
13796
- var HOST_KEY_FROM = "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8,127.0.0.1";
13797
- function hostKeyAuthLine(pub) {
13798
- return `from="${HOST_KEY_FROM}",restrict,pty ${pub}`;
13799
- }
13800
- function rewriteHostAuthorizedKeys(existing, pub) {
13801
- return [...keptForeignKeys(existing), hostKeyAuthLine(pub)].join("\n") + "\n";
13802
- }
13803
- function keptForeignKeys(existing) {
13804
- return existing.split("\n").filter((line) => line.trim() && !line.includes(HOST_KEY_COMMENT));
13805
- }
13806
- function stripHostAuthorizedKeys(existing) {
13807
- const kept = keptForeignKeys(existing);
13808
- return kept.length ? kept.join("\n") + "\n" : "";
13809
- }
13810
- var ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
13811
- function chooseHostChannelUser(input) {
13812
- if (input.invokerUid === 0) {
13813
- return { user: "root", authKeysPath: ROOT_AUTHORIZED_KEYS, viaSudo: false, rootUnavailable: false };
13814
- }
13815
- if (input.hasPasswordlessSudo) {
13816
- return { user: "root", authKeysPath: ROOT_AUTHORIZED_KEYS, viaSudo: true, rootUnavailable: false };
13115
+ if (m2) env[m2[1]] = m2[2];
13817
13116
  }
13818
- return {
13819
- user: input.invokerName,
13820
- authKeysPath: join6(input.invokerHome, ".ssh", "authorized_keys"),
13821
- viaSudo: false,
13822
- rootUnavailable: true
13823
- };
13117
+ return { env, unreadable: null };
13824
13118
  }
13825
- function hasPasswordlessSudo() {
13826
- try {
13827
- return spawnSync4("sudo", ["-n", "true"], { stdio: "ignore" }).status === 0;
13828
- } catch {
13829
- return false;
13830
- }
13831
- }
13832
- function plannedHostChannel(hostControl) {
13833
- if (!hostControl || process.platform !== "linux") return null;
13834
- let invoker;
13835
- try {
13836
- const info = userInfo();
13837
- invoker = { uid: info.uid, username: info.username, home: info.homedir };
13838
- } catch {
13839
- invoker = { uid: -1, username: process.env.USER || process.env.LOGNAME || "root", home: homedir() };
13840
- }
13841
- const decision = chooseHostChannelUser({
13842
- invokerUid: invoker.uid,
13843
- invokerName: invoker.username,
13844
- invokerHome: invoker.home,
13845
- // Only probe sudo when it could change the outcome — a root invoker never needs it.
13846
- hasPasswordlessSudo: invoker.uid !== 0 && hasPasswordlessSudo()
13847
- });
13848
- return { ...decision, keyPath: join6(COMPOSE_DIR, "host-ssh", "id_ed25519") };
13849
- }
13850
- function authorizeKeyAt(authKeysPath, pub) {
13851
- mkdirSync3(dirname2(authKeysPath), { recursive: true, mode: 448 });
13852
- const existing = existsSync4(authKeysPath) ? readFileSync3(authKeysPath, "utf8") : "";
13853
- const next = rewriteHostAuthorizedKeys(existing, pub);
13854
- if (next !== existing) writeFileSync2(authKeysPath, next, { mode: 384 });
13855
- chmodSync(authKeysPath, 384);
13856
- }
13857
- function authorizeRootKeyViaSudo(pub) {
13858
- const mk = spawnSync4("sudo", ["-n", "sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"], {
13859
- stdio: "ignore"
13860
- });
13861
- if (mk.status !== 0) return false;
13862
- const read = spawnSync4(
13863
- "sudo",
13864
- ["-n", "sh", "-c", `cat ${ROOT_AUTHORIZED_KEYS} 2>/dev/null || true`],
13865
- { encoding: "utf8" }
13866
- );
13867
- if (read.status !== 0) return false;
13868
- const next = rewriteHostAuthorizedKeys(read.stdout ?? "", pub);
13869
- const write = spawnSync4("sudo", ["-n", "tee", ROOT_AUTHORIZED_KEYS], {
13870
- input: next,
13871
- stdio: ["pipe", "ignore", "ignore"]
13872
- });
13873
- if (write.status !== 0) return false;
13874
- return spawnSync4("sudo", ["-n", "chmod", "600", ROOT_AUTHORIZED_KEYS], { stdio: "ignore" }).status === 0;
13875
- }
13876
- function revokeKeyAt(authKeysPath) {
13877
- try {
13878
- if (!existsSync4(authKeysPath)) return true;
13879
- const existing = readFileSync3(authKeysPath, "utf8");
13880
- if (!existing.includes(HOST_KEY_COMMENT)) return true;
13881
- writeFileSync2(authKeysPath, stripHostAuthorizedKeys(existing), { mode: 384 });
13882
- chmodSync(authKeysPath, 384);
13883
- return true;
13884
- } catch {
13885
- return false;
13886
- }
13887
- }
13888
- function revokeRootKeyViaSudo() {
13889
- const read = spawnSync4(
13890
- "sudo",
13891
- ["-n", "sh", "-c", `cat ${ROOT_AUTHORIZED_KEYS} 2>/dev/null || true`],
13892
- { encoding: "utf8" }
13893
- );
13894
- if (read.status !== 0) return false;
13895
- const existing = read.stdout ?? "";
13896
- if (!existing.includes(HOST_KEY_COMMENT)) return true;
13897
- const write = spawnSync4("sudo", ["-n", "tee", ROOT_AUTHORIZED_KEYS], {
13898
- input: stripHostAuthorizedKeys(existing),
13899
- stdio: ["pipe", "ignore", "ignore"]
13900
- });
13901
- return write.status === 0;
13902
- }
13903
- function retireHostSshChannel(prev) {
13904
- const keyDir = join6(COMPOSE_DIR, "host-ssh");
13905
- if (!existsSync4(keyDir) && !prev.OPENSHIP_HOST_SSH_HOST) return;
13906
- const invokerKeys = join6(homedir(), ".ssh", "authorized_keys");
13907
- const asRoot = (prev.OPENSHIP_HOST_SSH_USER?.trim() || "root") === "root";
13908
- const weAreRoot = typeof process.getuid === "function" && process.getuid() === 0;
13909
- const revoked = asRoot ? weAreRoot ? revokeKeyAt(ROOT_AUTHORIZED_KEYS) : hasPasswordlessSudo() && revokeRootKeyViaSudo() : revokeKeyAt(invokerKeys);
13910
- const alsoInvoker = asRoot ? revokeKeyAt(invokerKeys) : true;
13911
- try {
13912
- rmSync(keyDir, { recursive: true, force: true });
13913
- } catch {
13914
- }
13915
- const stuck = asRoot ? ROOT_AUTHORIZED_KEYS : invokerKeys;
13916
- console.log(
13917
- revoked && alsoInvoker ? " Host control off: dropped OPENSHIP_HOST_SSH_* from .env, deleted the host key and\n revoked its authorized_keys line." : ` Host control off: dropped OPENSHIP_HOST_SSH_* from .env and deleted the host key (it
13918
- can no longer be used). Removing its line needs root \u2014 run:
13919
- sudo sed -i '/${HOST_KEY_COMMENT}/d' ${stuck}`
13920
- );
13921
- }
13922
- function provisionHostSshChannel(cfg) {
13923
- const target = plannedHostChannel(cfg.hostControl);
13924
- if (!target) return { channel: null };
13925
- const { user, keyPath, authKeysPath, viaSudo, rootUnavailable } = target;
13926
- const provisioned = (channel) => {
13927
- const port = toPort(cfg.hostSshPort) ?? HOST_CHANNEL_DEFAULT_PORT;
13928
- return probeHostSshd(port) === "absent" ? { channel, issue: { code: "no-sshd", detail: `port ${port}` } } : { channel };
13929
- };
13930
- try {
13931
- mkdirSync3(join6(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 448 });
13932
- if (!existsSync4(keyPath)) {
13933
- const g2 = spawnSync4(
13934
- "ssh-keygen",
13935
- ["-t", "ed25519", "-N", "", "-q", "-f", keyPath, "-C", HOST_KEY_COMMENT],
13936
- // stderr is captured rather than discarded: it is the only account of why the
13937
- // channel is missing, and the caller prints it.
13938
- { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] }
13939
- );
13940
- if (g2.status !== 0) return { channel: null, issue: { code: "keygen", detail: keygenWhy(g2) } };
13941
- }
13942
- const pub = readFileSync3(`${keyPath}.pub`, "utf8").trim();
13943
- if (!pub) return { channel: null, issue: { code: "empty-key" } };
13944
- if (rootUnavailable) {
13945
- console.warn(
13946
- " \u26A0 Host control needs root, but this user isn't root and passwordless sudo isn't\n available. Mail and edge host operations will fail. Re-run `openship up` as root\n (or enable passwordless sudo) to fix. Continuing with a limited host channel."
13947
- );
13948
- authorizeKeyAt(authKeysPath, pub);
13949
- return provisioned({ user, keyPath });
13950
- }
13951
- if (viaSudo) {
13952
- if (!authorizeRootKeyViaSudo(pub)) {
13953
- console.warn(
13954
- " \u26A0 Could not authorize the host key for root via sudo \u2014 falling back to the\n current user. Root host operations (mail/edge) may fail."
13955
- );
13956
- const invoker = userInfo().username;
13957
- authorizeKeyAt(join6(homedir(), ".ssh", "authorized_keys"), pub);
13958
- return provisioned({ user: invoker, keyPath });
13959
- }
13960
- revokeKeyAt(join6(homedir(), ".ssh", "authorized_keys"));
13961
- return provisioned({ user, keyPath });
13962
- }
13963
- authorizeKeyAt(authKeysPath, pub);
13964
- return provisioned({ user, keyPath });
13965
- } catch (err) {
13966
- return { channel: null, issue: { code: "error", detail: err?.message } };
13967
- }
13968
- }
13969
- function keygenWhy(r2) {
13970
- if (r2.error?.code === "ENOENT") {
13971
- return "ssh-keygen is not installed \u2014 the openssh-client package provides it";
13972
- }
13973
- const stderr = (r2.stderr ?? "").trim().split("\n").pop();
13974
- return stderr || r2.error?.message || `ssh-keygen exited ${r2.status ?? "abnormally"}`;
13975
- }
13976
- function probeHostSshd(port) {
13977
- for (const [cmd, args] of [
13978
- ["ss", ["-H", "-ltn"]],
13979
- ["netstat", ["-ltn"]]
13980
- ]) {
13981
- const r2 = spawnSync4(cmd, [...args], { encoding: "utf8" });
13982
- if (r2.status !== 0 || !r2.stdout) continue;
13983
- return listensOnPort(r2.stdout, port) ? "listening" : "absent";
13984
- }
13985
- return "unknown";
13986
- }
13987
- function listensOnPort(listing, port) {
13988
- return listing.split("\n").filter((line) => /\bLISTEN\b/.test(line)).some(
13989
- (line) => line.trim().split(/\s+/).some((token) => token.endsWith(`:${port}`))
13990
- );
13991
- }
13992
- function carriedHostChannel(prev) {
13993
- const keyPath = prev.OPENSHIP_HOST_KEY_PATH?.trim();
13994
- if (!prev.OPENSHIP_HOST_SSH_HOST?.trim() || !keyPath || !existsSync4(keyPath)) return null;
13995
- return { user: prev.OPENSHIP_HOST_SSH_USER?.trim() || "root", keyPath };
13996
- }
13997
- var HOST_ISSUE_INDENT = " ";
13998
- function hostIssueNote(text) {
13999
- return wrapText(text, 80).map((l2) => `${HOST_ISSUE_INDENT}${l2}
14000
- `).join("");
14001
- }
14002
- function renderHostChannelIssue(issue, ctx) {
14003
- const detail = issue.detail ? ` (${issue.detail})` : "";
14004
- const headline = issue.code === "no-sshd" ? ` \u26A0 Host control is provisioned, but nothing on this host is listening for SSH${detail}.` : ` \u26A0 Host control could NOT be provisioned on this box.`;
14005
- let cause;
14006
- switch (issue.code) {
14007
- case "keygen":
14008
- cause = `The channel's key could not be generated${detail}.`;
14009
- break;
14010
- case "empty-key":
14011
- cause = "The channel's key was generated but its public half read back empty.";
14012
- break;
14013
- case "no-sshd":
14014
- cause = `The api container now has a key and an address, and will be refused the first time it dials ${ctx.target}:
14015
- ` + explainHostChannelCause("refused", {
14016
- target: ctx.target,
14017
- // Measured here rather than left to core's default, which is Debian's `ssh` —
14018
- // a unit name that does not exist on the RHEL family, so the remedy printed on
14019
- // an Amazon Linux box was a command that silently does nothing.
14020
- sshdEnableHint: thisHost().sshdEnableHint()
14021
- }).body;
14022
- break;
14023
- case "error":
14024
- cause = `Setting up the channel's key failed${detail}.`;
14025
- break;
14026
- }
14027
- const continuity = issue.code === "no-sshd" ? "" : ctx.kept ? hostIssueNote(
14028
- "Kept the host channel this install already had in `.env` rather than dropping it: its key is still authorized, so the api container keeps the access it had instead of losing it to this failure."
14029
- ) : hostIssueNote(
14030
- "OPENSHIP_HOST_SSH_HOST is therefore unset, and host operations refuse rather than run against the api container's own filesystem."
14031
- );
14032
- return `${headline}
14033
- ` + hostIssueNote(cause) + continuity + hostIssueNote(HOST_CHANNEL_UNAFFECTED) + `${HOST_ISSUE_INDENT}Unavailable until the channel works:
14034
- ` + HOST_CHANNEL_BLOCKED.map((l2) => `${HOST_ISSUE_INDENT} \xB7 ${l2}
14035
- `).join("") + hostIssueNote(HOST_CHANNEL_RECHECK);
14036
- }
14037
- function composeProjectName(prev) {
14038
- if (prev.COMPOSE_PROJECT_NAME) return prev.COMPOSE_PROJECT_NAME;
14039
- return Object.keys(prev).length > 0 ? "compose" : "openship";
14040
- }
14041
- var PGDATA_ROOT = "/var/lib/postgresql/data";
14042
- function probePgDataDir(volume) {
14043
- const r2 = spawnSync4(
14044
- "docker",
14045
- [
14046
- "run",
14047
- "--rm",
14048
- "-v",
14049
- `${volume}:/from:ro`,
14050
- "alpine:3",
14051
- "sh",
14052
- "-c",
14053
- `if [ -f /from/PG_VERSION ]; then echo root; elif [ -f /from/pgdata/PG_VERSION ]; then echo sub; elif [ -z "$(ls -A /from 2>/dev/null | grep -v '^lost+found$')" ]; then echo empty; else echo foreign; fi`
14054
- ],
14055
- { encoding: "utf8" }
14056
- );
14057
- if (r2.status !== 0) return "unknown";
14058
- const out = `${r2.stdout ?? ""}`.trim();
14059
- return out === "root" || out === "sub" || out === "empty" || out === "foreign" ? out : "unknown";
14060
- }
14061
- function resolvePgData(prev) {
14062
- const SUB = `${PGDATA_ROOT}/pgdata`;
14063
- if (prev.OPENSHIP_PGDATA) return prev.OPENSHIP_PGDATA;
14064
- const volume = survivingDbVolume(prev);
14065
- if (!volume) return SUB;
14066
- return probePgDataDir(volume) === "root" ? PGDATA_ROOT : SUB;
14067
- }
14068
- function pgDataDetectionRisk(prev) {
14069
- if (prev.OPENSHIP_PGDATA) return null;
14070
- const volume = survivingDbVolume(prev);
14071
- if (!volume) return null;
14072
- return probePgDataDir(volume) === "foreign" ? { volume } : null;
14073
- }
14074
- function composePgDataRisk(_opts = {}) {
14075
- return pgDataDetectionRisk(readEnvFile());
14076
- }
14077
- function renderPgDataRefusal(risk, opts = {}) {
14078
- const SUB = `${PGDATA_ROOT}/pgdata`;
14079
- return `
14080
- ${opts.dryRun ? "A real run would REFUSE here" : "Refusing to continue"}: this install's data volume holds data, but not a Postgres cluster we recognize.
14081
-
14082
- volume: ${risk.volume}
14083
- checked: ${PGDATA_ROOT}/PG_VERSION and ${SUB}/PG_VERSION \u2014 neither present
14084
-
14085
- Guessing where the cluster lives is how a wrong path makes Postgres run initdb over
14086
- your existing data and orphan it, so nothing has been written. If you know the layout,
14087
- pin it and re-run:
14088
-
14089
- echo 'OPENSHIP_PGDATA=${SUB}' >> ${ENV_FILE} # or ${PGDATA_ROOT} if the cluster is at the root
14090
- `;
14091
- }
14092
- var PS_SWEEP_FORMAT = '{{.Names}} {{.Label "com.docker.compose.project"}} {{.Label "com.docker.compose.project.config_files"}} {{.Label "com.docker.compose.service"}}';
14093
- function parsePsSweep(stdout) {
14094
- return stdout.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
14095
- const [name, project, configFiles, service] = line.split(" ");
14096
- return {
14097
- name: name ?? "",
14098
- project: project ?? "",
14099
- configFiles: configFiles ?? "",
14100
- service: service ?? ""
14101
- };
14102
- });
14103
- }
14104
- function psSweepRaw() {
14105
- const r2 = spawnSync4("docker", ["ps", "-a", "--format", PS_SWEEP_FORMAT], { encoding: "utf8" });
14106
- return r2.status === 0 && r2.stdout ? r2.stdout : null;
14107
- }
14108
- function psSweep() {
14109
- return parsePsSweep(psSweepRaw() ?? "");
14110
- }
14111
- function fromOurComposeFile(row) {
14112
- return row.configFiles.split(",").some((f2) => f2.trim() === COMPOSE_FILE);
14113
- }
14114
- function orphanedStackContainers(project) {
14115
- return psSweep().filter((c2) => c2.name && c2.project && c2.project !== project && fromOurComposeFile(c2)).map(({ name, project: proj }) => ({ name, project: proj }));
14116
- }
14117
- function adoptedStackOrigin(psStdout, project) {
14118
- const rows = parsePsSweep(psStdout).filter((c2) => c2.name && c2.project === project);
14119
- if (rows.length === 0 || rows.some(fromOurComposeFile)) return null;
14120
- return {
14121
- project,
14122
- configFiles: rows.map((c2) => c2.configFiles).find(Boolean) ?? "",
14123
- apiContainer: rows.find((c2) => c2.service === "api")?.name || `${project}-api-1`
14124
- };
14125
- }
14126
- function adoptedStack(project) {
14127
- const sweep = psSweepRaw();
14128
- return sweep ? adoptedStackOrigin(sweep, project) : null;
14129
- }
14130
- function reconcileDbPassword(user, password) {
14131
- if (!password) return;
14132
- if (compose(["up", "-d", "--wait", "postgres"], { quiet: true }) !== 0) return;
14133
- const attempt = (asRole) => spawnSync4(
14134
- "docker",
14135
- [
14136
- "compose",
14137
- "-f",
14138
- COMPOSE_FILE,
14139
- "exec",
14140
- "-T",
14141
- "postgres",
14142
- "psql",
14143
- "-U",
14144
- asRole,
14145
- "-d",
14146
- "postgres",
14147
- "-v",
14148
- "ON_ERROR_STOP=1",
14149
- // Read the statement from STDIN, not `-c`: psql has no bind parameters for
14150
- // ALTER USER, so the password has to be inlined in SQL — and an argv copy
14151
- // would be readable in `ps` for the life of the call. stdin keeps it to this
14152
- // process and the socket. Our generated password is hex, so it cannot break
14153
- // out of the quoted literal either.
14154
- "-f",
14155
- "-"
14156
- ],
14157
- {
14158
- cwd: COMPOSE_DIR,
14159
- encoding: "utf8",
14160
- // Create the role if the volume predates it, else just realign it. Both
14161
- // branches leave exactly the credentials this `.env` will present.
14162
- input: `DO $$ BEGIN
14163
- IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${user}') THEN
14164
- ALTER ROLE "${user}" WITH LOGIN PASSWORD '${password}';
14165
- ELSE
14166
- CREATE ROLE "${user}" WITH LOGIN SUPERUSER PASSWORD '${password}';
14167
- END IF;
14168
- END $$;
14169
- `
14170
- }
14171
- );
14172
- let r2 = attempt(user);
14173
- if (r2.status !== 0) r2 = attempt("postgres");
14174
- if (r2.status !== 0) {
14175
- console.log(
14176
- `
14177
- ! Could not realign the database password (${(r2.stderr ?? "").trim() || "psql failed"}).
14178
- The api will fail with "password authentication failed for user "${user}"" because the
14179
- existing data volume was initialized with a different password.
14180
-
14181
- Fix it one of two ways:
14182
- \u2022 restore the .env that created the volume, or
14183
- \u2022 start fresh (DESTROYS DB DATA): openship uninstall then openship up
14184
- `
14185
- );
14186
- }
14187
- }
14188
- function removeOrphanedStack(project) {
14189
- const orphans = orphanedStackContainers(project);
14190
- if (orphans.length === 0) return;
14191
- const projects = [...new Set(orphans.map((o2) => o2.project))].join(", ");
14192
- console.log(
14193
- ` Removing ${orphans.length} container(s) from a previous stack (project "${projects}") \u2014 they would hold the ports this stack needs. Volumes are kept.`
14194
- );
14195
- spawnSync4("docker", ["rm", "-f", ...orphans.map((o2) => o2.name)], { stdio: "ignore" });
14196
- }
14197
- function edgeNameNeedsReclaim(psStdout, project) {
14198
- const row = parsePsSweep(psStdout).find((c2) => c2.name === "openship-edge");
14199
- if (!row) return false;
14200
- return !(row.project === project && fromOurComposeFile(row));
14201
- }
14202
- function reconcileEdgeContainerName(project) {
14203
- const sweep = psSweepRaw();
14204
- if (!sweep) return;
14205
- if (!edgeNameNeedsReclaim(sweep, project)) return;
14206
- console.log(
14207
- " Replacing an unmanaged `openship-edge` container so compose can own it (config is on the host, so no routes are lost)."
14208
- );
14209
- spawnSync4("docker", ["rm", "-f", "openship-edge"], { stdio: "ignore" });
14210
- }
14211
- function migrateLegacyEdgeVolumes(project) {
14212
- const pairs = [
14213
- { volume: `${project}_openship_sites`, host: EDGE_SITES_HOST_DIR },
14214
- { volume: `${project}_openship_certs`, host: "/etc/letsencrypt" },
14215
- { volume: `${project}_openship_acme`, host: EDGE_ACME_HOST_DIR },
14216
- { volume: `${project}_openship_static`, host: "/opt/openship/static" }
14217
- ];
14218
- const ls = spawnSync4("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
14219
- if (ls.status !== 0 || !ls.stdout) return;
14220
- const existing = new Set(ls.stdout.split("\n").map((l2) => l2.trim()).filter(Boolean));
14221
- for (const { volume, host } of pairs) {
14222
- if (!existing.has(volume)) continue;
14223
- mkdirSync3(host, { recursive: true });
14224
- const alreadyThere = spawnSync4("sh", ["-c", `ls -A ${JSON.stringify(host)} 2>/dev/null | head -1`], {
14225
- encoding: "utf8"
14226
- });
14227
- if (alreadyThere.stdout?.trim()) continue;
14228
- console.log(` Moving edge state from volume ${volume} onto ${host}...`);
14229
- const copy = spawnSync4(
14230
- "docker",
14231
- [
14232
- "run",
14233
- "--rm",
14234
- "-v",
14235
- `${volume}:/from:ro`,
14236
- "-v",
14237
- `${host}:/to`,
14238
- "alpine:3",
14239
- "sh",
14240
- "-c",
14241
- "cp -a /from/. /to/ 2>/dev/null || true"
14242
- ],
14243
- { stdio: "inherit" }
14244
- );
14245
- if (copy.status !== 0) {
14246
- console.log(
14247
- ` Could not copy ${volume} \u2014 the stack will start with an empty ${host}.
14248
- Recover manually with: docker run --rm -v ${volume}:/from -v ${host}:/to alpine cp -a /from/. /to/`
14249
- );
14250
- }
14251
- }
14252
- }
14253
- function warnOrphanedVolumes(project) {
14254
- const r2 = spawnSync4("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
14255
- if (r2.status !== 0 || !r2.stdout) return;
14256
- const others = new Set(
14257
- r2.stdout.split("\n").map((l2) => l2.trim()).filter((l2) => l2.endsWith("_openship_sites")).map((l2) => l2.slice(0, -"_openship_sites".length)).filter((p2) => p2 && p2 !== project)
14258
- );
14259
- if (others.size === 0) return;
14260
- console.log(
14261
- ` Note: volumes from a previous install (project "${[...others].join(", ")}") are still on disk
14262
- and are NOT mounted by this stack \u2014 its database starts fresh. (Certificates and
14263
- vhosts now live on the host, so those carry over.)
14264
- Remove them with \`docker volume ls | grep _openship_\` once you're sure they aren't needed.`
14265
- );
14266
- }
14267
- function keepConfig(prev, key, explicit) {
14268
- const set = explicit?.trim();
14269
- if (set) return set;
14270
- const carried = prev[key]?.trim();
14271
- return carried || void 0;
14272
- }
14273
- var ACME_ENV_KEYS = [
14274
- "OPENSHIP_ACME_EMAIL",
14275
- "OPENSHIP_ACME_DIRECTORY_URL",
14276
- "OPENSHIP_ACME_EAB_KID",
14277
- "OPENSHIP_ACME_EAB_HMAC_KEY",
14278
- "OPENSHIP_ACME_KEY_TYPE",
14279
- "OPENSHIP_ACME_CA_BUNDLE",
14280
- "OPENSHIP_ACME_TOS_AGREED"
14281
- ];
14282
- function hostGatewayEnv(prev) {
14283
- const value = process.env.OPENSHIP_HOST_GATEWAY?.trim() || prev.OPENSHIP_HOST_GATEWAY?.trim();
14284
- return value ? [`OPENSHIP_HOST_GATEWAY=${value}`] : [];
14285
- }
14286
- function rootlessSocketCandidates() {
14287
- const runtimeDir = process.env.XDG_RUNTIME_DIR?.trim();
14288
- const sudoUid = process.env.SUDO_UID?.trim();
14289
- let uid;
14290
- try {
14291
- uid = userInfo().uid;
14292
- } catch {
14293
- }
14294
- return [
14295
- ...runtimeDir ? [join6(runtimeDir, "docker.sock")] : [],
14296
- ...sudoUid ? [`/run/user/${sudoUid}/docker.sock`] : [],
14297
- ...uid !== void 0 ? [`/run/user/${uid}/docker.sock`] : []
14298
- ];
14299
- }
14300
- function detectDockerSocketPath() {
14301
- const fromClient = resolveLocalDockerSocketPath(void 0, process.env);
14302
- if (fromClient !== DEFAULT_DOCKER_SOCKET_PATH) return fromClient;
14303
- if (existsSync4(DEFAULT_DOCKER_SOCKET_PATH)) return DEFAULT_DOCKER_SOCKET_PATH;
14304
- return rootlessSocketCandidates().find((p2) => existsSync4(p2)) ?? DEFAULT_DOCKER_SOCKET_PATH;
14305
- }
14306
- function resolveDockerSocket(prev) {
14307
- const pinned = process.env.OPENSHIP_DOCKER_SOCKET?.trim() || prev.OPENSHIP_DOCKER_SOCKET?.trim();
14308
- return pinned ? { path: pinned, pinned: true } : { path: detectDockerSocketPath(), pinned: false };
14309
- }
14310
- function dockerSocketEnv(prev) {
14311
- const { path, pinned } = resolveDockerSocket(prev);
14312
- return pinned || path !== DEFAULT_DOCKER_SOCKET_PATH ? [`OPENSHIP_DOCKER_SOCKET=${path}`] : [];
14313
- }
14314
- function warnMissingDockerSocket(path) {
14315
- console.warn(
14316
- `
14317
- ! The Docker socket this install mounts into the api isn't there:
14318
- ${path}
14319
- Docker will create that path as an empty DIRECTORY, so the stack will come up
14320
- and then fail every container operation with a transport error naming neither
14321
- this path nor the cause (#482). Point it at the real socket:
14322
- docker context inspect --format '{{.Endpoints.docker.Host}}'
14323
- OPENSHIP_DOCKER_SOCKET=/run/user/$(id -u)/docker.sock openship up --compose
14324
- (a rootless daemon keeps its socket under the daemon user's runtime dir).
14325
- `
14326
- );
14327
- }
14328
- function renderAcmeEnv(prev) {
14329
- return ACME_ENV_KEYS.flatMap((key) => {
14330
- const value = process.env[key]?.trim() || prev[key]?.trim();
14331
- if (!value) return [];
14332
- if (/[\r\n]/.test(value)) throw new Error(`${key} must be a single-line value`);
14333
- return [`${key}=${value}`];
14334
- });
14335
- }
14336
- function resolveEnvConfig(prev, opts) {
14337
- const publicUrl = keepConfig(prev, "OPENSHIP_PUBLIC_URL", opts.publicUrl);
14338
- const bindAddr = keepConfig(prev, "OPENSHIP_BIND_ADDR") ?? (publicUrl ? "0.0.0.0" : void 0);
14339
- return {
14340
- apiPort: keepConfig(prev, "API_PORT", opts.apiPort) ?? String(DEFAULT_API_PORT),
14341
- dashPort: keepConfig(prev, "DASHBOARD_PORT", opts.dashboardPort) ?? String(DEFAULT_DASHBOARD_PORT),
14342
- ...bindAddr ? { bindAddr } : {},
14343
- ...publicUrl ? { publicUrl } : {},
14344
- // A public URL always implies a proxy in front; otherwise keep whatever the
14345
- // install was configured with.
14346
- trustProxy: Boolean(opts.trustProxy) || Boolean(publicUrl) || prev.TRUST_PROXY === "true",
14347
- ...keepConfig(prev, "OPENSHIP_EXTRA_TRUSTED_ORIGINS", opts.extraTrustedOrigins) ? {
14348
- extraTrustedOrigins: keepConfig(
14349
- prev,
14350
- "OPENSHIP_EXTRA_TRUSTED_ORIGINS",
14351
- opts.extraTrustedOrigins
14352
- )
14353
- } : {},
14354
- registry: keepConfig(prev, "OPENSHIP_IMAGE_REGISTRY", opts.registry) ?? DEFAULT_IMAGE_REGISTRY,
14355
- // Tri-state: the flag is absent on a plain re-run, so fall back to what the
14356
- // install chose rather than silently re-granting host control.
14357
- hostControl: opts.noHostControl === void 0 ? prev.OPENSHIP_HOST_CONTROL !== "false" : !opts.noHostControl,
14358
- // Sticky too: `--mail` is only the install-time default, so a plain re-run
14359
- // (or `openship update`) must not hand a mail box back the platform shell.
14360
- // Turning it OFF is the dashboard's job — Settings → Instance stores the mode
14361
- // in the database, which wins over this env var either way.
14362
- productMode: opts.mail || keepConfig(prev, "OPENSHIP_PRODUCT") === "mail" ? "mail" : "platform",
14363
- // Sticky like every other operator choice here. These were literals in
14364
- // `renderEnv`, so a box where `host.docker.internal:22` is simply not the
14365
- // right address — rootless Docker, sshd on another port — had nowhere to say
14366
- // so, and every `openship up` overwrote a hand-edited `.env` back to the
14367
- // value that didn't work (#490/#482).
14368
- hostSshHost: keepConfig(prev, "OPENSHIP_HOST_SSH_HOST", opts.hostSshHost) ?? HOST_CHANNEL_DEFAULT_HOST,
14369
- hostSshPort: keepConfig(prev, "OPENSHIP_HOST_SSH_PORT", opts.hostSshPort) ?? String(HOST_CHANNEL_DEFAULT_PORT)
14370
- };
14371
- }
14372
- function toPort(value) {
14373
- const n2 = Number(value);
14374
- return Number.isInteger(n2) && n2 > 0 && n2 <= 65535 ? n2 : void 0;
14375
- }
14376
- function composeEnvPorts() {
14377
- const env = readEnvFile();
14378
- return { api: toPort(env.API_PORT), dashboard: toPort(env.DASHBOARD_PORT) };
14379
- }
14380
- function composeBindAddr() {
14381
- return readEnvFile().OPENSHIP_BIND_ADDR?.trim() || "127.0.0.1";
14382
- }
14383
- function composeHostChannel() {
14384
- const env = readEnvFile();
14385
- const host = env.OPENSHIP_HOST_SSH_HOST?.trim();
14386
- if (!host) return null;
14387
- return {
14388
- host,
14389
- port: toPort(env.OPENSHIP_HOST_SSH_PORT) ?? 22,
14390
- user: env.OPENSHIP_HOST_SSH_USER?.trim() || "root"
14391
- };
14392
- }
14393
- function composeHostChannelExpected() {
14394
- if (!existsSync4(COMPOSE_FILE)) return false;
14395
- return readEnvFile().OPENSHIP_HOST_CONTROL !== "false";
14396
- }
14397
- function composeTrustedOriginUrls() {
14398
- const env = readEnvFile();
14399
- return [env.OPENSHIP_PUBLIC_URL, env.OPENSHIP_EXTRA_TRUSTED_ORIGINS].filter(
14400
- (v2) => !!v2?.trim()
14401
- );
14402
- }
14403
- function composeHeldPorts() {
14404
- const r2 = spawnSync4(
14405
- "docker",
14406
- ["ps", "--format", '{{.Label "com.docker.compose.project.config_files"}} {{.Ports}}'],
14407
- { encoding: "utf8" }
14408
- );
14409
- if (r2.status !== 0 || !r2.stdout) return [];
14410
- const ports = /* @__PURE__ */ new Set();
14411
- for (const line of r2.stdout.split("\n")) {
14412
- const [configFiles = "", published = ""] = line.split(" ");
14413
- if (!configFiles.split(",").some((f2) => f2.trim() === COMPOSE_FILE)) continue;
14414
- for (const m2 of published.matchAll(/:(\d+)->/g)) ports.add(Number(m2[1]));
14415
- }
14416
- return [...ports];
14417
- }
14418
- async function resolveComposePorts(prefs, opts = {}) {
14419
- return resolvePorts(
14420
- {
14421
- api: toPort(prefs.api),
14422
- dashboard: toPort(prefs.dashboard),
14423
- previous: composeEnvPorts(),
14424
- bindAddr: composeBindAddr(),
14425
- reclaimable: composeHeldPorts()
14426
- },
14427
- opts
14428
- );
14429
- }
14430
- var OMITTABLE_MANAGED_KEYS = /* @__PURE__ */ new Set([
14431
- "OPENSHIP_HOST_SSH_HOST",
14432
- "OPENSHIP_HOST_SSH_USER",
14433
- "OPENSHIP_HOST_SSH_PORT",
14434
- "OPENSHIP_HOST_SSH_KEY",
14435
- "OPENSHIP_HOST_KEY_PATH",
14436
- "OPENSHIP_HOST_GATEWAY",
14437
- "TRUST_PROXY"
14438
- ]);
14439
- var envKeyOf = (line) => line.match(/^([A-Z0-9_]+)=/)?.[1];
14440
- function operatorEnvPassthrough(managed, prev) {
14441
- return Object.entries(prev).filter(([key]) => !managed.has(key) && !OMITTABLE_MANAGED_KEYS.has(key)).map(([key, value]) => `${key}=${value}`);
14442
- }
14443
- var PRESERVED_ENV_HEADER = "# Preserved from your existing .env \u2014 set by you, not by `openship up` (#485).";
14444
- function resolveImageVersion(opts) {
14445
- return opts.version?.trim() || process.env.OPENSHIP_VERSION?.trim() || (true ? "0.6.5" : "latest");
14446
- }
14447
- function managedEnvLines(opts, host, cfg, prev) {
14448
- const lines = [
14449
- "# Managed by `openship up`. Secrets are generated once and preserved.",
14450
- // Do NOT edit on a live install — it is the volume prefix (see composeProjectName).
14451
- `COMPOSE_PROJECT_NAME=${composeProjectName(prev)}`,
14452
- "CLOUD_MODE=false",
14453
- "OPENSHIP_TARGET=local",
14454
- "OPENSHIP_REQUIRE_AUTH=true",
14455
- // Read by createHostExecutor (throws when false) and by the servers list
14456
- // (hides the local row). Written explicitly so the policy is visible in .env.
14457
- `OPENSHIP_HOST_CONTROL=${cfg.hostControl ? "true" : "false"}`,
14458
- // The host side of the api's docker socket mount, when it isn't the rootful
14459
- // default. Unlike the host channel above this is NOT optional plumbing — every
14460
- // deploy goes through it — so it is resolved on every run, host control or not.
14461
- ...dockerSocketEnv(prev),
14462
- // Which shell the dashboard defaults to. Emitted on EVERY run, including the
14463
- // "platform" default: that keeps the key permanently in the managed set, so
14464
- // the #485 passthrough can never mistake it for operator config (which would
14465
- // pin it and make the Settings → Instance toggle look broken after an `up`).
14466
- `OPENSHIP_PRODUCT=${cfg.productMode}`,
14467
- `OPENSHIP_IMAGE_REGISTRY=${cfg.registry}`,
14468
- `OPENSHIP_VERSION=${resolveImageVersion(opts)}`,
14469
- `POSTGRES_PASSWORD=${keepSecret(prev, "POSTGRES_PASSWORD")}`,
14470
- // Pinned once (see resolvePgData): fresh install → subdir, existing volume → root.
14471
- `OPENSHIP_PGDATA=${resolvePgData(prev)}`,
14472
- `BETTER_AUTH_SECRET=${keepSecret(prev, "BETTER_AUTH_SECRET")}`,
14473
- `INTERNAL_TOKEN=${keepSecret(prev, "INTERNAL_TOKEN")}`,
14474
- `API_PORT=${cfg.apiPort}`,
14475
- `DASHBOARD_PORT=${cfg.dashPort}`,
14476
- // The api's OWN view of the dashboard port: the self-app boot reconcile points
14477
- // the operator's domain at the dashboard through this (self-deploy.ts), and it
14478
- // silently defaults to 3001 when unset. Ports are dynamic now, so leaving it
14479
- // out publishes a domain routed to a port nothing is listening on.
14480
- `OPENSHIP_DASHBOARD_PORT=${cfg.dashPort}`,
14481
- // Alternate CA/EAB values are operator configuration (including one secret),
14482
- // so a routine `openship up`/upgrade must not silently discard them.
14483
- ...renderAcmeEnv(prev)
14484
- ];
14485
- if (cfg.bindAddr) lines.push(`OPENSHIP_BIND_ADDR=${cfg.bindAddr}`);
14486
- if (cfg.publicUrl) lines.push(`OPENSHIP_PUBLIC_URL=${cfg.publicUrl}`);
14487
- if (cfg.extraTrustedOrigins) {
14488
- lines.push(`OPENSHIP_EXTRA_TRUSTED_ORIGINS=${cfg.extraTrustedOrigins}`);
14489
- }
14490
- if (cfg.trustProxy) lines.push("TRUST_PROXY=true");
14491
- if (host) {
14492
- lines.push(
14493
- `OPENSHIP_HOST_SSH_HOST=${cfg.hostSshHost}`,
14494
- `OPENSHIP_HOST_SSH_USER=${host.user}`,
14495
- `OPENSHIP_HOST_SSH_PORT=${cfg.hostSshPort}`,
14496
- "OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key",
14497
- `OPENSHIP_HOST_KEY_PATH=${host.keyPath}`,
14498
- ...hostGatewayEnv(prev)
14499
- );
14500
- }
14501
- return lines;
14502
- }
14503
- function renderEnvAndCarried(opts, host, cfg, prev) {
14504
- const lines = managedEnvLines(opts, host, cfg, prev);
14505
- const managed = new Set(lines.map(envKeyOf).filter((k2) => !!k2));
14506
- const preserved = operatorEnvPassthrough(managed, prev);
14507
- const body = preserved.length ? [...lines, "", PRESERVED_ENV_HEADER, ...preserved] : lines;
14508
- return {
14509
- text: body.join("\n") + "\n",
14510
- carried: preserved.map((line) => line.slice(0, line.indexOf("=")))
14511
- };
14512
- }
14513
- function renderEnv(opts, host, cfg) {
14514
- return renderEnvAndCarried(opts, host, cfg, readEnvFile()).text;
14515
- }
14516
- function sourceBuildDir() {
14517
- const marker = readSourceInstall();
14518
- if (!marker?.dir) return null;
14519
- const hasAll = BUILT_SERVICES.every((s2) => existsSync4(join6(marker.dir, s2.dockerfile)));
14520
- return hasAll ? marker.dir : null;
14521
- }
14522
- function renderBuildOverride(repoDir) {
14523
- const services = BUILT_SERVICES.map(
14524
- (s2) => ` ${s2.service}:
14525
- build:
14526
- context: ${repoDir}
14527
- dockerfile: ${s2.dockerfile}
14528
- `
14529
- ).join("");
14530
- return `# Managed by \`openship up\` (from-source install) \u2014 builds instead of pulls.
14531
- services:
14532
- ${services}`;
14533
- }
14534
- function materialize(opts) {
14535
- mkdirSync3(COMPOSE_DIR, { recursive: true, mode: 448 });
14536
- const prev = readEnvFile();
14537
- const cfg = resolveEnvConfig(prev, opts);
14538
- const provision = provisionHostSshChannel(cfg);
14539
- let host = provision.channel;
14540
- let kept = false;
14541
- if (!host && provision.issue) {
14542
- const previousChannel = carriedHostChannel(prev);
14543
- if (previousChannel) {
14544
- host = previousChannel;
14545
- kept = true;
14546
- }
14547
- }
14548
- if (!cfg.hostControl) retireHostSshChannel(prev);
14549
- if (provision.issue && !opts.alreadyFetched) {
14550
- console.warn(
14551
- renderHostChannelIssue(provision.issue, {
14552
- target: `${cfg.hostSshHost}:${cfg.hostSshPort}`,
14553
- kept
14554
- })
14555
- );
14556
- }
14557
- const socket = resolveDockerSocket(prev);
14558
- if (!opts.alreadyFetched && !existsSync4(socket.path)) warnMissingDockerSocket(socket.path);
14559
- let before = "";
14560
- try {
14561
- before = readFileSync3(ENV_FILE, "utf8");
14562
- } catch {
14563
- }
14564
- const { text: rendered, carried } = renderEnvAndCarried(opts, host, cfg, prev);
14565
- writeFileSync2(COMPOSE_FILE, COMPOSE_YAML);
14566
- writeEnvFile(rendered, before);
14567
- if (carried.length) {
14568
- console.log(` Kept ${carried.length} operator-set .env variable(s): ${carried.join(", ")}`);
14569
- }
14570
- const buildDir = opts.build === false ? null : sourceBuildDir();
14571
- if (buildDir) writeFileSync2(BUILD_FILE, renderBuildOverride(buildDir));
14572
- return { buildDir, cfg, envChanged: before !== "" && rendered !== before };
14573
- }
14574
- function writeEnvFile(rendered, before) {
14575
- if (rendered === before) return;
14576
- if (before) writeFileSync2(ENV_BACKUP_FILE, before, { mode: 384 });
14577
- writeFileSync2(ENV_TMP_FILE, rendered, { mode: 384 });
14578
- renameSync(ENV_TMP_FILE, ENV_FILE);
14579
- }
14580
- var SECRET_ENV_KEY = /(PASSWORD|SECRET|TOKEN|HMAC_KEY)$/;
14581
- function composePlan(opts) {
14582
- const prev = readEnvFile();
14583
- const cfg = resolveEnvConfig(prev, opts);
14584
- const buildDir = opts.build === false ? null : sourceBuildDir();
14585
- const hostChannel = plannedHostChannel(cfg.hostControl);
14586
- const settings = [];
14587
- const newSecrets = [];
14588
- for (const line of renderEnv(opts, hostChannel, cfg).split("\n")) {
14589
- const at2 = line.indexOf("=");
14590
- if (at2 < 1 || line.startsWith("#")) continue;
14591
- const key = line.slice(0, at2);
14592
- const value = line.slice(at2 + 1);
14593
- if (!SECRET_ENV_KEY.test(key)) {
14594
- settings.push({ key, value });
14595
- continue;
14596
- }
14597
- const preserved = !!prev[key] && prev[key] === value;
14598
- if (!preserved) newSecrets.push(key);
14599
- settings.push({ key, value: preserved ? "<preserved>" : "<generated on this run>" });
14600
- }
14601
- return {
14602
- composeFile: COMPOSE_FILE,
14603
- envFile: ENV_FILE,
14604
- installMethodFile: INSTALL_METHOD_FILE,
14605
- ...buildDir ? { buildFile: BUILD_FILE } : {},
14606
- yaml: COMPOSE_YAML,
14607
- settings,
14608
- newSecrets,
14609
- secretRotation: opts.resetSecrets ? null : secretRotationRisk(prev),
14610
- pgDataRisk: pgDataDetectionRisk(prev),
14611
- existing: Object.keys(prev).length > 0,
14612
- mountDirs: EDGE_CONTAINER_MOUNTS.map((m2) => m2.host),
14613
- buildDir,
14614
- hostChannel
14615
- };
14616
- }
14617
- function dbVolumeExists(project) {
14618
- const r2 = spawnSync4("docker", ["volume", "inspect", `${project}_postgres_data`], {
14619
- stdio: "ignore"
14620
- });
14621
- return r2.status === 0;
14622
- }
14623
- function compose(args, opts) {
14624
- const files = ["-f", COMPOSE_FILE];
14625
- if (opts?.withBuildOverride) files.push("-f", BUILD_FILE);
14626
- const r2 = spawnSync4("docker", ["compose", ...files, ...args], {
14627
- cwd: COMPOSE_DIR,
14628
- stdio: opts?.quiet ? "ignore" : "inherit"
14629
- });
14630
- return r2.status ?? 1;
14631
- }
14632
- function missingPinnedImages(registry, version) {
14633
- const missing = [];
14634
- for (const { service } of BUILT_SERVICES) {
14635
- const ref = `${registry}/openship-${service}:${version}`;
14636
- const r2 = spawnSync4("docker", ["manifest", "inspect", ref], { encoding: "utf8" });
14637
- if (r2.status === 0) continue;
14638
- const stderr = `${r2.stderr ?? ""}`.toLowerCase();
14639
- if (/manifest unknown|no such manifest|manifest for .+ not found/.test(stderr)) {
14640
- missing.push(ref);
14641
- continue;
14642
- }
14643
- return null;
14644
- }
14645
- return missing;
14646
- }
14647
- function pinnedImagesReady(opts) {
14648
- if (sourceBuildDir()) return true;
14649
- const prev = readEnvFile();
14650
- const version = resolveImageVersion(opts);
14651
- const missing = missingPinnedImages(resolveEnvConfig(prev, opts).registry, version);
14652
- if (!missing || missing.length === 0) return true;
14653
- const prevVersion = prev.OPENSHIP_VERSION?.trim();
14654
- const out = [
14655
- "",
14656
- ` The pinned image tag \`${version}\` isn't in the registry yet:`,
14657
- ...missing.map((ref) => ` ${ref}`),
14658
- "",
14659
- " The CLI reached npm before its container images finished publishing \u2014 a",
14660
- " release-ordering race (#486). Nothing on this box was changed.",
14661
- "",
14662
- " Any one of these gets you moving:",
14663
- " \u2022 Wait a few minutes and re-run \u2014 the images usually land right after a release.",
14664
- " \u2022 Pin a tag that exists:",
14665
- " openship up --compose --image-version <tag>",
14666
- " OPENSHIP_VERSION=<tag> openship up --compose"
14667
- ];
14668
- if (prevVersion && prevVersion !== version) {
14669
- out.push(` \u2022 Your current install runs \`${prevVersion}\`, a tag known good on this box.`);
14670
- }
14671
- out.push("");
14672
- console.error(out.join("\n"));
14673
- return false;
14674
- }
14675
- function composePrefetch(opts) {
14676
- const rotation = composeSecretRotationRisk(opts);
14677
- if (rotation) {
14678
- console.error(renderSecretRotationRefusal(rotation));
14679
- return { ok: false, envChanged: false };
14680
- }
14681
- const { buildDir, envChanged } = materialize(opts);
14682
- if (buildDir) {
14683
- return {
14684
- ok: compose(["pull", "postgres", "redis"], { withBuildOverride: true }) === 0 && compose(["build"], { withBuildOverride: true }) === 0,
14685
- envChanged
14686
- };
14687
- }
14688
- return { ok: compose(["pull"]) === 0, envChanged };
14689
- }
14690
- function ensureEdgeMountDirs() {
14691
- const failed = [];
14692
- for (const { host } of EDGE_CONTAINER_MOUNTS) {
14693
- try {
14694
- mkdirSync3(host, { recursive: true });
14695
- } catch (err) {
14696
- if (err.code !== "EEXIST") failed.push(host);
14697
- }
14698
- }
14699
- return failed;
14700
- }
14701
- function isRootlessDocker() {
14702
- const r2 = spawnSync4("docker", ["info", "-f", "{{println .SecurityOptions}}"], {
14703
- encoding: "utf8"
14704
- });
14705
- return r2.status === 0 && /rootless/i.test(r2.stdout ?? "");
14706
- }
14707
- async function composeUp(opts) {
14708
- if (!opts.alreadyFetched && !pinnedImagesReady(opts)) {
14709
- const pre = resolveEnvConfig(readEnvFile(), opts);
14710
- return { ok: false, apiPort: pre.apiPort, dashPort: pre.dashPort };
14711
- }
14712
- const rotation = composeSecretRotationRisk(opts);
14713
- if (rotation) {
14714
- const pre = resolveEnvConfig(readEnvFile(), opts);
14715
- console.error(renderSecretRotationRefusal(rotation));
14716
- return { ok: false, refused: true, apiPort: pre.apiPort, dashPort: pre.dashPort };
14717
- }
14718
- const pgData = composePgDataRisk(opts);
14719
- if (pgData) {
14720
- const pre = resolveEnvConfig(readEnvFile(), opts);
14721
- console.error(renderPgDataRefusal(pgData));
14722
- return { ok: false, refused: true, apiPort: pre.apiPort, dashPort: pre.dashPort };
14723
- }
14724
- const { buildDir, cfg, envChanged: rendered } = materialize(opts);
14725
- const apiPort = cfg.apiPort;
14726
- const dashPort = cfg.dashPort;
14727
- const envChanged = opts.envChanged ?? rendered;
14728
- const up = (extra = {}) => compose(
14729
- envChanged ? ["up", "-d", "--force-recreate", ...ENV_CONSUMING_SERVICES] : ["up", "-d"],
14730
- extra
14731
- );
14732
- const env = readEnvFile();
14733
- const project = env.COMPOSE_PROJECT_NAME || "openship";
14734
- removeOrphanedStack(project);
14735
- reconcileEdgeContainerName(project);
14736
- migrateLegacyEdgeVolumes(project);
14737
- warnOrphanedVolumes(project);
14738
- const unmakeableMounts = ensureEdgeMountDirs();
14739
- if (unmakeableMounts.length && isRootlessDocker()) {
14740
- const dirs = unmakeableMounts.join(" ");
14741
- console.error(
14742
- `
14743
- Rootless Docker can't create the edge's host directories under root-owned paths:
14744
- ${unmakeableMounts.join("\n ")}
14745
-
14746
- Create them once (owned by your user), then re-run \`openship up --compose\`:
14747
- sudo mkdir -p ${dirs}
14748
- sudo chown -R "$(id -un)" ${dirs}
14749
- `
14750
- );
14751
- return { ok: false, apiPort, dashPort };
14752
- }
14753
- if (dbVolumeExists(project)) {
14754
- reconcileDbPassword(env.POSTGRES_USER || "openship", env.POSTGRES_PASSWORD ?? "");
14755
- }
14756
- await sanitizeEdgeVhosts(
14757
- new LocalExecutor(),
14758
- EDGE_SITES_HOST_DIR,
14759
- (l2) => console.log(` ${l2.message}`)
14760
- ).catch(() => {
14761
- });
14762
- if (buildDir) {
14763
- if (!opts.alreadyFetched) {
14764
- if (compose(["pull", "postgres", "redis"], { withBuildOverride: true }) !== 0) {
14765
- return { ok: false, apiPort, dashPort };
14766
- }
14767
- if (compose(["build"], { withBuildOverride: true }) !== 0) {
14768
- return { ok: false, apiPort, dashPort };
14769
- }
14770
- }
14771
- if (up({ withBuildOverride: true }) !== 0) {
14772
- return { ok: false, apiPort, dashPort };
14773
- }
14774
- onEdgeContainerChanged();
14775
- writeInstallMethod("compose");
14776
- return { ok: true, apiPort, dashPort };
14777
- }
14778
- if (!opts.alreadyFetched && compose(["pull"]) !== 0) {
14779
- return { ok: false, apiPort, dashPort };
14780
- }
14781
- if (up() !== 0) return { ok: false, apiPort, dashPort };
14782
- onEdgeContainerChanged();
14783
- writeInstallMethod("compose");
14784
- return { ok: true, apiPort, dashPort };
14785
- }
14786
- function onEdgeContainerChanged() {
14787
- invalidateEdgeContainer();
14788
- }
14789
- function composeDown() {
14790
- if (!existsSync4(COMPOSE_FILE)) return false;
14791
- return compose(["down"]) === 0;
14792
- }
14793
- function composeUninstall(opts = {}) {
14794
- const removedImages = [];
14795
- const ok = existsSync4(COMPOSE_FILE) ? compose(["down", "-v", "--remove-orphans"]) === 0 : false;
14796
- if (opts.removeImages) {
14797
- const env = readEnvFile();
14798
- const registry = env.OPENSHIP_IMAGE_REGISTRY || DEFAULT_IMAGE_REGISTRY;
14799
- const version = env.OPENSHIP_VERSION || "latest";
14800
- for (const { service } of BUILT_SERVICES) {
14801
- const ref = `${registry}/openship-${service}:${version}`;
14802
- if (spawnSync4("docker", ["image", "rm", "-f", ref], { stdio: "ignore" }).status === 0) {
14803
- removedImages.push(ref);
14804
- }
14805
- }
14806
- }
14807
- return { ok, removedImages };
14808
- }
14809
- async function composeUpdate(version) {
14810
- if (!existsSync4(COMPOSE_FILE)) return { applied: false, refused: false };
14811
- const r2 = await composeUp(version ? { version } : {});
14812
- return { applied: r2.ok, refused: !!r2.refused };
14813
- }
14814
- function composePs() {
14815
- return compose(["ps"]);
14816
- }
14817
- function composeRunning() {
14818
- const r2 = spawnSync4(
14819
- "docker",
14820
- ["compose", "-f", COMPOSE_FILE, "ps", "--status", "running", "-q"],
14821
- { cwd: COMPOSE_DIR, encoding: "utf8" }
14822
- );
14823
- return r2.status === 0 && r2.stdout.trim().length > 0;
14824
- }
14825
- function composeStart() {
14826
- reconcileEdgeContainerName(readEnvFile().COMPOSE_PROJECT_NAME || "openship");
14827
- return compose(["up", "-d"], { quiet: true }) === 0;
14828
- }
14829
- function composeRestart() {
14830
- return compose(["restart"], { quiet: true }) === 0;
14831
- }
14832
- function composeInternalToken() {
14833
- return readEnvFile().INTERNAL_TOKEN ?? null;
14834
- }
14835
- var composePaths = {
14836
- dir: COMPOSE_DIR,
14837
- file: COMPOSE_FILE,
14838
- env: ENV_FILE,
14839
- envBackup: ENV_BACKUP_FILE
14840
- };
14841
13119
 
14842
13120
  export {
14843
- PORTS_FILE,
14844
- saveInstanceUrl,
14845
- readInstanceUrl,
14846
- readStoredPorts,
14847
- storedApiPort,
14848
- storedDashboardPort,
14849
- portMoveNotice,
14850
- resolvePorts,
13121
+ resolveLocalEnvironmentSync,
14851
13122
  thisHost,
13123
+ pasteable,
14852
13124
  startUnitHint,
14853
- DEFAULT_REPO,
14854
- SOURCE_CHECKOUT_DIR,
14855
- SOURCE_DIST_DIR,
14856
- has,
14857
- shortSha,
14858
- prepareFromSource,
14859
- readSourceInstall,
14860
- remoteSha,
14861
- rebuildFromSource,
14862
- readInstallMethod,
14863
- hasDockerCompose,
14864
- dockerInstallPreview,
14865
- ensureDocker,
14866
- composeSecretRotationRisk,
14867
- renderSecretRotationRefusal,
14868
- composePgDataRisk,
14869
- renderPgDataRefusal,
14870
- composeHostChannel,
14871
- composeHostChannelExpected,
14872
- composeTrustedOriginUrls,
14873
- resolveComposePorts,
14874
- sourceBuildDir,
14875
- composePlan,
14876
- pinnedImagesReady,
14877
- composePrefetch,
14878
- composeUp,
14879
- composeDown,
14880
- composeUninstall,
14881
- composeUpdate,
14882
- composePs,
14883
- composeRunning,
14884
- composeStart,
14885
- composeRestart,
14886
- composeInternalToken,
14887
- composePaths
13125
+ COMPOSE_DIR,
13126
+ COMPOSE_FILE,
13127
+ COMPOSE_ENV_FILE,
13128
+ composeInstallExists,
13129
+ readComposeEnvFile
14888
13130
  };