openship 0.6.0 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +7 -2
  2. package/dist/{bare-FSUAM5V5.js → bare-XQIPDO4R.js} +10 -9
  3. package/dist/{chunk-PSZSKEPA.js → chunk-3ZAGC5NT.js} +64 -17
  4. package/dist/{chunk-6ODUBKUB.js → chunk-5OUWIPOB.js} +69 -7
  5. package/dist/chunk-6HOA5JT7.js +124 -0
  6. package/dist/{chunk-TWJCLD4W.js → chunk-6JELW75A.js} +4 -4
  7. package/dist/{chunk-GUQ3QCJ7.js → chunk-7334NSJL.js} +399 -369
  8. package/dist/chunk-7S4USQYF.js +1753 -0
  9. package/dist/{chunk-3LDWWY7U.js → chunk-A7EWM2KA.js} +41 -7
  10. package/dist/chunk-ELGRMZOW.js +25 -0
  11. package/dist/chunk-EZPPTWJQ.js +292 -0
  12. package/dist/{chunk-IXM5245F.js → chunk-GRTXMUHA.js} +553 -538
  13. package/dist/chunk-HPXP6LXZ.js +428 -0
  14. package/dist/{chunk-OTLSJYAC.js → chunk-LEYSL46B.js} +2135 -85
  15. package/dist/{chunk-EMXI4UCU.js → chunk-LFCSC4IU.js} +29 -10
  16. package/dist/{chunk-JIMUYEKN.js → chunk-MSZUCNPV.js} +1 -1
  17. package/dist/{chunk-OGY63DGD.js → chunk-PZRRL5PC.js} +71 -28
  18. package/dist/{chunk-VE2YOK4I.js → chunk-QLX7NBEA.js} +1218 -588
  19. package/dist/chunk-RFWJE7ZN.js +68 -0
  20. package/dist/{chunk-VDDMADOF.js → chunk-TM2IQPYU.js} +16 -2
  21. package/dist/{chunk-3FYIXVX5.js → chunk-VDESJSG6.js} +451 -106
  22. package/dist/{chunk-B3CE2OMR.js → chunk-VHMOQL5Y.js} +276 -50
  23. package/dist/{chunk-QEH4QUBX.js → chunk-VLQ3UIVO.js} +393 -53
  24. package/dist/{chunk-VZ2ZHPS2.js → chunk-WE7BXW6L.js} +3 -9
  25. package/dist/chunk-WFRXYWK2.js +444 -0
  26. package/dist/chunk-XRNRT37W.js +316 -0
  27. package/dist/{chunk-EP2TNALB.js → chunk-XVOBL7BC.js} +31 -8
  28. package/dist/cloud-3DODOWTR.js +36 -0
  29. package/dist/{detect-ALF4GXRX.js → detect-P4CIQBRH.js} +3 -3
  30. package/dist/{docker-QECQJ5P4.js → docker-3K2NEBD6.js} +15 -8
  31. package/dist/{docker-edge-executor-62N64MGS.js → docker-edge-executor-YZZ6P7IC.js} +5 -5
  32. package/dist/{edge-import-CQWHN2Q3.js → edge-import-MCJNVTZ2.js} +26 -23
  33. package/dist/edge-preflight-ES6FH4JK.js +42 -0
  34. package/dist/{ensure-container-edge-MJVXJQG5.js → ensure-container-edge-YPITNH45.js} +9 -8
  35. package/dist/{executor-JELARVKC.js → executor-DZGX55K2.js} +12 -5
  36. package/dist/host-channel-preflight-MUXE3KZU.js +49 -0
  37. package/dist/index.js +3668 -4063
  38. package/dist/{nginx-AXGNWRKZ.js → nginx-NSAHWGJV.js} +11 -5
  39. package/dist/node-bootstrap.js +241 -0
  40. package/dist/node-entry.js +52 -1
  41. package/dist/{openresty-lua-IE4P5YQX.js → openresty-lua-DR4D3EZ6.js} +6 -2
  42. package/dist/privilege-YTVI4SKF.js +19 -0
  43. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +4 -1
  44. package/dist/server/index.js +21501 -13137
  45. package/dist/server/lua/geo_country.lua +4 -3
  46. package/dist/server/lua/pipe_stream.lua +52 -39
  47. package/dist/server/lua/site_logger.lua +22 -24
  48. package/dist/server/migrations/0098_instance_product_mode.sql +12 -0
  49. package/dist/server/migrations/0099_mail_webmail_project.sql +24 -0
  50. package/dist/server/migrations/0100_server_ssh_private_key.sql +11 -0
  51. package/dist/server/migrations/0101_mail_resume_step.sql +12 -0
  52. package/dist/server/migrations/0102_project_workload_axes.sql +19 -0
  53. package/dist/server/migrations/0103_dns_credential.sql +41 -0
  54. package/dist/server/migrations/0104_instance_host_control.sql +15 -0
  55. package/dist/server/migrations/meta/_journal.json +49 -0
  56. package/dist/{setup-FA4PW4NU.js → setup-WUUZURHS.js} +10 -9
  57. package/dist/up-XEO3SR6Z.js +58 -0
  58. package/package.json +4 -2
  59. package/dist/chunk-IW2UKBVF.js +0 -144
  60. package/dist/chunk-ZCFVHA25.js +0 -89
  61. package/dist/cloud-E5M6VMCG.js +0 -31
  62. package/dist/server/lua/pipe_log.lua +0 -77
  63. /package/dist/{dist-es-JGBPABJI.js → dist-es-ZP5QDSJ3.js} +0 -0
@@ -0,0 +1,1753 @@
1
+ #!/usr/bin/env sh
2
+ ":" //# ; exec "$(command -v node || command -v bun)" "$0" "$@"
3
+ import { createRequire as __ospCreateRequire } from "node:module";
4
+ const require = __ospCreateRequire(import.meta.url);
5
+ import {
6
+ importMigratedSites
7
+ } from "./chunk-6HOA5JT7.js";
8
+ import {
9
+ completeHostEdge,
10
+ markStoppedProxyImported,
11
+ planAndApplyHostEdge,
12
+ previewHostEdge,
13
+ rollbackHostEdge
14
+ } from "./chunk-HPXP6LXZ.js";
15
+ import {
16
+ verifyHostChannel
17
+ } from "./chunk-XRNRT37W.js";
18
+ import {
19
+ DEFAULT_REPO,
20
+ PORTS_FILE,
21
+ SOURCE_CHECKOUT_DIR,
22
+ SOURCE_DIST_DIR,
23
+ composeInternalToken,
24
+ composePgDataRisk,
25
+ composePlan,
26
+ composePrefetch,
27
+ composeSecretRotationRisk,
28
+ composeTrustedOriginUrls,
29
+ composeUp,
30
+ dockerInstallPreview,
31
+ ensureDocker,
32
+ hasDockerCompose,
33
+ pinnedImagesReady,
34
+ portMoveNotice,
35
+ prepareFromSource,
36
+ readInstanceUrl,
37
+ readStoredPorts,
38
+ renderPgDataRefusal,
39
+ renderSecretRotationRefusal,
40
+ resolveComposePorts,
41
+ resolvePorts,
42
+ sourceBuildDir,
43
+ startUnitHint,
44
+ thisHost
45
+ } from "./chunk-QLX7NBEA.js";
46
+ import {
47
+ AUTH_SECRET_FILE,
48
+ DATA_DIR,
49
+ INTERNAL_TOKEN_FILE,
50
+ IS_ALT_HOME,
51
+ LOG_DIR,
52
+ OS_DIR
53
+ } from "./chunk-ELGRMZOW.js";
54
+ import {
55
+ LocalExecutor
56
+ } from "./chunk-6FH4AXVY.js";
57
+ import {
58
+ EDGE_CONTAINER_NAME,
59
+ edgeCrashReason,
60
+ edgeIsBroken
61
+ } from "./chunk-WE7BXW6L.js";
62
+ import {
63
+ isValidEmail
64
+ } from "./chunk-LEYSL46B.js";
65
+
66
+ // src/commands/up.ts
67
+ import { Command } from "commander";
68
+ import chalk2 from "chalk";
69
+ import ora from "ora";
70
+ import { spawn } from "child_process";
71
+ import { randomBytes as randomBytes2 } from "crypto";
72
+ import { createWriteStream as createWriteStream2, existsSync as existsSync5, mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
73
+ import { dirname as dirname2, join as join5 } from "path";
74
+ import { fileURLToPath } from "url";
75
+
76
+ // src/lib/dashboard.ts
77
+ import { spawnSync } from "child_process";
78
+ import { existsSync, mkdirSync as mkdirSync2, rmSync, writeFileSync } from "fs";
79
+ import { join as join2 } from "path";
80
+
81
+ // src/lib/cache.ts
82
+ import { createHash } from "crypto";
83
+ import { createReadStream, createWriteStream, mkdirSync } from "fs";
84
+ import { dirname, join } from "path";
85
+ var CACHE_DIR = join(OS_DIR, "cache");
86
+ var RELEASES_DIR = join(CACHE_DIR, "releases");
87
+ function releaseDir(tag) {
88
+ return join(RELEASES_DIR, tag);
89
+ }
90
+ function parseSha256(content) {
91
+ const token = content.trim().split(/\s+/)[0]?.toLowerCase();
92
+ return token && /^[0-9a-f]{64}$/.test(token) ? token : null;
93
+ }
94
+ async function hashFile(path) {
95
+ const hash = createHash("sha256");
96
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
97
+ return hash.digest("hex");
98
+ }
99
+ async function downloadToFile(url, dest, onProgress) {
100
+ const res = await fetch(url, { headers: { "User-Agent": "openship-cli" } });
101
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} for ${url}`);
102
+ mkdirSync(dirname(dest), { recursive: true });
103
+ const total = Number(res.headers.get("content-length")) || 0;
104
+ const hash = createHash("sha256");
105
+ const file = createWriteStream(dest);
106
+ const reader = res.body.getReader();
107
+ let received = 0;
108
+ try {
109
+ for (; ; ) {
110
+ const { done, value } = await reader.read();
111
+ if (done) break;
112
+ hash.update(value);
113
+ if (!file.write(Buffer.from(value))) {
114
+ await new Promise((r) => file.once("drain", r));
115
+ }
116
+ received += value.length;
117
+ onProgress?.(received, total);
118
+ }
119
+ } finally {
120
+ file.end();
121
+ }
122
+ await new Promise((resolve2, reject) => {
123
+ file.on("finish", () => resolve2());
124
+ file.on("error", reject);
125
+ });
126
+ return { sha256: hash.digest("hex"), size: received };
127
+ }
128
+ function formatBytes(n) {
129
+ if (n < 1024) return `${n} B`;
130
+ const units = ["KB", "MB", "GB"];
131
+ let v = n / 1024;
132
+ let i = 0;
133
+ while (v >= 1024 && i < units.length - 1) {
134
+ v /= 1024;
135
+ i += 1;
136
+ }
137
+ return `${v.toFixed(1)} ${units[i]}`;
138
+ }
139
+
140
+ // src/lib/github-releases.ts
141
+ var REPO = "oblien/openship";
142
+ var RELEASES = `https://github.com/${REPO}/releases`;
143
+ var LATEST_API = `https://api.github.com/repos/${REPO}/releases/latest`;
144
+ async function resolveLatestTag() {
145
+ const res = await fetch(LATEST_API, {
146
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "openship-cli" },
147
+ signal: AbortSignal.timeout(1e4)
148
+ });
149
+ if (!res.ok) throw new Error(`GitHub latest-release lookup failed: HTTP ${res.status}`);
150
+ const data = await res.json();
151
+ if (!data.tag_name) throw new Error("Latest release has no tag_name");
152
+ return data.tag_name;
153
+ }
154
+ function assetUrl(tag, name) {
155
+ return `${RELEASES}/download/${tag}/${name}`;
156
+ }
157
+ async function fetchSidecar(url) {
158
+ const res = await fetch(url, {
159
+ headers: { "User-Agent": "openship-cli" },
160
+ signal: AbortSignal.timeout(15e3)
161
+ });
162
+ if (res.status === 404) return null;
163
+ if (!res.ok) throw new Error(`Sidecar fetch failed: HTTP ${res.status}`);
164
+ return res.text();
165
+ }
166
+ async function expectedSha256(tag, name) {
167
+ const body = await fetchSidecar(`${assetUrl(tag, name)}.sha256`);
168
+ return body ? parseSha256(body) : null;
169
+ }
170
+
171
+ // src/lib/dashboard.ts
172
+ var DASHBOARD_CACHE = join2(CACHE_DIR, "dashboard");
173
+ function assetName(tag) {
174
+ return `openship-dashboard-${tag}.tar.gz`;
175
+ }
176
+ function resolveDashboardEntry(cwd) {
177
+ const wrapped = join2(cwd, "standalone-server.mjs");
178
+ return existsSync(wrapped) ? wrapped : join2(cwd, "server.js");
179
+ }
180
+ async function ensureDashboard(opts = {}) {
181
+ const override = process.env.OPENSHIP_DASHBOARD_DIR?.trim();
182
+ if (override) {
183
+ const cwd = join2(override, "apps", "dashboard");
184
+ const serverJs = join2(cwd, "server.js");
185
+ if (!existsSync(serverJs)) {
186
+ throw new Error(
187
+ `OPENSHIP_DASHBOARD_DIR=${override} but ${serverJs} is missing \u2014 build the dashboard standalone first (see docs).`
188
+ );
189
+ }
190
+ return { tag: "local", entry: resolveDashboardEntry(cwd), cwd };
191
+ }
192
+ const requested = opts.tag ?? await resolveLatestTag();
193
+ try {
194
+ return await fetchBundle(requested, opts.onProgress);
195
+ } catch (err) {
196
+ if (opts.tag && /\b404\b/.test(err?.message ?? "")) {
197
+ const latest = await resolveLatestTag();
198
+ if (latest && latest !== requested) {
199
+ return await fetchBundle(latest, opts.onProgress);
200
+ }
201
+ }
202
+ throw err;
203
+ }
204
+ }
205
+ async function fetchBundle(tag, onProgress) {
206
+ const dir = join2(DASHBOARD_CACHE, tag);
207
+ const cwd = join2(dir, "apps", "dashboard");
208
+ const serverJs = join2(cwd, "server.js");
209
+ const marker = join2(dir, ".extracted");
210
+ if (existsSync(marker) && existsSync(serverJs)) {
211
+ return { tag, entry: resolveDashboardEntry(cwd), cwd };
212
+ }
213
+ rmSync(dir, { recursive: true, force: true });
214
+ mkdirSync2(dir, { recursive: true });
215
+ const name = assetName(tag);
216
+ const tarball = join2(dir, name);
217
+ const { sha256 } = await downloadToFile(assetUrl(tag, name), tarball, onProgress);
218
+ const expected = await expectedSha256(tag, name);
219
+ if (!expected) {
220
+ throw new Error(
221
+ `No .sha256 sidecar for ${name}; refusing to run an unverified dashboard bundle.`
222
+ );
223
+ }
224
+ if (expected !== sha256) {
225
+ rmSync(dir, { recursive: true, force: true });
226
+ throw new Error(`Dashboard bundle checksum mismatch (expected ${expected}, got ${sha256}).`);
227
+ }
228
+ const untar = spawnSync("tar", ["-xzf", tarball, "-C", dir], { stdio: "inherit" });
229
+ if (untar.status !== 0) {
230
+ throw new Error("Failed to extract the dashboard bundle (is `tar` available?).");
231
+ }
232
+ rmSync(tarball, { force: true });
233
+ if (!existsSync(serverJs)) {
234
+ throw new Error(`Dashboard bundle extracted but ${serverJs} is missing (unexpected layout).`);
235
+ }
236
+ writeFileSync(marker, `${tag}
237
+ `);
238
+ return { tag, entry: resolveDashboardEntry(cwd), cwd };
239
+ }
240
+
241
+ // src/lib/service.ts
242
+ import { spawnSync as spawnSync2 } from "child_process";
243
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, rmSync as rmSync2 } from "fs";
244
+ import { homedir } from "os";
245
+ import { join as join4, resolve } from "path";
246
+
247
+ // src/lib/cli-install.ts
248
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, writeFileSync as writeFileSync2 } from "fs";
249
+ import { join as join3 } from "path";
250
+ var MARKER = join3(OS_DIR, "cli-install.json");
251
+ function parseCliInstall(raw) {
252
+ try {
253
+ const obj = JSON.parse(raw);
254
+ if (!obj || obj.method !== "tarball" || typeof obj.tag !== "string" || !obj.tag) return null;
255
+ return {
256
+ method: "tarball",
257
+ tag: obj.tag,
258
+ launcher: typeof obj.launcher === "string" ? obj.launcher : join3(OS_DIR, "bin", "openship"),
259
+ runtime: obj.runtime === "vendored" ? "vendored" : "system"
260
+ };
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+ function readCliInstall() {
266
+ if (!existsSync2(MARKER)) return null;
267
+ try {
268
+ return parseCliInstall(readFileSync(MARKER, "utf8"));
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+ function writeCliInstall(info) {
274
+ mkdirSync3(OS_DIR, { recursive: true });
275
+ writeFileSync2(MARKER, `${JSON.stringify(info, null, 2)}
276
+ `);
277
+ }
278
+
279
+ // src/lib/service.ts
280
+ var HOME = homedir();
281
+ var OPENSHIP_BIN = join4(OS_DIR, "bin");
282
+ var MAC_LABEL = IS_ALT_HOME ? "io.openship-dev.up" : "io.openship.up";
283
+ var MAC_PLIST = join4(HOME, "Library", "LaunchAgents", `${MAC_LABEL}.plist`);
284
+ var SYSTEMD_NAME = IS_ALT_HOME ? "openship-dev" : "openship";
285
+ var WIN_TASK = IS_ALT_HOME ? "OpenshipDev" : "Openship";
286
+ function selfInvocation() {
287
+ const cli = readCliInstall();
288
+ if (cli?.method === "tarball" && existsSync3(cli.launcher)) {
289
+ return { runtime: cli.launcher, args: [] };
290
+ }
291
+ const runtime = process.execPath;
292
+ const entry = resolve(process.argv[1] ?? "");
293
+ return { runtime, args: [entry] };
294
+ }
295
+ function upArgs(flags) {
296
+ const a = ["up", "--foreground"];
297
+ if (flags.port) a.push("--port", flags.port);
298
+ if (flags.dataDir) a.push("--data-dir", flags.dataDir);
299
+ if (flags.dashboardPort) a.push("--dashboard-port", flags.dashboardPort);
300
+ if (flags.ui === false) a.push("--no-ui");
301
+ if (flags.uiVersion) a.push("--ui-version", flags.uiVersion);
302
+ if (flags.publicUrl) a.push("--public-url", flags.publicUrl);
303
+ if (flags.trustProxy) a.push("--trust-proxy");
304
+ if (flags.host) a.push("--host", flags.host);
305
+ if (flags.managedEdge) a.push("--managed-edge");
306
+ if (flags.acmeEmail) a.push("--acme-email", flags.acmeEmail);
307
+ if (flags.mail) a.push("--mail");
308
+ return a;
309
+ }
310
+ function runArgv(flags) {
311
+ const { runtime, args } = selfInvocation();
312
+ return [runtime, ...args, ...upArgs(flags)];
313
+ }
314
+ function run(cmd, args) {
315
+ const r = spawnSync2(cmd, args, { encoding: "utf8" });
316
+ return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
317
+ }
318
+ function sleepSync(ms) {
319
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
320
+ }
321
+ function launchdPid() {
322
+ const r = run("launchctl", ["list", MAC_LABEL]);
323
+ if (!r.ok) return null;
324
+ const m = r.out.match(/"PID"\s*=\s*(\d+)/);
325
+ return m ? Number(m[1]) : null;
326
+ }
327
+ function launchdLastExit() {
328
+ const m = run("launchctl", ["list", MAC_LABEL]).out.match(/"LastExitStatus"\s*=\s*(-?\d+)/);
329
+ return m ? Number(m[1]) : null;
330
+ }
331
+ function isRoot() {
332
+ return typeof process.getuid === "function" && process.getuid() === 0;
333
+ }
334
+ function detectKind() {
335
+ if (process.platform === "darwin") return "launchd";
336
+ if (process.platform === "linux") {
337
+ if (thisHost().profile.serviceManager !== "systemd") return "unsupported";
338
+ return isRoot() ? "systemd-system" : "systemd-user";
339
+ }
340
+ if (process.platform === "win32") return "schtasks";
341
+ return "unsupported";
342
+ }
343
+ function xmlEscape(s) {
344
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
345
+ }
346
+ function serviceEnv() {
347
+ const extra = {};
348
+ const dashDir = process.env.OPENSHIP_DASHBOARD_DIR?.trim();
349
+ if (dashDir) extra.OPENSHIP_DASHBOARD_DIR = dashDir;
350
+ const home = process.env.OPENSHIP_HOME?.trim();
351
+ if (home) extra.OPENSHIP_HOME = home;
352
+ return extra;
353
+ }
354
+ function plist(flags) {
355
+ const argv = runArgv(flags);
356
+ const items = argv.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
357
+ const runtimeBin = readCliInstall()?.method === "tarball" ? OPENSHIP_BIN : join4(HOME, ".bun/bin");
358
+ const path = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", runtimeBin].join(":");
359
+ const extraEnv = Object.entries(serviceEnv()).map(([k, v]) => `<key>${xmlEscape(k)}</key><string>${xmlEscape(v)}</string>`).join("");
360
+ return `<?xml version="1.0" encoding="UTF-8"?>
361
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
362
+ <plist version="1.0">
363
+ <dict>
364
+ <key>Label</key><string>${MAC_LABEL}</string>
365
+ <key>ProgramArguments</key>
366
+ <array>
367
+ ${items}
368
+ </array>
369
+ <key>RunAtLoad</key><true/>
370
+ <key>KeepAlive</key><true/>
371
+ <key>StandardOutPath</key><string>${join4(LOG_DIR, "up.log")}</string>
372
+ <key>StandardErrorPath</key><string>${join4(LOG_DIR, "up.err.log")}</string>
373
+ <key>EnvironmentVariables</key>
374
+ <dict><key>PATH</key><string>${path}</string>${extraEnv}</dict>
375
+ </dict>
376
+ </plist>
377
+ `;
378
+ }
379
+ function systemdUnit(flags) {
380
+ const argv = runArgv(flags);
381
+ const execStart = argv.map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ");
382
+ const extraEnv = Object.entries(serviceEnv()).map(([k, v]) => `Environment=${k}=${v}
383
+ `).join("");
384
+ const pathEnv = readCliInstall()?.method === "tarball" ? `Environment=PATH=${["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin", OPENSHIP_BIN].join(":")}
385
+ ` : "";
386
+ return `[Unit]
387
+ Description=Openship control plane
388
+ After=network-online.target
389
+ Wants=network-online.target
390
+
391
+ [Service]
392
+ Type=simple
393
+ ExecStart=${execStart}
394
+ Restart=always
395
+ RestartSec=2
396
+ Environment=NODE_ENV=production
397
+ ${pathEnv}${extraEnv}
398
+ [Install]
399
+ WantedBy=default.target
400
+ `;
401
+ }
402
+ function installStepFor(kind) {
403
+ switch (kind) {
404
+ case "launchd":
405
+ return "launchctl bootstrap (starts it now and at login)";
406
+ case "systemd-user":
407
+ return "systemctl --user enable --now + loginctl enable-linger (starts it now, on boot, without a login session)";
408
+ case "systemd-system":
409
+ return "systemctl enable --now (starts it now and on boot)";
410
+ case "schtasks":
411
+ return "schtasks /Create /SC ONLOGON (starts it at logon)";
412
+ default:
413
+ return "nothing \u2014 no supported service manager on this box (needs systemd on Linux); `up --foreground` or `up --compose` instead";
414
+ }
415
+ }
416
+ function preview(flags) {
417
+ const kind = detectKind();
418
+ if (kind === "launchd") return { kind, path: MAC_PLIST, content: plist(flags) };
419
+ if (kind === "systemd-user") return { kind, path: join4(HOME, ".config/systemd/user", `${SYSTEMD_NAME}.service`), content: systemdUnit(flags) };
420
+ if (kind === "systemd-system") return { kind, path: `/etc/systemd/system/${SYSTEMD_NAME}.service`, content: systemdUnit(flags) };
421
+ if (kind === "schtasks") return { kind, path: WIN_TASK, content: runArgv(flags).join(" ") };
422
+ return { kind, path: "", content: "" };
423
+ }
424
+ function installAndStart(flags) {
425
+ mkdirSync4(LOG_DIR, { recursive: true });
426
+ const kind = detectKind();
427
+ if (kind === "launchd") {
428
+ mkdirSync4(join4(HOME, "Library", "LaunchAgents"), { recursive: true });
429
+ run("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/${MAC_LABEL}`]);
430
+ writeFileSync3(MAC_PLIST, plist(flags));
431
+ const uid = String(process.getuid?.() ?? "");
432
+ let r = run("launchctl", ["bootstrap", `gui/${uid}`, MAC_PLIST]);
433
+ if (!r.ok) r = run("launchctl", ["load", "-w", MAC_PLIST]);
434
+ if (!r.ok) throw new Error(`launchctl failed to load the agent: ${r.out}`);
435
+ return { kind, detail: `launchd agent ${MAC_LABEL} (logs: ${LOG_DIR})` };
436
+ }
437
+ if (kind === "systemd-user" || kind === "systemd-system") {
438
+ const sysArgs = kind === "systemd-user" ? ["--user"] : [];
439
+ const unitPath = kind === "systemd-user" ? join4(HOME, ".config/systemd/user", `${SYSTEMD_NAME}.service`) : `/etc/systemd/system/${SYSTEMD_NAME}.service`;
440
+ mkdirSync4(unitPath.slice(0, unitPath.lastIndexOf("/")), { recursive: true });
441
+ writeFileSync3(unitPath, systemdUnit(flags));
442
+ run("systemctl", [...sysArgs, "daemon-reload"]);
443
+ const r = run("systemctl", [...sysArgs, "enable", "--now", SYSTEMD_NAME]);
444
+ if (!r.ok) throw new Error(`systemctl enable failed: ${r.out}`);
445
+ if (kind === "systemd-user") {
446
+ run("loginctl", ["enable-linger", process.env.USER ?? ""]);
447
+ }
448
+ return { kind, detail: `systemd unit ${SYSTEMD_NAME} (${kind === "systemd-user" ? "--user" : "system"})` };
449
+ }
450
+ if (kind === "schtasks") {
451
+ const tr = runArgv(flags).map((a) => `\\"${a}\\"`).join(" ");
452
+ const r = run("schtasks", ["/Create", "/TN", WIN_TASK, "/SC", "ONLOGON", "/RL", "HIGHEST", "/TR", tr, "/F"]);
453
+ if (!r.ok) throw new Error(`schtasks create failed: ${r.out}`);
454
+ run("schtasks", ["/Run", "/TN", WIN_TASK]);
455
+ return { kind, detail: `Scheduled Task ${WIN_TASK} (best-effort; runs at logon)` };
456
+ }
457
+ throw new Error(
458
+ "No supported service manager found (need systemd on Linux). Run `openship up --foreground` instead, or use docker compose for always-on."
459
+ );
460
+ }
461
+ function restart() {
462
+ const kind = detectKind();
463
+ if (kind === "launchd") {
464
+ if (!existsSync3(MAC_PLIST)) return { restarted: false, detail: "no launchd agent installed" };
465
+ const uid = String(process.getuid?.() ?? "");
466
+ const r = run("launchctl", ["kickstart", "-k", `gui/${uid}/${MAC_LABEL}`]);
467
+ if (!r.ok) return { restarted: false, detail: r.out || "launchctl kickstart failed" };
468
+ for (let i = 0; i < 6; i++) {
469
+ sleepSync(500);
470
+ if (launchdPid() != null) return { restarted: true, detail: `restarted ${MAC_LABEL}` };
471
+ }
472
+ const exit = launchdLastExit();
473
+ return {
474
+ restarted: false,
475
+ detail: `${MAC_LABEL} was killed but didn't stay up${exit != null ? ` (last exit ${exit})` : ""} \u2014 check logs in ${LOG_DIR}`
476
+ };
477
+ }
478
+ if (kind === "systemd-user" || kind === "systemd-system") {
479
+ const sysArgs = kind === "systemd-user" ? ["--user"] : [];
480
+ const unitPath = kind === "systemd-user" ? join4(HOME, ".config/systemd/user", `${SYSTEMD_NAME}.service`) : `/etc/systemd/system/${SYSTEMD_NAME}.service`;
481
+ if (!existsSync3(unitPath)) return { restarted: false, detail: "no systemd unit installed" };
482
+ const r = run("systemctl", [...sysArgs, "restart", SYSTEMD_NAME]);
483
+ return { restarted: r.ok, detail: r.ok ? `restarted ${SYSTEMD_NAME}` : r.out };
484
+ }
485
+ if (kind === "schtasks") {
486
+ run("schtasks", ["/End", "/TN", WIN_TASK]);
487
+ const r = run("schtasks", ["/Run", "/TN", WIN_TASK]);
488
+ return { restarted: r.ok, detail: r.ok ? `restarted ${WIN_TASK}` : r.out };
489
+ }
490
+ return { restarted: false, detail: "no supported service manager" };
491
+ }
492
+ function serviceStatus() {
493
+ const kind = detectKind();
494
+ if (kind === "launchd") {
495
+ return { kind, installed: existsSync3(MAC_PLIST), running: launchdPid() != null };
496
+ }
497
+ if (kind === "systemd-user" || kind === "systemd-system") {
498
+ const sysArgs = kind === "systemd-user" ? ["--user"] : [];
499
+ const unitPath = kind === "systemd-user" ? join4(HOME, ".config/systemd/user", `${SYSTEMD_NAME}.service`) : `/etc/systemd/system/${SYSTEMD_NAME}.service`;
500
+ return {
501
+ kind,
502
+ installed: existsSync3(unitPath),
503
+ running: run("systemctl", [...sysArgs, "is-active", SYSTEMD_NAME]).out.trim() === "active"
504
+ };
505
+ }
506
+ if (kind === "schtasks") {
507
+ const q = run("schtasks", ["/Query", "/TN", WIN_TASK]);
508
+ return { kind, installed: q.ok, running: q.ok && /Running/i.test(q.out) };
509
+ }
510
+ return { kind, installed: false, running: false };
511
+ }
512
+ function sweepOrphanPorts() {
513
+ if (process.platform === "win32") return;
514
+ const stored = readStoredPorts();
515
+ const ports = [stored.api, stored.dashboard].filter((n) => typeof n === "number");
516
+ if (ports.length === 0) return;
517
+ const self = process.pid;
518
+ for (const port of ports) {
519
+ const q = run("lsof", ["-ti", `tcp:${port}`]);
520
+ if (!q.ok) continue;
521
+ for (const token of q.out.split(/\s+/).filter(Boolean)) {
522
+ const pid = Number(token);
523
+ if (Number.isInteger(pid) && pid > 1 && pid !== self) {
524
+ try {
525
+ process.kill(pid, "SIGKILL");
526
+ } catch {
527
+ }
528
+ }
529
+ }
530
+ }
531
+ }
532
+ function stop() {
533
+ const kind = detectKind();
534
+ let result;
535
+ if (kind === "launchd") {
536
+ run("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/${MAC_LABEL}`]);
537
+ if (existsSync3(MAC_PLIST)) rmSync2(MAC_PLIST, { force: true });
538
+ result = { kind, detail: `launchd agent ${MAC_LABEL} stopped + removed` };
539
+ } else if (kind === "systemd-user" || kind === "systemd-system") {
540
+ const sysArgs = kind === "systemd-user" ? ["--user"] : [];
541
+ run("systemctl", [...sysArgs, "disable", "--now", SYSTEMD_NAME]);
542
+ const unitPath = kind === "systemd-user" ? join4(HOME, ".config/systemd/user", `${SYSTEMD_NAME}.service`) : `/etc/systemd/system/${SYSTEMD_NAME}.service`;
543
+ if (existsSync3(unitPath)) rmSync2(unitPath, { force: true });
544
+ run("systemctl", [...sysArgs, "daemon-reload"]);
545
+ result = { kind, detail: `systemd unit ${SYSTEMD_NAME} stopped + disabled` };
546
+ } else if (kind === "schtasks") {
547
+ run("schtasks", ["/End", "/TN", WIN_TASK]);
548
+ run("schtasks", ["/Delete", "/TN", WIN_TASK, "/F"]);
549
+ result = { kind, detail: `Scheduled Task ${WIN_TASK} stopped + removed` };
550
+ } else {
551
+ result = { kind, detail: "no supported service manager \u2014 nothing to stop" };
552
+ }
553
+ sweepOrphanPorts();
554
+ return result;
555
+ }
556
+
557
+ // src/lib/up-plan.ts
558
+ import chalk from "chalk";
559
+ function planMethod(opts, dockerUsable) {
560
+ if (opts.bare) return { method: "bare", reason: "--bare was passed" };
561
+ if (opts.compose) return { method: "compose", reason: "--compose was passed" };
562
+ if (process.platform !== "linux") {
563
+ return {
564
+ method: "bare",
565
+ reason: `the compose edge needs host networking, which Docker Desktop on ${process.platform} doesn't provide`
566
+ };
567
+ }
568
+ return dockerUsable ? { method: "compose", reason: "the default on Linux when Docker is usable" } : { method: "bare", reason: "Docker isn't usable here, so `up` falls back to the process service" };
569
+ }
570
+ async function planUp(opts) {
571
+ const mode = opts.fromSource || opts.source ? "from-source" : opts.foreground ? "foreground" : "service";
572
+ const attached = mode !== "service";
573
+ const docker = dockerInstallPreview();
574
+ const dockerUsable = docker.gap === null || docker.wouldInstall;
575
+ const { method, reason } = attached ? { method: "bare", reason: `--${mode === "foreground" ? "foreground" : "from-source"} runs attached (no service, no compose)` } : planMethod(opts, dockerUsable);
576
+ const plan = {
577
+ mode,
578
+ method,
579
+ methodReason: reason,
580
+ docker,
581
+ ports: method === "compose" ? await resolveComposePorts({ api: opts.port, dashboard: opts.dashboardPort }, { persist: false }) : await resolvePorts(
582
+ {
583
+ api: opts.port ? Number(opts.port) : void 0,
584
+ dashboard: opts.dashboardPort ? Number(opts.dashboardPort) : void 0
585
+ },
586
+ { persist: false }
587
+ ),
588
+ ...opts.publicUrl ? { publicUrl: opts.publicUrl } : {},
589
+ writes: [],
590
+ runs: []
591
+ };
592
+ if (mode === "from-source") {
593
+ plan.fromSource = {
594
+ ref: opts.source ? "local" : (opts.ref || "main").trim(),
595
+ ...opts.source ? { sourceDir: opts.source } : { repo: opts.repo || DEFAULT_REPO }
596
+ };
597
+ }
598
+ if (method === "compose") {
599
+ const stack = composePlan({
600
+ apiPort: String(plan.ports.api),
601
+ dashboardPort: String(plan.ports.dashboard),
602
+ ...opts.publicUrl ? { publicUrl: opts.publicUrl } : {},
603
+ ...opts.trustProxy ? { trustProxy: true } : {},
604
+ ...opts.hostControl === false ? { noHostControl: true } : {},
605
+ ...opts.hostSshHost ? { hostSshHost: opts.hostSshHost } : {},
606
+ ...opts.hostSshPort ? { hostSshPort: opts.hostSshPort } : {},
607
+ ...opts.resetSecrets ? { resetSecrets: true } : {},
608
+ ...opts.mail ? { mail: true } : {}
609
+ });
610
+ plan.compose = stack;
611
+ if (process.platform === "linux") plan.edge = await previewHostEdge();
612
+ plan.writes.push(
613
+ stack.composeFile,
614
+ `${stack.envFile}${stack.newSecrets.length ? ` (generates ${stack.newSecrets.join(", ")})` : ""}`,
615
+ ...stack.buildFile ? [stack.buildFile] : [],
616
+ ...stack.hostChannel ? [
617
+ stack.hostChannel.keyPath,
618
+ `${stack.hostChannel.authKeysPath}${stack.hostChannel.viaSudo ? " (via sudo)" : ""} (authorizes that key for ${stack.hostChannel.user}'s host ops; --no-host-control skips it)`,
619
+ ...stack.hostChannel.rootUnavailable ? [
620
+ "\u26A0 host control needs root \u2014 this user isn't root and passwordless sudo isn't available, so mail/edge host ops will fail (re-run as root to fix)"
621
+ ] : []
622
+ ] : [],
623
+ ...stack.mountDirs.map((d) => `${d}/ (edge routing state + certs)`),
624
+ stack.installMethodFile
625
+ );
626
+ plan.runs.push(
627
+ stack.buildDir ? `docker compose pull postgres redis && docker compose build (from ${stack.buildDir})` : `docker compose pull (postgres, redis, api, dashboard, edge \u2014 several hundred MB)`,
628
+ `docker compose up -d (the edge container binds host :80 and :443)`
629
+ );
630
+ } else {
631
+ plan.writes.push(PORTS_FILE, `${LOG_DIR}/`);
632
+ if (mode === "service") {
633
+ const svc = preview({
634
+ port: opts.port,
635
+ dataDir: opts.dataDir,
636
+ dashboardPort: opts.dashboardPort,
637
+ ui: opts.ui,
638
+ uiVersion: opts.uiVersion,
639
+ publicUrl: opts.publicUrl,
640
+ trustProxy: opts.trustProxy || opts.managedEdge,
641
+ host: opts.host,
642
+ managedEdge: opts.managedEdge,
643
+ acmeEmail: opts.acmeEmail,
644
+ mail: opts.mail
645
+ });
646
+ plan.service = svc;
647
+ if (svc.path) plan.writes.push(svc.path);
648
+ plan.runs.push(installStepFor(svc.kind));
649
+ }
650
+ plan.writes.push(
651
+ AUTH_SECRET_FILE,
652
+ INTERNAL_TOKEN_FILE,
653
+ `${opts.dataDir || DATA_DIR}/ (embedded Postgres)`
654
+ );
655
+ if (opts.ui !== false) {
656
+ plan.runs.push(`download the dashboard bundle \u2192 ${DASHBOARD_CACHE} (--no-ui skips it)`);
657
+ }
658
+ if (mode === "from-source") {
659
+ const src = plan.fromSource;
660
+ plan.runs.unshift(
661
+ ...src.sourceDir ? [`build the checkout at ${src.sourceDir}`] : [`git clone/fetch ${src.repo} @ ${src.ref} \u2192 ${SOURCE_CHECKOUT_DIR}`],
662
+ `bun install + build the release dist \u2192 ${SOURCE_DIST_DIR} (compiles the dashboard)`
663
+ );
664
+ }
665
+ if (opts.managedEdge) {
666
+ plan.runs.push(
667
+ "install OpenResty + issue a Let's Encrypt certificate on this box (--managed-edge)"
668
+ );
669
+ }
670
+ if (attached) {
671
+ plan.runs.push("run the API + dashboard attached to this terminal until Ctrl-C");
672
+ }
673
+ }
674
+ return plan;
675
+ }
676
+ function renderUpPlan(plan) {
677
+ const L = [];
678
+ const head = (s) => L.push("", chalk.bold(` ${s}`));
679
+ const item = (s) => L.push(chalk.dim(` ${s}`));
680
+ L.push(
681
+ "",
682
+ chalk.cyan(" Dry run \u2014 nothing on this machine has been changed."),
683
+ chalk.dim(` ${methodLabel(plan)} \xB7 ${plan.methodReason}`)
684
+ );
685
+ head("Docker");
686
+ const { docker } = plan;
687
+ if (!docker.gap) {
688
+ item("already installed and usable \u2014 nothing to install.");
689
+ } else if (docker.wouldInstall) {
690
+ item(chalk.yellow(`${docker.gap.summary}. A real run WOULD install it:`));
691
+ item(` ${docker.installCommand}`);
692
+ item("~150 MB of packages, and it enables the docker service on this box.");
693
+ if (plan.method === "compose") item("`openship up --bare` installs without Docker (embedded database).");
694
+ } else {
695
+ item(`${docker.gap.summary} \u2014 a real run would NOT install it.`);
696
+ if (docker.gap.hint) item(docker.gap.hint);
697
+ }
698
+ head("Ports");
699
+ item(
700
+ `API ${plan.ports.api} \xB7 dashboard ${plan.ports.dashboard}` + (plan.ports.switched.api || plan.ports.switched.dashboard ? ` (preferred ${plan.ports.preferred.api}/${plan.ports.preferred.dashboard} \u2014 busy right now)` : "")
701
+ );
702
+ if (plan.publicUrl) item(`public URL: ${plan.publicUrl}`);
703
+ if (plan.compose) {
704
+ head("Compose stack");
705
+ const { existing, newSecrets, secretRotation, pgDataRisk } = plan.compose;
706
+ item(
707
+ !existing ? "fresh install" : newSecrets.length === 0 ? "re-runs an existing install (secrets preserved)" : chalk.yellow(
708
+ `re-runs an existing install, but its .env no longer holds ${newSecrets.join(", ")} \u2014 a real run would GENERATE ${newSecrets.length > 1 ? "those" : "that"}, not preserve ${newSecrets.length > 1 ? "them" : "it"}.`
709
+ )
710
+ );
711
+ for (const { key, value } of plan.compose.settings) item(`${key}=${value}`);
712
+ if (secretRotation) {
713
+ L.push(chalk.yellow(renderSecretRotationRefusal(secretRotation, { dryRun: true })));
714
+ }
715
+ if (pgDataRisk) {
716
+ L.push(chalk.yellow(renderPgDataRefusal(pgDataRisk, { dryRun: true })));
717
+ }
718
+ }
719
+ if (plan.edge) {
720
+ head("Ports 80/443");
721
+ if (!plan.edge.blocked) {
722
+ item("free (or already Openship's edge) \u2014 the edge container binds them.");
723
+ } else {
724
+ item(chalk.yellow(`held by ${plan.edge.owner}.`));
725
+ item(
726
+ plan.edge.sites.length ? `A real run would ask before touching it: migrate its ${plan.edge.sites.length} site(s) into Openship's edge, take over (its sites stop serving), or cancel.` : "A real run would ask before stopping it (--edge=migrate|takeover|cancel)."
727
+ );
728
+ for (const site of plan.edge.sites.slice(0, 8)) {
729
+ item(` \u2022 ${site.serverNames.join(", ") || "(no server_name)"}${site.ssl ? " [TLS]" : ""}`);
730
+ }
731
+ if (plan.edge.sites.length > 8) item(` \u2022 \u2026and ${plan.edge.sites.length - 8} more`);
732
+ }
733
+ }
734
+ head("Would write");
735
+ for (const w of plan.writes) item(w);
736
+ if (plan.runs.length) {
737
+ head("Would run");
738
+ for (const r of plan.runs) item(r);
739
+ }
740
+ if (plan.service) {
741
+ head(`${plan.service.kind} definition (${plan.service.path})`);
742
+ L.push(plan.service.content.replace(/\n$/, ""));
743
+ }
744
+ if (plan.compose) {
745
+ head(`docker-compose.yml (${plan.compose.composeFile})`);
746
+ L.push(plan.compose.yaml.replace(/\n$/, ""));
747
+ }
748
+ L.push("", chalk.dim(" Re-run without --dry-run to apply this."), "");
749
+ return L.join("\n");
750
+ }
751
+ function methodLabel(plan) {
752
+ if (plan.mode === "from-source") return "Build from source and run attached";
753
+ if (plan.mode === "foreground") return "Run attached in this terminal";
754
+ return plan.method === "compose" ? "Install the Docker Compose stack" : `Install the ${serviceWord(plan.service?.kind)} service`;
755
+ }
756
+ function serviceWord(kind) {
757
+ switch (kind) {
758
+ case "launchd":
759
+ return "launchd";
760
+ case "systemd-user":
761
+ return "systemd (--user)";
762
+ case "systemd-system":
763
+ return "systemd (system)";
764
+ case "schtasks":
765
+ return "Scheduled Task";
766
+ default:
767
+ return "process";
768
+ }
769
+ }
770
+
771
+ // src/lib/loopback-api.ts
772
+ import { existsSync as existsSync4, readFileSync as readFileSync3, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
773
+ import { randomBytes } from "crypto";
774
+ function ensureInternalToken() {
775
+ const path = INTERNAL_TOKEN_FILE;
776
+ if (existsSync4(path)) return readFileSync3(path, "utf8").trim();
777
+ mkdirSync5(OS_DIR, { recursive: true, mode: 448 });
778
+ const token = randomBytes(32).toString("hex");
779
+ writeFileSync4(path, token, { mode: 384 });
780
+ return token;
781
+ }
782
+ async function internalGet(port, path, token) {
783
+ try {
784
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, {
785
+ headers: { "X-Internal-Token": token ?? ensureInternalToken() },
786
+ signal: AbortSignal.timeout(1e4)
787
+ });
788
+ if (!res.ok) return null;
789
+ return await res.json();
790
+ } catch {
791
+ return null;
792
+ }
793
+ }
794
+ async function internalPost(port, path, body, token) {
795
+ try {
796
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, {
797
+ method: "POST",
798
+ headers: { "Content-Type": "application/json", "X-Internal-Token": token ?? ensureInternalToken() },
799
+ body: JSON.stringify(body),
800
+ signal: AbortSignal.timeout(3e4)
801
+ });
802
+ const data = await res.json().catch(() => ({}));
803
+ return { ok: res.ok, data };
804
+ } catch (err) {
805
+ return { ok: false, data: { error: err.message } };
806
+ }
807
+ }
808
+ async function bootstrapAdmin(apiPort, admin, token) {
809
+ const { ok, data } = await internalPost(apiPort, "/api/system/bootstrap-admin", admin, token);
810
+ if (ok) return { ok: true };
811
+ if (data?.error === "An admin account already exists") return { ok: true, message: "already-exists" };
812
+ return { ok: false, message: data?.error || "failed" };
813
+ }
814
+ async function waitHealthy(apiPort, seconds = 90) {
815
+ for (let i = 0; i < seconds; i++) {
816
+ await new Promise((r) => setTimeout(r, 1e3));
817
+ try {
818
+ await fetch(`http://127.0.0.1:${apiPort}/api/health`, { signal: AbortSignal.timeout(2e3) });
819
+ return true;
820
+ } catch {
821
+ }
822
+ }
823
+ return false;
824
+ }
825
+ async function waitDashboard(dashPort, seconds = 45) {
826
+ for (let i = 0; i < seconds; i++) {
827
+ await new Promise((r) => setTimeout(r, 1e3));
828
+ try {
829
+ const res = await fetch(`http://127.0.0.1:${dashPort}/`, {
830
+ redirect: "manual",
831
+ signal: AbortSignal.timeout(2e3)
832
+ });
833
+ if (res.status > 0) return true;
834
+ } catch {
835
+ }
836
+ }
837
+ return false;
838
+ }
839
+ async function detectPublicIp() {
840
+ for (const url of ["https://api.ipify.org", "https://ifconfig.me/ip"]) {
841
+ try {
842
+ const res = await fetch(url, { signal: AbortSignal.timeout(3e3) });
843
+ if (!res.ok) continue;
844
+ const ip = (await res.text()).trim();
845
+ if (/^[0-9.]+$/.test(ip) || ip.includes(":")) return ip;
846
+ } catch {
847
+ }
848
+ }
849
+ return null;
850
+ }
851
+
852
+ // src/lib/instance-provision.ts
853
+ var HeadlessInputError = class extends Error {
854
+ code = "HEADLESS_INPUT";
855
+ constructor(message) {
856
+ super(message);
857
+ this.name = "HeadlessInputError";
858
+ }
859
+ };
860
+ var SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
861
+ function hostOf(value) {
862
+ const raw = value?.trim();
863
+ if (!raw) return void 0;
864
+ try {
865
+ if (raw.includes("://")) return new URL(raw).hostname || void 0;
866
+ } catch {
867
+ }
868
+ return raw.replace(/^\/+/, "").split("/")[0]?.split(":")[0] || void 0;
869
+ }
870
+ function resolveInstallInputs(flags) {
871
+ const email = flags.adminEmail?.trim();
872
+ const password = flags.adminPassword ?? process.env.OPENSHIP_ADMIN_PASSWORD;
873
+ const name = flags.adminName?.trim() || (email ? email.split("@")[0] : "");
874
+ if (!email || !isValidEmail(email)) {
875
+ throw new HeadlessInputError("Missing/invalid --admin-email for a non-interactive install.");
876
+ }
877
+ if (!password || password.length < 8) {
878
+ throw new HeadlessInputError(
879
+ "Missing --admin-password (or OPENSHIP_ADMIN_PASSWORD env), min 8 chars, for a non-interactive install."
880
+ );
881
+ }
882
+ const admin = { name: name || "Admin", email, password };
883
+ const kindRaw = flags.domainKind?.trim().toLowerCase() || (flags.publicUrl ? "byo" : "none");
884
+ const hostname = flags.hostname?.trim() || hostOf(flags.publicUrl);
885
+ switch (kindRaw) {
886
+ case "none":
887
+ return { admin, domain: { kind: "none" } };
888
+ case "byo":
889
+ return { admin, domain: { kind: "byo", hostname } };
890
+ case "custom": {
891
+ if (!hostname) throw new HeadlessInputError("--domain-kind custom requires --hostname (or --public-url).");
892
+ const edge = flags.edge?.trim().toLowerCase() || "cancel";
893
+ if (!["migrate", "takeover", "cancel"].includes(edge)) {
894
+ throw new HeadlessInputError(`Invalid --edge "${flags.edge}" (expected migrate | takeover | cancel).`);
895
+ }
896
+ const acmeEmail = flags.acmeEmail?.trim() || email;
897
+ if (!isValidEmail(acmeEmail)) {
898
+ throw new HeadlessInputError(`Invalid --acme-email "${acmeEmail}" \u2014 Let's Encrypt rejects a malformed contact.`);
899
+ }
900
+ return { admin, domain: { kind: "custom", hostname, acmeEmail, edge } };
901
+ }
902
+ case "free": {
903
+ const slug = (flags.slug?.trim() || (hostname ? hostname.split(".")[0] : "")).toLowerCase();
904
+ if (!slug || !SLUG_RE.test(slug)) {
905
+ throw new HeadlessInputError("--domain-kind free requires a valid --slug (lowercase letters, digits, hyphens).");
906
+ }
907
+ return { admin, domain: { kind: "free", slug, publicHost: hostOf(flags.publicUrl) } };
908
+ }
909
+ default:
910
+ throw new HeadlessInputError(`Invalid --domain-kind "${flags.domainKind}" (expected byo | custom | free | none).`);
911
+ }
912
+ }
913
+ async function bootstrapOrReset(port, admin, token) {
914
+ const boot = await bootstrapAdmin(port, admin, token);
915
+ if (boot.ok && boot.message !== "already-exists") return { ok: true };
916
+ if (boot.ok && boot.message === "already-exists") {
917
+ const rr = await internalPost(
918
+ port,
919
+ "/api/system/reset-admin-password",
920
+ { password: admin.password, email: admin.email, name: admin.name },
921
+ token
922
+ );
923
+ return rr.ok ? { ok: true, message: "reset-existing" } : { ok: false, message: rr.data?.error || "reset failed" };
924
+ }
925
+ return { ok: false, message: boot.message || "bootstrap failed" };
926
+ }
927
+ async function drainProvisionStream(port, sessionId, token, onLog) {
928
+ let detail;
929
+ try {
930
+ const res = await fetch(`http://127.0.0.1:${port}/api/system/self-register/stream?id=${sessionId}`, {
931
+ headers: { "X-Internal-Token": token ?? ensureInternalToken() },
932
+ signal: AbortSignal.timeout(18e4)
933
+ });
934
+ if (!res.body) return { completed: false };
935
+ const reader = res.body.getReader();
936
+ const decoder = new TextDecoder();
937
+ let buffer = "";
938
+ for (; ; ) {
939
+ const { value, done } = await reader.read();
940
+ if (done) break;
941
+ buffer += decoder.decode(value, { stream: true });
942
+ let sep;
943
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
944
+ const frame = buffer.slice(0, sep);
945
+ buffer = buffer.slice(sep + 2);
946
+ const event = /event:\s*(.*)/.exec(frame)?.[1]?.trim();
947
+ const dataRaw = /data:\s*([\s\S]*)/.exec(frame)?.[1]?.trim();
948
+ if (!event || !dataRaw) continue;
949
+ try {
950
+ const d = JSON.parse(dataRaw);
951
+ if (event === "log" && d.message) {
952
+ const msg = String(d.message).replace(/\s+/g, " ");
953
+ onLog?.(msg);
954
+ if (d.level === "warn" || d.level === "error") detail = msg;
955
+ } else if (event === "complete") {
956
+ return { completed: d.status === "completed", detail };
957
+ } else if (event === "end") {
958
+ return { completed: true, detail };
959
+ }
960
+ } catch {
961
+ }
962
+ }
963
+ }
964
+ } catch {
965
+ }
966
+ return { completed: false, detail };
967
+ }
968
+ function isSlugTaken(data) {
969
+ const body = data;
970
+ if (body?.code === "slug_taken") return true;
971
+ return /already taken|slug_taken/i.test(body?.error ?? "");
972
+ }
973
+ async function headlessProvision(opts) {
974
+ const { port, inputs, token } = opts;
975
+ const log = opts.onLog ?? (() => {
976
+ });
977
+ const warnings = [];
978
+ log("Waiting for the API to become healthy\u2026");
979
+ if (!await waitHealthy(port)) {
980
+ throw new HeadlessInputError("The API did not become healthy in time \u2014 check `openship logs`.");
981
+ }
982
+ log("Creating the admin account\u2026");
983
+ const admin = await bootstrapOrReset(port, inputs.admin, token);
984
+ if (!admin.ok) throw new HeadlessInputError(`Could not create the admin account: ${admin.message}`);
985
+ const dashPort = opts.dashPort ? Number(opts.dashPort) : void 0;
986
+ const d = inputs.domain;
987
+ let liveUrl;
988
+ let domainRegistered = false;
989
+ log(`Registering domain (${d.kind})\u2026`);
990
+ if (d.kind === "none") {
991
+ await internalPost(port, "/api/system/self-register", { domainType: "byo" }, token);
992
+ domainRegistered = true;
993
+ } else if (d.kind === "byo") {
994
+ const res = await internalPost(
995
+ port,
996
+ "/api/system/self-register",
997
+ { domainType: "byo", ...d.hostname ? { hostname: d.hostname } : {} },
998
+ token
999
+ );
1000
+ domainRegistered = res.ok;
1001
+ liveUrl = res.data?.url ?? (d.hostname ? `https://${d.hostname}` : void 0);
1002
+ if (!res.ok) warnings.push(`Domain registration returned: ${res.data?.error || "failed"}`);
1003
+ } else if (d.kind === "custom") {
1004
+ const res = await internalPost(
1005
+ port,
1006
+ "/api/system/self-register",
1007
+ {
1008
+ domainType: "custom",
1009
+ hostname: d.hostname,
1010
+ dashPort,
1011
+ acmeEmail: d.acmeEmail,
1012
+ edgeTakeover: d.edge === "takeover",
1013
+ edgeMigrate: d.edge === "migrate"
1014
+ },
1015
+ token
1016
+ );
1017
+ domainRegistered = res.ok;
1018
+ liveUrl = res.data?.url ?? `https://${d.hostname}`;
1019
+ if (res.ok && res.data?.sessionId) {
1020
+ log("Issuing HTTPS certificate (Let's Encrypt) \u2014 best-effort\u2026");
1021
+ const cert = await drainProvisionStream(
1022
+ port,
1023
+ String(res.data.sessionId),
1024
+ token,
1025
+ (m) => log(` ${m}`)
1026
+ );
1027
+ if (!cert.completed) {
1028
+ warnings.push(
1029
+ cert.detail ? `HTTPS certificate not issued yet: ${cert.detail}` : "HTTPS certificate not issued yet \u2014 it retries in the background and on the next boot."
1030
+ );
1031
+ }
1032
+ } else if (!res.ok) {
1033
+ warnings.push(`Custom domain provisioning returned: ${res.data?.error || "failed"}`);
1034
+ }
1035
+ } else {
1036
+ const freeBody = (s) => ({
1037
+ domainType: "free",
1038
+ slug: s,
1039
+ publicHost: d.publicHost,
1040
+ dashPort,
1041
+ localEdge: d.localEdge,
1042
+ edgeTakeover: d.edgeTakeover,
1043
+ edgeMigrate: d.edgeMigrate,
1044
+ ...d.staticRootOverrides ? { staticRootOverrides: d.staticRootOverrides } : {}
1045
+ });
1046
+ let slug = d.slug;
1047
+ let res = await internalPost(port, "/api/system/self-register", freeBody(slug), token);
1048
+ for (let attempt = 0; !res.ok && isSlugTaken(res.data) && opts.onSlugTaken && attempt < 5; attempt++) {
1049
+ const next = await opts.onSlugTaken(slug);
1050
+ if (!next) break;
1051
+ slug = next;
1052
+ res = await internalPost(port, "/api/system/self-register", freeBody(slug), token);
1053
+ }
1054
+ domainRegistered = res.ok;
1055
+ liveUrl = res.data?.url;
1056
+ if (res.ok && res.data?.sessionId) {
1057
+ const edge = await drainProvisionStream(port, String(res.data.sessionId), token, (m) => log(` ${m}`));
1058
+ if (!edge.completed && edge.detail) {
1059
+ warnings.push(`Local edge not fully ready: ${edge.detail} \u2014 it retries on next boot.`);
1060
+ }
1061
+ }
1062
+ if (!res.ok) {
1063
+ warnings.push(
1064
+ isSlugTaken(res.data) ? `Free domain not created: "${slug}.opsh.io" is already taken. Re-run and choose a different subdomain, or add one later in Settings \u2192 Domains.` : `Free .opsh.io domain not registered: ${res.data?.error || "failed"}. A free domain needs the box connected to Openship Cloud \u2014 connect it in Settings \u2192 Cloud, or use --domain-kind byo/custom.`
1065
+ );
1066
+ }
1067
+ }
1068
+ return { adminReady: true, domainRegistered, liveUrl, warnings };
1069
+ }
1070
+
1071
+ // src/commands/up.ts
1072
+ var EDGE_ACTIONS = ["migrate", "takeover", "cancel"];
1073
+ function normalizeUrl(raw) {
1074
+ const value = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
1075
+ try {
1076
+ const u = new URL(value);
1077
+ return `${u.protocol}//${u.host}`;
1078
+ } catch {
1079
+ return null;
1080
+ }
1081
+ }
1082
+ function effectivePublicUrl(flag) {
1083
+ if (flag) return normalizePublicUrl(flag);
1084
+ const saved = readInstanceUrl();
1085
+ if (!saved || /^https?:\/\/(localhost|127\.|\[?::1\]?)([:/]|$)/i.test(saved)) return void 0;
1086
+ return normalizePublicUrl(saved);
1087
+ }
1088
+ function normalizePublicUrl(raw) {
1089
+ const url = normalizeUrl(raw);
1090
+ if (!url) {
1091
+ console.error(
1092
+ chalk2.red(`
1093
+ Invalid --public-url: ${raw}`) + chalk2.dim("\n Expected something like https://ops.example.com\n")
1094
+ );
1095
+ process.exit(1);
1096
+ }
1097
+ return url;
1098
+ }
1099
+ var DIST_DIR = dirname2(fileURLToPath(import.meta.url));
1100
+ var SERVER_DIR = join5(DIST_DIR, "server");
1101
+ function ensureAuthSecret() {
1102
+ const path = AUTH_SECRET_FILE;
1103
+ if (existsSync5(path)) return readFileSync4(path, "utf8").trim();
1104
+ mkdirSync6(OS_DIR, { recursive: true, mode: 448 });
1105
+ const secret = randomBytes2(32).toString("hex");
1106
+ writeFileSync5(path, secret, { mode: 384 });
1107
+ return secret;
1108
+ }
1109
+ var upCommand = new Command("up").description("Start Openship as a persistent service (boot + auto-restart); --foreground to run attached").option("--port <port>", "API port to listen on (default: 4000, or the next free port if it's taken)").option("--data-dir <dir>", "Directory for the embedded database").option("--dashboard-port <port>", "Dashboard port (default: 3001, or the next free port if it's taken)").option("--no-ui", "Run the API only \u2014 don't download/serve the dashboard").option("--ui-version <tag>", "Dashboard release tag to run (default: this CLI's version)").option(
1110
+ "--image-version <tag>",
1111
+ "Compose mode: image tag to pull for api/dashboard/edge (default: this CLI's version, or the OPENSHIP_VERSION env var). Use a known-good tag if a release's images aren't in the registry yet."
1112
+ ).option("-f, --foreground", "Run attached in this terminal instead of as a background service").option(
1113
+ "--dry-run",
1114
+ "Preview only: print what `up` would install (method, ports, files, commands, the service definition / compose file) and exit WITHOUT changing this machine"
1115
+ ).option(
1116
+ "--public-url <url>",
1117
+ "Serve remotely at this public URL (VPS): binds the dashboard to all interfaces, proxies the API same-origin, and requires login"
1118
+ ).option(
1119
+ "--trust-proxy",
1120
+ "Trust the X-Real-IP set by a reverse proxy in front (the proxy MUST overwrite X-Real-IP with the real client IP, e.g. `proxy_set_header X-Real-IP $remote_addr`, and the app port MUST be firewalled so only the proxy can reach it; enables per-client rate limiting)"
1121
+ ).option(
1122
+ "--host <addr>",
1123
+ "Bind the dashboard to this interface so an upstream reverse proxy (or another LAN host) can reach it \u2014 e.g. 0.0.0.0 or a LAN IP like 192.168.1.50. Default 127.0.0.1. The API stays on loopback (the dashboard proxies to it). A concrete IP auto-trusts that browser origin for login; for 0.0.0.0 or a domain also pass --public-url (or set OPENSHIP_EXTRA_TRUSTED_ORIGINS) so login isn't rejected."
1124
+ ).option(
1125
+ "--managed-edge",
1126
+ "Managed edge: install OpenResty + a free Let's Encrypt cert on this box and route --public-url's domain to the dashboard (no reverse proxy needed)"
1127
+ ).option("--acme-email <email>", "Contact email for Let's Encrypt certificates (managed edge)").option("--from-source", "Preview: build + run Openship from source (a branch) instead of a published release \u2014 runs attached").option("--ref <branch>", "Git branch/tag/sha to build with --from-source (default: main)").option("--source <path>", "Build from an existing local Openship checkout instead of cloning").option("--repo <url>", "Git remote to clone for --from-source (default: oblien/openship)").option("--compose", "Install via Docker Compose using the published images (postgres + redis + api + dashboard + edge on :80/:443). Default when Docker is available on Linux.").option("--bare", "Install as the bare process service (embedded DB, no Docker) instead of Compose").option(
1128
+ "--mail",
1129
+ "Install as Openship Mail: the dashboard opens on the mail control plane (mail servers, domains, mailboxes) instead of the full platform. Same install, same binary \u2014 a default shell, not a restriction. Anyone can switch back under Settings \u2192 General, and an admin can change the box-wide default under Settings \u2192 Instance."
1130
+ ).option(
1131
+ "--no-host-control",
1132
+ "Harden: don't give the control plane a channel to this machine's OS. No host SSH key is generated or mounted, host operations refuse, and this box stops being offered as a deploy target. Recommended when this box only manages REMOTE servers \u2014 it loses :80/:443 takeover, the host terminal and host port scans. The Docker socket is still mounted (deployments need it), so this is defense in depth, not isolation."
1133
+ ).option(
1134
+ "--host-ssh-host <addr>",
1135
+ "Compose mode: address the api container dials for host operations (default host.docker.internal, mapped to the docker host-gateway). Set this to the box's own LAN/bridge address when host-gateway doesn't reach the host \u2014 notably under rootless Docker. Preserved across re-runs."
1136
+ ).option(
1137
+ "--host-ssh-port <port>",
1138
+ "Compose mode: port the host's sshd listens on for host operations (default 22). Preserved across re-runs."
1139
+ ).option(
1140
+ "--open-host-firewall",
1141
+ "Compose mode: if the api container can't reach this machine's SSH port, add the host firewall rule that allows it (scoped to the container subnet, ufw/firewalld only). Without this the blocked channel is reported and left alone. Not implied by --yes."
1142
+ ).option("--edge <action>", "Compose mode: how to handle an existing proxy on :80/:443 \u2014 'migrate' (import its sites into Openship's edge), 'takeover' (stop it; its sites stop serving), or 'cancel'. Default: prompt when interactive, else cancel.").option(
1143
+ "--reset-secrets",
1144
+ "Compose mode: generate new secrets even though this install's data volume still exists. `up` normally refuses, because the database keeps the password it was created with and every stored environment variable was encrypted with the old BETTER_AUTH_SECRET \u2014 the password gets realigned, those variables do not. Use only when the original .env is genuinely gone."
1145
+ ).option("--non-interactive", "Headless install: after the service starts, create the admin + register the domain from the flags below (no prompts). Alias: --yes.").option("--yes", "Alias for --non-interactive.").option("--admin-name <name>", "Admin display name (headless install)").option("--admin-email <email>", "Admin email \u2014 required for a headless install").option("--admin-password <password>", "Admin password (min 8). Prefer the OPENSHIP_ADMIN_PASSWORD env var to keep it out of shell history.").option("--domain-kind <kind>", "Headless install domain: byo | custom | free | none (default: byo if --public-url set, else none)").option("--hostname <host>", "Domain/hostname for --domain-kind byo|custom (or derived from --public-url)").option("--slug <slug>", "Free .opsh.io subdomain for --domain-kind free (box must already be Cloud-connected)").action(async (opts) => {
1146
+ if (opts.dryRun) {
1147
+ console.log(renderUpPlan(await planUp({ ...opts, publicUrl: effectivePublicUrl(opts.publicUrl) })));
1148
+ return;
1149
+ }
1150
+ if (opts.fromSource || opts.source) return runFromSource(opts);
1151
+ if (opts.foreground) return runForeground(opts);
1152
+ const headless = !!(opts.nonInteractive || opts.yes);
1153
+ const needsDocker = !opts.bare && (Boolean(opts.compose) || process.platform === "linux");
1154
+ const dockerUsable = needsDocker ? await ensureDocker() : false;
1155
+ const { method } = planMethod(opts, dockerUsable);
1156
+ if (method === "compose" && !dockerUsable) {
1157
+ console.error(
1158
+ chalk2.red("\n --compose needs Docker + docker compose, and they couldn't be installed automatically.") + chalk2.dim(
1159
+ "\n Install Docker (https://docs.docker.com/engine/install/) and re-run, or use --bare.\n"
1160
+ )
1161
+ );
1162
+ process.exit(1);
1163
+ }
1164
+ if (method === "compose") {
1165
+ const started2 = await runCompose(opts);
1166
+ if (headless) {
1167
+ const token = composeInternalToken();
1168
+ if (!token) {
1169
+ console.warn(
1170
+ chalk2.yellow(
1171
+ "\n Couldn't read the stack's internal token (compose/.env) \u2014 create the admin from the dashboard.\n"
1172
+ )
1173
+ );
1174
+ } else {
1175
+ await runHeadlessProvision(
1176
+ opts,
1177
+ { port: started2.apiPort, dashPort: started2.dashPort },
1178
+ { token, method: "compose" }
1179
+ );
1180
+ }
1181
+ }
1182
+ return;
1183
+ }
1184
+ const started = await startService(opts);
1185
+ if (headless) await runHeadlessProvision(opts, started, { method: "bare" });
1186
+ });
1187
+ async function runHeadlessProvision(opts, started, extra) {
1188
+ let inputs;
1189
+ try {
1190
+ inputs = resolveInstallInputs({
1191
+ adminName: opts.adminName,
1192
+ adminEmail: opts.adminEmail,
1193
+ adminPassword: opts.adminPassword,
1194
+ domainKind: opts.domainKind,
1195
+ hostname: opts.hostname,
1196
+ slug: opts.slug,
1197
+ publicUrl: opts.publicUrl,
1198
+ acmeEmail: opts.acmeEmail,
1199
+ edge: opts.edge
1200
+ });
1201
+ } catch (err) {
1202
+ if (err instanceof HeadlessInputError) {
1203
+ console.error(chalk2.red(`
1204
+ ${err.message}
1205
+ `));
1206
+ process.exit(1);
1207
+ }
1208
+ throw err;
1209
+ }
1210
+ try {
1211
+ const result = await headlessProvision({
1212
+ port: started.port,
1213
+ dashPort: started.dashPort,
1214
+ inputs,
1215
+ token: extra?.token,
1216
+ method: extra?.method,
1217
+ onLog: (m) => console.log(chalk2.dim(` ${m}`))
1218
+ });
1219
+ console.log(chalk2.green(`
1220
+ \u2713 Openship provisioned${result.liveUrl ? `: ${result.liveUrl}` : "."}`));
1221
+ for (const w of result.warnings) console.warn(chalk2.yellow(` \u26A0 ${w}`));
1222
+ } catch (err) {
1223
+ console.error(chalk2.red(`
1224
+ Headless provisioning failed: ${err.message}
1225
+ `));
1226
+ process.exit(1);
1227
+ }
1228
+ }
1229
+ async function runCompose(opts) {
1230
+ const headless = !!(opts.nonInteractive || opts.yes);
1231
+ if (!hasDockerCompose()) {
1232
+ console.error(
1233
+ chalk2.red("\n Docker + `docker compose` are required for the Compose install.\n") + chalk2.dim(" Install Docker, or run `openship up --bare` for the process mode.\n")
1234
+ );
1235
+ process.exit(1);
1236
+ }
1237
+ if (opts.edge && !EDGE_ACTIONS.includes(opts.edge)) {
1238
+ console.error(
1239
+ chalk2.red(`
1240
+ Invalid --edge value: ${opts.edge}`) + chalk2.dim(`
1241
+ Expected one of: ${EDGE_ACTIONS.join(", ")}
1242
+ `)
1243
+ );
1244
+ process.exit(1);
1245
+ }
1246
+ const publicUrl = effectivePublicUrl(opts.publicUrl);
1247
+ const ports = await resolveComposePorts({ api: opts.port, dashboard: opts.dashboardPort });
1248
+ const apiPort = String(ports.api);
1249
+ const dashboardPort = String(ports.dashboard);
1250
+ const portNotice = portMoveNotice(ports, [...composeTrustedOriginUrls(), publicUrl]);
1251
+ if (portNotice.length) {
1252
+ console.log("\n" + portNotice.map((l) => chalk2.yellow(` ${l}`)).join("\n"));
1253
+ }
1254
+ if (!pinnedImagesReady({ version: opts.imageVersion })) process.exit(1);
1255
+ const rotation = composeSecretRotationRisk({ resetSecrets: opts.resetSecrets });
1256
+ if (rotation) {
1257
+ console.error(chalk2.yellow(renderSecretRotationRefusal(rotation)));
1258
+ process.exit(1);
1259
+ }
1260
+ const pgData = composePgDataRisk({ resetSecrets: opts.resetSecrets });
1261
+ if (pgData) {
1262
+ console.error(chalk2.yellow(renderPgDataRefusal(pgData)));
1263
+ process.exit(1);
1264
+ }
1265
+ const prefetch = ora(
1266
+ sourceBuildDir() ? "Building images before touching :80/:443\u2026" : "Pulling images before touching :80/:443\u2026"
1267
+ ).start();
1268
+ const fetched = composePrefetch({
1269
+ apiPort,
1270
+ dashboardPort,
1271
+ publicUrl,
1272
+ version: opts.imageVersion,
1273
+ trustProxy: opts.trustProxy,
1274
+ noHostControl: opts.hostControl === false ? true : void 0,
1275
+ // The prefetch writes the same `.env` `composeUp` will, so every knob has to be
1276
+ // given to BOTH. A knob passed only to `composeUp` would change `.env` after the
1277
+ // recreate decision was already taken here (see ComposePrefetchResult.envChanged),
1278
+ // so the containers would keep an environment the file no longer shows.
1279
+ hostSshHost: opts.hostSshHost,
1280
+ hostSshPort: opts.hostSshPort,
1281
+ resetSecrets: opts.resetSecrets,
1282
+ mail: opts.mail
1283
+ });
1284
+ if (!fetched.ok) {
1285
+ prefetch.fail("Couldn't fetch the stack's images \u2014 nothing was changed on this box.");
1286
+ console.error(
1287
+ chalk2.dim("\n Your current proxy is untouched and still serving. Fix the pull/build error above and re-run.\n")
1288
+ );
1289
+ process.exit(1);
1290
+ }
1291
+ prefetch.succeed("Images ready \u2014 nothing on :80/:443 has changed yet.");
1292
+ let edgePlan;
1293
+ try {
1294
+ edgePlan = await planAndApplyHostEdge({ edge: opts.edge });
1295
+ } catch (e) {
1296
+ console.error(
1297
+ chalk2.red(`
1298
+ Edge preflight failed: ${e.message}
1299
+ `) + chalk2.dim(" Re-run, or pass --edge=cancel to skip taking over :80/:443.\n")
1300
+ );
1301
+ process.exit(1);
1302
+ }
1303
+ if (!edgePlan.proceed) {
1304
+ console.log(
1305
+ edgePlan.blockedBy ? chalk2.red(`
1306
+ Can't take over the edge: ${edgePlan.blockedBy}.
1307
+ `) + chalk2.dim(" Find what else is bound to those ports (`ss -ltnp | grep -E ':80|:443'`), then re-run.\n") : chalk2.yellow("\n Left the existing proxy on :80/:443 running \u2014 not starting the stack.\n") + chalk2.dim(" Re-run and choose migrate / take-over (or pass --edge=migrate|takeover) when ready.\n")
1308
+ );
1309
+ process.exit(1);
1310
+ }
1311
+ const fromSource = sourceBuildDir();
1312
+ const spinner = ora(
1313
+ fromSource ? `Building Openship from ${fromSource} and starting the stack\u2026` : "Starting Openship via Docker Compose\u2026"
1314
+ ).start();
1315
+ const res = await composeUp({
1316
+ // Prefetched above, before the preflight stopped anything.
1317
+ alreadyFetched: true,
1318
+ // ...which also means the prefetch's materialize is the only one that could still
1319
+ // see what the running containers were created with. Passing its verdict is what
1320
+ // makes a changed `.env` reach them (see ComposePrefetchResult.envChanged).
1321
+ envChanged: fetched.envChanged,
1322
+ apiPort,
1323
+ dashboardPort,
1324
+ publicUrl,
1325
+ version: opts.imageVersion,
1326
+ trustProxy: opts.trustProxy,
1327
+ // commander maps `--no-host-control` to hostControl === false. `undefined` when
1328
+ // the flag is absent, so a plain re-run keeps the install's original choice
1329
+ // instead of silently re-granting host control (see resolveEnvConfig).
1330
+ noHostControl: opts.hostControl === false ? true : void 0,
1331
+ // Absent on a plain re-run, so `keepConfig` carries whatever the install was
1332
+ // configured with rather than resetting it to host.docker.internal:22.
1333
+ hostSshHost: opts.hostSshHost,
1334
+ hostSshPort: opts.hostSshPort,
1335
+ resetSecrets: opts.resetSecrets,
1336
+ // Absent on a re-run keeps the install's mode (resolveEnvConfig), so this
1337
+ // never flips a mail box back to the platform shell.
1338
+ mail: opts.mail
1339
+ });
1340
+ if (!res.ok) {
1341
+ spinner.fail("docker compose failed to start the stack");
1342
+ const restored = edgePlan.action ? await rollbackHostEdge() : false;
1343
+ console.error(
1344
+ chalk2.dim("\n Check `docker compose -f ~/.openship/compose/docker-compose.yml logs`.\n") + (restored ? chalk2.yellow(" Restored the previous proxy on :80/:443 \u2014 your sites are serving again.\n") : chalk2.dim(" If ports 80/443 are held by another proxy, re-run \u2014 the preflight will offer to migrate or take over.\n"))
1345
+ );
1346
+ process.exit(1);
1347
+ }
1348
+ spinner.succeed("Openship is running via Docker Compose.");
1349
+ if (edgePlan.action && await edgeIsBroken(new LocalExecutor())) {
1350
+ const reason = await edgeCrashReason(new LocalExecutor());
1351
+ console.error(
1352
+ chalk2.red(`
1353
+ Openship's edge container is not running${reason ? ` \u2014 ${reason}` : "."}`)
1354
+ );
1355
+ const restored = await rollbackHostEdge();
1356
+ const back = startUnitHint("nginx");
1357
+ console.error(
1358
+ restored ? chalk2.yellow(
1359
+ ` Your previous proxy has been restarted \u2014 the box is serving again, on it.
1360
+ Nothing was migrated. Fix the cause above, then re-run \`openship up\`.
1361
+ `
1362
+ ) : chalk2.red(
1363
+ ` AND your previous proxy could NOT be restarted automatically \u2014 the box is
1364
+ serving nothing right now. Start it by hand:
1365
+ ` + (back ? ` docker stop ${EDGE_CONTAINER_NAME} && ${back}
1366
+ ` : ` stop ${EDGE_CONTAINER_NAME} and start your proxy the way this host starts services
1367
+ `)
1368
+ )
1369
+ );
1370
+ process.exit(1);
1371
+ }
1372
+ let importedOk = true;
1373
+ if (edgePlan.action === "migrate" && edgePlan.sites?.length) {
1374
+ const outcome = await importMigratedSites(
1375
+ res.apiPort,
1376
+ edgePlan.sites,
1377
+ edgePlan.certPems,
1378
+ edgePlan.staticRootOverrides
1379
+ );
1380
+ importedOk = outcome.registered.length > 0;
1381
+ if (importedOk) markStoppedProxyImported();
1382
+ }
1383
+ if (edgePlan.action && importedOk) await completeHostEdge();
1384
+ await verifyHostChannel({ openFirewall: opts.openHostFirewall }).catch(() => {
1385
+ });
1386
+ const dashboardUrl = publicUrl ?? `http://localhost:${res.dashPort}`;
1387
+ console.log(
1388
+ chalk2.dim(` Dashboard: ${dashboardUrl} (login required)
1389
+ `) + chalk2.dim(" Images: api + dashboard + edge (OpenResty on :80/:443)\n") + chalk2.dim(" Manage: openship stop \xB7 openship update \xB7 openship status\n") + // In headless mode the admin is bootstrapped below — don't tell the user to do it by hand.
1390
+ (headless ? "" : chalk2.dim(" Create an admin: open the dashboard and register the first account.\n"))
1391
+ );
1392
+ return { apiPort: res.apiPort, dashPort: res.dashPort };
1393
+ }
1394
+ async function runFromSource(opts) {
1395
+ console.log(chalk2.cyan("\n Building Openship from source (preview mode)\u2026"));
1396
+ console.log(
1397
+ chalk2.dim(" Unverified dev build \u2014 for previewing a branch, not production self-hosting.\n")
1398
+ );
1399
+ let src;
1400
+ try {
1401
+ src = await prepareFromSource({ ref: opts.ref, source: opts.source, repo: opts.repo });
1402
+ } catch (e) {
1403
+ console.error(
1404
+ chalk2.red(`
1405
+ Build from source failed: ${e.message}
1406
+ `) + chalk2.dim(" Small boxes can OOM on the dashboard build \u2014 build on a bigger machine and pass --source, or use a published release.\n")
1407
+ );
1408
+ process.exit(1);
1409
+ }
1410
+ console.log(chalk2.green(`
1411
+ Built ${src.ref} (${src.sha}). Starting\u2026
1412
+ `));
1413
+ await runForeground(opts, src);
1414
+ }
1415
+ async function startService(opts, runOpts = {}) {
1416
+ const publicUrl = effectivePublicUrl(opts.publicUrl);
1417
+ const resolved = await resolvePorts({
1418
+ api: opts.port ? Number(opts.port) : void 0,
1419
+ dashboard: opts.dashboardPort ? Number(opts.dashboardPort) : void 0
1420
+ });
1421
+ const port = String(resolved.api);
1422
+ const dashPort = String(resolved.dashboard);
1423
+ const flags = {
1424
+ port,
1425
+ dataDir: opts.dataDir,
1426
+ dashboardPort: dashPort,
1427
+ ui: opts.ui,
1428
+ uiVersion: opts.uiVersion,
1429
+ publicUrl,
1430
+ trustProxy: opts.trustProxy || opts.managedEdge,
1431
+ // managed edge = OpenResty sets XFF
1432
+ host: opts.host,
1433
+ managedEdge: opts.managedEdge,
1434
+ acmeEmail: opts.acmeEmail,
1435
+ mail: opts.mail
1436
+ };
1437
+ try {
1438
+ const res = installAndStart(flags);
1439
+ if (!runOpts.quiet) {
1440
+ const portNotice = portMoveNotice(resolved, [publicUrl]);
1441
+ if (portNotice.length) {
1442
+ console.log("\n" + portNotice.map((l) => chalk2.yellow(` ${l}`)).join("\n"));
1443
+ }
1444
+ const dashboardLine = publicUrl ? chalk2.dim(` Dashboard: ${publicUrl} (login required)
1445
+ `) : chalk2.dim(` Dashboard: http://localhost:${dashPort} (login required)
1446
+ `);
1447
+ console.log(
1448
+ chalk2.green("\n \u2714 Openship is running as a service.\n") + (opts.ui !== false ? dashboardLine : "") + (publicUrl ? chalk2.dim(" API is proxied through the dashboard (not exposed). Point your reverse proxy / DNS at the dashboard port.\n") : chalk2.dim(` API: http://localhost:${port}/api
1449
+ `)) + chalk2.dim(` ${res.detail}
1450
+ `) + chalk2.dim(" Starts on boot and auto-restarts. Stop with `openship stop`.\n")
1451
+ );
1452
+ }
1453
+ return { port, dashPort, publicUrl };
1454
+ } catch (e) {
1455
+ if (runOpts.quiet) throw e;
1456
+ console.error(
1457
+ chalk2.red(`
1458
+ Couldn't install the service: ${e.message}
1459
+ `) + chalk2.dim(" Run `openship up --foreground` to run it attached instead.\n")
1460
+ );
1461
+ process.exit(1);
1462
+ }
1463
+ }
1464
+ async function runForeground(opts, source) {
1465
+ let apiCmd = process.execPath;
1466
+ let apiArgs;
1467
+ let apiCwd;
1468
+ if (source) {
1469
+ apiCmd = "bun";
1470
+ apiArgs = ["run", "src/index.ts"];
1471
+ apiCwd = source.apiDir;
1472
+ } else {
1473
+ const serverEntry = join5(SERVER_DIR, "index.js");
1474
+ if (!existsSync5(serverEntry)) {
1475
+ console.error(
1476
+ chalk2.red("\n Bundled server not found in this install.") + chalk2.dim("\n Reinstall with `openship update` (or `npm i -g openship`).\n")
1477
+ );
1478
+ process.exit(1);
1479
+ }
1480
+ apiArgs = [serverEntry];
1481
+ }
1482
+ const resolved = await resolvePorts({
1483
+ api: opts.port ? Number(opts.port) : void 0,
1484
+ dashboard: opts.dashboardPort ? Number(opts.dashboardPort) : void 0
1485
+ });
1486
+ const port = String(resolved.api);
1487
+ const dashPort = String(resolved.dashboard);
1488
+ const publicUrl = effectivePublicUrl(opts.publicUrl);
1489
+ const managedEdge = Boolean(opts.managedEdge && publicUrl);
1490
+ const dataDir = opts.dataDir || DATA_DIR;
1491
+ mkdirSync6(dataDir, { recursive: true });
1492
+ mkdirSync6(LOG_DIR, { recursive: true });
1493
+ const instanceLogPath = join5(LOG_DIR, "instance.log");
1494
+ const instanceLog = createWriteStream2(instanceLogPath, { flags: "w" });
1495
+ const env = {
1496
+ ...process.env,
1497
+ PORT: port,
1498
+ NODE_ENV: "production",
1499
+ // BARE, not "desktop". This box is a server-host: openship runs ON it and it
1500
+ // is itself a deploy target. Claiming "desktop" (once done here just to get
1501
+ // the in-process job runner) made the API report `isServerHost: false`
1502
+ // (health.routes.ts), so `registerSelfServerReconcile` — gated on
1503
+ // modes:["selfhosted"] — never ran, "This Server" was never registered, and
1504
+ // the deploy wizard offered Openship Cloud as the only target on a box the
1505
+ // operator had just installed on purpose.
1506
+ //
1507
+ // It also mislabelled the security posture: DEPLOY_MODE=desktop relaxes the
1508
+ // zero-auth gate and makes INTERNAL_TOKEN optional. "desktop" belongs to
1509
+ // Electron alone (apps/desktop/src/main/services.ts:361) — the only launcher
1510
+ // that is genuinely a single-user loopback app.
1511
+ //
1512
+ // The job runner is unaffected: it picks BullMQ vs in-process by REDIS
1513
+ // REACHABILITY (app.ts:238), and OPENSHIP_JOB_RUNNER below already pins it.
1514
+ DEPLOY_MODE: "bare",
1515
+ OPENSHIP_TARGET: "local",
1516
+ OPENSHIP_JOB_RUNNER: "in-process",
1517
+ PGLITE_DATA_DIR: dataDir,
1518
+ BETTER_AUTH_SECRET: ensureAuthSecret()
1519
+ };
1520
+ if (!source) {
1521
+ env.OPENSHIP_MIGRATIONS_DIR = join5(SERVER_DIR, "migrations");
1522
+ env.OPENSHIP_PGLITE_ASSETS_DIR = join5(SERVER_DIR, "pglite");
1523
+ const engineDir = join5(SERVER_DIR, "engine");
1524
+ if (existsSync5(engineDir)) env.MAIL_SERVER_ENGINE_DIR = engineDir;
1525
+ }
1526
+ env.OPENSHIP_REQUIRE_AUTH = "true";
1527
+ env.INTERNAL_TOKEN = ensureInternalToken();
1528
+ if (opts.mail) env.OPENSHIP_PRODUCT = "mail";
1529
+ if (!env.OPENSHIP_AUTH_MODE) env.OPENSHIP_AUTH_MODE = "local";
1530
+ env.OPENSHIP_API_HOST = "127.0.0.1";
1531
+ env.OPENSHIP_DASHBOARD_PORT = dashPort;
1532
+ env.OPENSHIP_INSTANCE_LOG = instanceLogPath;
1533
+ delete env.OPENSHIP_ALLOW_ZERO_AUTH;
1534
+ if (publicUrl) {
1535
+ env.OPENSHIP_PUBLIC_URL = publicUrl;
1536
+ } else if (opts.host && !/^(0\.0\.0\.0|127\.|::1?$|localhost$)/i.test(opts.host.trim())) {
1537
+ env.OPENSHIP_EXTRA_TRUSTED_ORIGINS = `http://${opts.host.trim()}:${dashPort}`;
1538
+ }
1539
+ if (opts.trustProxy || managedEdge) env.TRUST_PROXY = "true";
1540
+ if (managedEdge) {
1541
+ env.OPENSHIP_MANAGED_EDGE = "true";
1542
+ env.OPENSHIP_DASHBOARD_PORT = dashPort;
1543
+ if (opts.acmeEmail) env.OPENSHIP_ACME_EMAIL = opts.acmeEmail;
1544
+ }
1545
+ delete env.DATABASE_URL;
1546
+ delete env.POSTGRES_URL;
1547
+ const spinner = ora(`Starting Openship on http://localhost:${port} \u2026`).start();
1548
+ const child = spawn(apiCmd, apiArgs, {
1549
+ cwd: apiCwd,
1550
+ env,
1551
+ stdio: ["ignore", "pipe", "pipe"],
1552
+ detached: process.platform !== "win32"
1553
+ });
1554
+ child.stdout.on("data", (d) => instanceLog.write(d));
1555
+ child.stderr.on("data", (d) => instanceLog.write(d));
1556
+ let buffered = "";
1557
+ const buffer = (d) => {
1558
+ buffered += d.toString();
1559
+ };
1560
+ child.stdout.on("data", buffer);
1561
+ child.stderr.on("data", buffer);
1562
+ child.on("exit", (code) => {
1563
+ if (code && code !== 0) {
1564
+ spinner.fail(`Openship server exited (code ${code})`);
1565
+ process.stderr.write(buffered.slice(-2e3));
1566
+ process.exit(code);
1567
+ }
1568
+ });
1569
+ const healthUrl = `http://127.0.0.1:${port}/api/health`;
1570
+ let healthy = false;
1571
+ for (let i = 0; i < 60 && child.exitCode === null; i++) {
1572
+ await new Promise((r) => setTimeout(r, 1e3));
1573
+ try {
1574
+ const res = await fetch(healthUrl, { signal: AbortSignal.timeout(2e3) });
1575
+ if (res.ok) {
1576
+ healthy = true;
1577
+ break;
1578
+ }
1579
+ } catch {
1580
+ }
1581
+ }
1582
+ if (!healthy) {
1583
+ spinner.fail("Openship did not become healthy in time");
1584
+ process.stderr.write(buffered.slice(-2e3));
1585
+ child.kill("SIGTERM");
1586
+ process.exit(1);
1587
+ }
1588
+ spinner.succeed(`Openship API running at http://localhost:${port}`);
1589
+ const children = [child];
1590
+ const killTree = (c, sig) => {
1591
+ try {
1592
+ if (c.pid && process.platform !== "win32") process.kill(-c.pid, sig);
1593
+ else c.kill(sig);
1594
+ } catch {
1595
+ }
1596
+ };
1597
+ let stopping = false;
1598
+ const stopAll = (exitCode = 0) => {
1599
+ if (stopping) return;
1600
+ stopping = true;
1601
+ try {
1602
+ instanceLog.end();
1603
+ } catch {
1604
+ }
1605
+ for (const c of children) killTree(c, "SIGTERM");
1606
+ setTimeout(() => {
1607
+ for (const c of children) killTree(c, "SIGKILL");
1608
+ process.exit(exitCode);
1609
+ }, 1500);
1610
+ };
1611
+ let dashboardUrl = null;
1612
+ if (opts.ui !== false) {
1613
+ if (source) process.env.OPENSHIP_DASHBOARD_DIR = source.dashboardDir;
1614
+ const uiSpinner = ora("Preparing the dashboard\u2026").start();
1615
+ try {
1616
+ const bundle = await ensureDashboard({
1617
+ tag: source ? "local" : opts.uiVersion || `v${"0.6.5"}`,
1618
+ onProgress: (received, total) => {
1619
+ if (total) {
1620
+ uiSpinner.text = `Downloading dashboard\u2026 ${Math.round(received / total * 100)}%`;
1621
+ }
1622
+ }
1623
+ });
1624
+ uiSpinner.text = "Starting the dashboard\u2026";
1625
+ const dash = spawn(process.execPath, [bundle.entry], {
1626
+ cwd: bundle.cwd,
1627
+ detached: process.platform !== "win32",
1628
+ env: {
1629
+ ...process.env,
1630
+ NODE_ENV: "production",
1631
+ OPENSHIP_TARGET: "local",
1632
+ PORT: dashPort,
1633
+ // Reachable remotely when public; loopback-only otherwise. Under
1634
+ // managed edge the local OpenResty fronts the dashboard, so it stays
1635
+ // on loopback even though there's a public URL.
1636
+ HOSTNAME: opts.host?.trim() || (publicUrl && !managedEdge ? "0.0.0.0" : "127.0.0.1"),
1637
+ // The dashboard's same-origin proxy (NEXT_PUBLIC_API_PROXY, baked
1638
+ // into the release build) forwards /api/proxy/* to this address, so
1639
+ // the browser never needs to know where the API lives. Set in every
1640
+ // mode; loopback because the dashboard runs on the same box.
1641
+ INTERNAL_API_URL: `http://127.0.0.1:${port}`,
1642
+ // ALWAYS tell the dashboard the real loopback API origin. The API port
1643
+ // is dynamic, so a browser opened on THIS box must learn it via
1644
+ // window.__OPENSHIP_API_ORIGIN__ (layout.tsx) — otherwise it falls back
1645
+ // to the static default :4000 and every call 404s. Use `localhost` (NOT
1646
+ // 127.0.0.1) to MATCH the host the dashboard is opened on — a host-only
1647
+ // SameSite session cookie set on 127.0.0.1 is never sent to localhost
1648
+ // (they're different sites to a browser), which is the login-reload loop.
1649
+ // Older dashboards use this origin verbatim; newer ones align it anyway.
1650
+ // `localhost` still reaches the 127.0.0.1-bound API. In proxy mode this
1651
+ // is just a fallback (sameOriginProxyOrigin wins for remote browsers).
1652
+ OPENSHIP_LOCAL_API_URL: `http://localhost:${port}`,
1653
+ ...publicUrl ? { OPENSHIP_PUBLIC_URL: publicUrl } : {}
1654
+ },
1655
+ stdio: ["ignore", "pipe", "pipe"]
1656
+ });
1657
+ children.push(dash);
1658
+ dash.stdout.on("data", (d) => instanceLog.write(d));
1659
+ dash.stderr.on("data", (d) => instanceLog.write(d));
1660
+ let dashBuf = "";
1661
+ const onDash = (d) => {
1662
+ dashBuf += d.toString();
1663
+ };
1664
+ dash.stdout.on("data", onDash);
1665
+ dash.stderr.on("data", onDash);
1666
+ let dashUp = false;
1667
+ for (let i = 0; i < 45 && dash.exitCode === null; i++) {
1668
+ await new Promise((r) => setTimeout(r, 1e3));
1669
+ try {
1670
+ const res = await fetch(`http://127.0.0.1:${dashPort}`, { signal: AbortSignal.timeout(2e3) });
1671
+ if (res.status < 500) {
1672
+ dashUp = true;
1673
+ break;
1674
+ }
1675
+ } catch {
1676
+ }
1677
+ }
1678
+ if (dashUp) {
1679
+ dashboardUrl = publicUrl ?? `http://localhost:${dashPort}`;
1680
+ uiSpinner.succeed(`Dashboard running at ${dashboardUrl}`);
1681
+ dash.stdout.off("data", onDash);
1682
+ dash.stderr.off("data", onDash);
1683
+ dash.stdout.on("data", (d) => process.stdout.write(d));
1684
+ dash.stderr.on("data", (d) => process.stderr.write(d));
1685
+ } else {
1686
+ uiSpinner.warn("Dashboard didn't come up in time \u2014 continuing with the API only.");
1687
+ process.stderr.write(dashBuf.slice(-1e3));
1688
+ }
1689
+ } catch (e) {
1690
+ uiSpinner.warn(`Dashboard unavailable: ${e.message}`);
1691
+ console.log(
1692
+ chalk2.dim(
1693
+ " The API is still running. Retry `openship up`, pass --no-ui, or use `openship install` for the desktop app.\n"
1694
+ )
1695
+ );
1696
+ }
1697
+ }
1698
+ if (publicUrl) {
1699
+ console.log(
1700
+ (dashboardUrl ? chalk2.dim(` Dashboard: ${dashboardUrl} (login required)
1701
+ `) : "") + chalk2.dim(" API is proxied through the dashboard (bound to loopback, not exposed).\n") + chalk2.dim(` Data: ${dataDir}
1702
+ `) + (managedEdge ? chalk2.dim(" Managed edge (OpenResty + Let's Encrypt) fronts this box \u2014 point your domain's A record at this server's IP. Stop with Ctrl-C.\n") : chalk2.dim(" Point your reverse proxy / DNS at the dashboard port. Stop with Ctrl-C.\n"))
1703
+ );
1704
+ } else {
1705
+ console.log(
1706
+ chalk2.dim(` API: http://localhost:${port}/api
1707
+ `) + (dashboardUrl ? chalk2.dim(` Dashboard: ${dashboardUrl} (login required)
1708
+ `) : "") + chalk2.dim(` Data: ${dataDir}
1709
+ `) + chalk2.dim(" Log in with your admin account (run `openship` to create one). Stop with Ctrl-C.\n")
1710
+ );
1711
+ }
1712
+ child.stdout.off("data", buffer);
1713
+ child.stderr.off("data", buffer);
1714
+ child.stdout.on("data", (d) => process.stdout.write(d));
1715
+ child.stderr.on("data", (d) => process.stderr.write(d));
1716
+ process.on("SIGINT", () => stopAll(0));
1717
+ process.on("SIGTERM", () => stopAll(0));
1718
+ child.on("exit", (code) => stopAll(code ?? 0));
1719
+ }
1720
+
1721
+ export {
1722
+ CACHE_DIR,
1723
+ RELEASES_DIR,
1724
+ releaseDir,
1725
+ parseSha256,
1726
+ hashFile,
1727
+ downloadToFile,
1728
+ formatBytes,
1729
+ RELEASES,
1730
+ resolveLatestTag,
1731
+ assetUrl,
1732
+ fetchSidecar,
1733
+ expectedSha256,
1734
+ ensureDashboard,
1735
+ readCliInstall,
1736
+ writeCliInstall,
1737
+ selfInvocation,
1738
+ restart,
1739
+ serviceStatus,
1740
+ stop,
1741
+ ensureInternalToken,
1742
+ internalGet,
1743
+ internalPost,
1744
+ bootstrapAdmin,
1745
+ waitHealthy,
1746
+ waitDashboard,
1747
+ detectPublicIp,
1748
+ headlessProvision,
1749
+ normalizeUrl,
1750
+ upCommand,
1751
+ runHeadlessProvision,
1752
+ startService
1753
+ };