openship 0.1.11 → 0.2.1

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 (42) hide show
  1. package/dist/index.js +680 -104
  2. package/dist/server/index.js +17063 -8214
  3. package/dist/server/lua/geo_country.lua +101 -0
  4. package/dist/server/lua/mgmt_api.lua +291 -0
  5. package/dist/server/lua/pipe_log.lua +71 -0
  6. package/dist/server/lua/pipe_stream.lua +90 -0
  7. package/dist/server/lua/rules_guard.lua +118 -0
  8. package/dist/server/lua/rules_lib.lua +176 -0
  9. package/dist/server/lua/site_logger.lua +182 -0
  10. package/dist/server/lua/webhook_handler.lua +126 -0
  11. package/dist/server/migrations/0035_thankful_morgan_stark.sql +1 -0
  12. package/dist/server/migrations/0036_gorgeous_scalphunter.sql +17 -0
  13. package/dist/server/migrations/0037_clever_makkari.sql +28 -0
  14. package/dist/server/migrations/0038_young_magneto.sql +1 -0
  15. package/dist/server/migrations/0039_secret_lady_ursula.sql +15 -0
  16. package/dist/server/migrations/0040_sharp_red_wolf.sql +16 -0
  17. package/dist/server/migrations/0041_round_spyke.sql +4 -0
  18. package/dist/server/migrations/0042_dapper_havok.sql +1 -0
  19. package/dist/server/migrations/0043_fast_sharon_ventura.sql +15 -0
  20. package/dist/server/migrations/0044_amused_shape.sql +8 -0
  21. package/dist/server/migrations/0045_amusing_korath.sql +1 -0
  22. package/dist/server/migrations/0046_white_harrier.sql +35 -0
  23. package/dist/server/migrations/0047_sloppy_strong_guy.sql +2 -0
  24. package/dist/server/migrations/0048_funny_emma_frost.sql +2 -0
  25. package/dist/server/migrations/0049_nappy_piledriver.sql +3 -0
  26. package/dist/server/migrations/meta/0035_snapshot.json +7610 -0
  27. package/dist/server/migrations/meta/0036_snapshot.json +7752 -0
  28. package/dist/server/migrations/meta/0037_snapshot.json +7978 -0
  29. package/dist/server/migrations/meta/0038_snapshot.json +7985 -0
  30. package/dist/server/migrations/meta/0039_snapshot.json +8090 -0
  31. package/dist/server/migrations/meta/0040_snapshot.json +8198 -0
  32. package/dist/server/migrations/meta/0041_snapshot.json +8211 -0
  33. package/dist/server/migrations/meta/0042_snapshot.json +8217 -0
  34. package/dist/server/migrations/meta/0043_snapshot.json +8316 -0
  35. package/dist/server/migrations/meta/0044_snapshot.json +8360 -0
  36. package/dist/server/migrations/meta/0045_snapshot.json +8367 -0
  37. package/dist/server/migrations/meta/0046_snapshot.json +8657 -0
  38. package/dist/server/migrations/meta/0047_snapshot.json +8669 -0
  39. package/dist/server/migrations/meta/0048_snapshot.json +8683 -0
  40. package/dist/server/migrations/meta/0049_snapshot.json +8702 -0
  41. package/dist/server/migrations/meta/_journal.json +105 -0
  42. package/package.json +4 -1
package/dist/index.js CHANGED
@@ -902,11 +902,13 @@ var PLANS = {
902
902
  free: {
903
903
  id: "free",
904
904
  name: "Free",
905
- description: "Get started with 500 credits per month",
905
+ description: "Get started for free",
906
906
  price: { monthly: 0, annual: 0 },
907
907
  stripePriceId: { monthly: null, annual: null },
908
- monthlyCredits: 5e5,
909
- // milli-credits
908
+ // Paid tiers below are `null` (price + credits) until Openship Cloud pricing
909
+ // is finalized — the UI renders "coming soon". Self-hosted is free and never
910
+ // surfaces any of these numbers.
911
+ monthlyCredits: null,
910
912
  oblienLimits: {
911
913
  max_workspaces: 1,
912
914
  max_vcpus: 2,
@@ -914,7 +916,6 @@ var PLANS = {
914
916
  max_disk_gb: 10
915
917
  },
916
918
  features: [
917
- "500 credits/mo",
918
919
  "1 workspace",
919
920
  "Community support"
920
921
  ],
@@ -925,13 +926,13 @@ var PLANS = {
925
926
  id: "pro",
926
927
  name: "Pro",
927
928
  description: "For solo builders shipping production workloads",
928
- price: { monthly: 2e3, annual: 19200 },
929
- // cents (20% annual discount)
929
+ price: { monthly: null, annual: null },
930
+ // coming soon
930
931
  stripePriceId: {
931
932
  monthly: process.env.STRIPE_PRICE_PRO_MONTHLY ?? "price_pro_monthly_placeholder",
932
933
  annual: process.env.STRIPE_PRICE_PRO_ANNUAL ?? "price_pro_annual_placeholder"
933
934
  },
934
- monthlyCredits: 5e6,
935
+ monthlyCredits: null,
935
936
  oblienLimits: {
936
937
  max_workspaces: 10,
937
938
  max_vcpus: 16,
@@ -939,7 +940,6 @@ var PLANS = {
939
940
  max_disk_gb: 100
940
941
  },
941
942
  features: [
942
- "5,000 credits/mo",
943
943
  "Up to 10 workspaces",
944
944
  "Email support"
945
945
  ],
@@ -950,12 +950,13 @@ var PLANS = {
950
950
  id: "team",
951
951
  name: "Team",
952
952
  description: "For teams collaborating on shared infra",
953
- price: { monthly: 5e3, annual: 48e3 },
953
+ price: { monthly: null, annual: null },
954
+ // coming soon
954
955
  stripePriceId: {
955
956
  monthly: process.env.STRIPE_PRICE_TEAM_MONTHLY ?? "price_team_monthly_placeholder",
956
957
  annual: process.env.STRIPE_PRICE_TEAM_ANNUAL ?? "price_team_annual_placeholder"
957
958
  },
958
- monthlyCredits: 25e6,
959
+ monthlyCredits: null,
959
960
  oblienLimits: {
960
961
  max_workspaces: 50,
961
962
  max_vcpus: 64,
@@ -963,7 +964,6 @@ var PLANS = {
963
964
  max_disk_gb: 500
964
965
  },
965
966
  features: [
966
- "25,000 credits/mo",
967
967
  "Up to 50 workspaces",
968
968
  "Team collaboration",
969
969
  "Priority email support"
@@ -1128,8 +1128,12 @@ var localhost = (port) => `http://localhost:${port}`;
1128
1128
  var LOCAL_WEB_URL = localhost(DEFAULT_PORT.web);
1129
1129
  var LOCAL_DASHBOARD_URL = localhost(DEFAULT_PORT.dashboard);
1130
1130
  var LOCAL_API_URL = localhost(DEFAULT_PORT.api);
1131
- var CLOUD_DASHBOARD_URL = "https://app.openship.io";
1132
- var CLOUD_API_URL = "https://api.openship.io";
1131
+ var envUrl = (key) => {
1132
+ const v = typeof process !== "undefined" ? process.env?.[key] : void 0;
1133
+ return v && v.trim() ? v.trim() : void 0;
1134
+ };
1135
+ var CLOUD_DASHBOARD_URL = envUrl("OPENSHIP_CLOUD_DASHBOARD_URL") ?? "https://app.openship.io";
1136
+ var CLOUD_API_URL = envUrl("OPENSHIP_CLOUD_API_URL") ?? "https://api.openship.io";
1133
1137
  var DASHBOARD_RUNTIME_TARGETS = {
1134
1138
  local: {
1135
1139
  dashboard: LOCAL_DASHBOARD_URL,
@@ -2230,8 +2234,8 @@ var loginCommand = new Command("login").description("Authenticate with a Persona
2230
2234
  chalk2.bold("\n Openship login\n") + chalk2.dim(" Create a Personal Access Token in Settings \u2192 Personal Access Tokens,\n") + chalk2.dim(" then paste it here.\n")
2231
2235
  );
2232
2236
  try {
2233
- const { default: open } = await import("open");
2234
- await open(settingsUrl);
2237
+ const { default: open2 } = await import("open");
2238
+ await open2(settingsUrl);
2235
2239
  } catch {
2236
2240
  }
2237
2241
  console.log(
@@ -2350,8 +2354,8 @@ var openCommand = new Command3("open").description("Open the Openship dashboard
2350
2354
  }
2351
2355
  }
2352
2356
  try {
2353
- const { default: open } = await import("open");
2354
- await open(target);
2357
+ const { default: open2 } = await import("open");
2358
+ await open2(target);
2355
2359
  console.log(chalk4.dim(`
2356
2360
  Opening ${target}
2357
2361
  `));
@@ -2474,6 +2478,17 @@ function assetName(tag) {
2474
2478
  return `openship-dashboard-${tag}.tar.gz`;
2475
2479
  }
2476
2480
  async function ensureDashboard(opts = {}) {
2481
+ const override = process.env.OPENSHIP_DASHBOARD_DIR?.trim();
2482
+ if (override) {
2483
+ const cwd2 = join3(override, "apps", "dashboard");
2484
+ const entry2 = join3(cwd2, "server.js");
2485
+ if (!existsSync2(entry2)) {
2486
+ throw new Error(
2487
+ `OPENSHIP_DASHBOARD_DIR=${override} but ${entry2} is missing \u2014 build the dashboard standalone first (see docs).`
2488
+ );
2489
+ }
2490
+ return { tag: "local", entry: entry2, cwd: cwd2 };
2491
+ }
2477
2492
  const tag = opts.tag ?? await resolveLatestTag();
2478
2493
  const dir = join3(DASHBOARD_CACHE, tag);
2479
2494
  const cwd = join3(dir, "apps", "dashboard");
@@ -2534,6 +2549,10 @@ function upArgs(flags) {
2534
2549
  if (flags.dashboardPort) a.push("--dashboard-port", flags.dashboardPort);
2535
2550
  if (flags.ui === false) a.push("--no-ui");
2536
2551
  if (flags.uiVersion) a.push("--ui-version", flags.uiVersion);
2552
+ if (flags.publicUrl) a.push("--public-url", flags.publicUrl);
2553
+ if (flags.trustProxy) a.push("--trust-proxy");
2554
+ if (flags.managedEdge) a.push("--managed-edge");
2555
+ if (flags.acmeEmail) a.push("--acme-email", flags.acmeEmail);
2537
2556
  return a;
2538
2557
  }
2539
2558
  function runArgv(flags) {
@@ -2562,10 +2581,17 @@ function hasSystemd() {
2562
2581
  function xmlEscape(s) {
2563
2582
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2564
2583
  }
2584
+ function serviceEnv() {
2585
+ const extra = {};
2586
+ const dashDir = process.env.OPENSHIP_DASHBOARD_DIR?.trim();
2587
+ if (dashDir) extra.OPENSHIP_DASHBOARD_DIR = dashDir;
2588
+ return extra;
2589
+ }
2565
2590
  function plist(flags) {
2566
2591
  const argv = runArgv(flags);
2567
2592
  const items = argv.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
2568
2593
  const path2 = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", join4(HOME, ".bun/bin")].join(":");
2594
+ const extraEnv = Object.entries(serviceEnv()).map(([k, v]) => `<key>${xmlEscape(k)}</key><string>${xmlEscape(v)}</string>`).join("");
2569
2595
  return `<?xml version="1.0" encoding="UTF-8"?>
2570
2596
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2571
2597
  <plist version="1.0">
@@ -2580,7 +2606,7 @@ ${items}
2580
2606
  <key>StandardOutPath</key><string>${join4(LOG_DIR, "up.log")}</string>
2581
2607
  <key>StandardErrorPath</key><string>${join4(LOG_DIR, "up.err.log")}</string>
2582
2608
  <key>EnvironmentVariables</key>
2583
- <dict><key>PATH</key><string>${path2}</string></dict>
2609
+ <dict><key>PATH</key><string>${path2}</string>${extraEnv}</dict>
2584
2610
  </dict>
2585
2611
  </plist>
2586
2612
  `;
@@ -2588,6 +2614,8 @@ ${items}
2588
2614
  function systemdUnit(flags) {
2589
2615
  const argv = runArgv(flags);
2590
2616
  const execStart = argv.map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ");
2617
+ const extraEnv = Object.entries(serviceEnv()).map(([k, v]) => `Environment=${k}=${v}
2618
+ `).join("");
2591
2619
  return `[Unit]
2592
2620
  Description=Openship control plane
2593
2621
  After=network-online.target
@@ -2599,7 +2627,7 @@ ExecStart=${execStart}
2599
2627
  Restart=always
2600
2628
  RestartSec=2
2601
2629
  Environment=NODE_ENV=production
2602
-
2630
+ ${extraEnv}
2603
2631
  [Install]
2604
2632
  WantedBy=default.target
2605
2633
  `;
@@ -2649,6 +2677,28 @@ function installAndStart(flags) {
2649
2677
  "No supported service manager found (need systemd on Linux). Run `openship up --foreground` instead, or use docker compose for always-on."
2650
2678
  );
2651
2679
  }
2680
+ function restart() {
2681
+ const kind = detectKind();
2682
+ if (kind === "launchd") {
2683
+ if (!existsSync3(MAC_PLIST)) return { restarted: false, detail: "no launchd agent installed" };
2684
+ const uid = String(process.getuid?.() ?? "");
2685
+ const r = run("launchctl", ["kickstart", "-k", `gui/${uid}/${MAC_LABEL}`]);
2686
+ return { restarted: r.ok, detail: r.ok ? `restarted ${MAC_LABEL}` : r.out };
2687
+ }
2688
+ if (kind === "systemd-user" || kind === "systemd-system") {
2689
+ const sysArgs = kind === "systemd-user" ? ["--user"] : [];
2690
+ const unitPath = kind === "systemd-user" ? join4(HOME, ".config/systemd/user", `${SYSTEMD_NAME}.service`) : `/etc/systemd/system/${SYSTEMD_NAME}.service`;
2691
+ if (!existsSync3(unitPath)) return { restarted: false, detail: "no systemd unit installed" };
2692
+ const r = run("systemctl", [...sysArgs, "restart", SYSTEMD_NAME]);
2693
+ return { restarted: r.ok, detail: r.ok ? `restarted ${SYSTEMD_NAME}` : r.out };
2694
+ }
2695
+ if (kind === "schtasks") {
2696
+ run("schtasks", ["/End", "/TN", WIN_TASK]);
2697
+ const r = run("schtasks", ["/Run", "/TN", WIN_TASK]);
2698
+ return { restarted: r.ok, detail: r.ok ? `restarted ${WIN_TASK}` : r.out };
2699
+ }
2700
+ return { restarted: false, detail: "no supported service manager" };
2701
+ }
2652
2702
  function stop() {
2653
2703
  const kind = detectKind();
2654
2704
  if (kind === "launchd") {
@@ -2673,6 +2723,26 @@ function stop() {
2673
2723
  }
2674
2724
 
2675
2725
  // src/commands/up.ts
2726
+ function normalizeUrl(raw) {
2727
+ const value = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
2728
+ try {
2729
+ const u = new URL(value);
2730
+ return `${u.protocol}//${u.host}`;
2731
+ } catch {
2732
+ return null;
2733
+ }
2734
+ }
2735
+ function normalizePublicUrl(raw) {
2736
+ const url = normalizeUrl(raw);
2737
+ if (!url) {
2738
+ console.error(
2739
+ chalk5.red(`
2740
+ Invalid --public-url: ${raw}`) + chalk5.dim("\n Expected something like https://ops.example.com\n")
2741
+ );
2742
+ process.exit(1);
2743
+ }
2744
+ return url;
2745
+ }
2676
2746
  var DIST_DIR = dirname2(fileURLToPath(import.meta.url));
2677
2747
  var SERVER_DIR = join5(DIST_DIR, "server");
2678
2748
  var OS_DIR2 = join5(homedir4(), ".openship");
@@ -2684,17 +2754,42 @@ function ensureAuthSecret() {
2684
2754
  writeFileSync4(path2, secret, { mode: 384 });
2685
2755
  return secret;
2686
2756
  }
2687
- var upCommand = new Command4("up").description("Start Openship as a persistent service (boot + auto-restart); --foreground to run attached").option("--port <port>", "API port to listen on", "4000").option("--data-dir <dir>", "Directory for the embedded database").option("--dashboard-port <port>", "Dashboard port", "3001").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("-f, --foreground", "Run attached in this terminal instead of as a background service").option("--dry-run", "Print the service definition that would be installed, then exit").action(async (opts) => {
2757
+ function ensureInternalToken() {
2758
+ const path2 = join5(OS_DIR2, "internal-token");
2759
+ if (existsSync4(path2)) return readFileSync2(path2, "utf8").trim();
2760
+ mkdirSync5(OS_DIR2, { recursive: true, mode: 448 });
2761
+ const token = randomBytes(32).toString("hex");
2762
+ writeFileSync4(path2, token, { mode: 384 });
2763
+ return token;
2764
+ }
2765
+ var upCommand = new Command4("up").description("Start Openship as a persistent service (boot + auto-restart); --foreground to run attached").option("--port <port>", "API port to listen on", "4000").option("--data-dir <dir>", "Directory for the embedded database").option("--dashboard-port <port>", "Dashboard port", "3001").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("-f, --foreground", "Run attached in this terminal instead of as a background service").option("--dry-run", "Print the service definition that would be installed, then exit").option(
2766
+ "--public-url <url>",
2767
+ "Serve remotely at this public URL (VPS): binds the dashboard to all interfaces, proxies the API same-origin, and requires login"
2768
+ ).option(
2769
+ "--trust-proxy",
2770
+ "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)"
2771
+ ).option(
2772
+ "--managed-edge",
2773
+ "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)"
2774
+ ).option("--acme-email <email>", "Contact email for Let's Encrypt certificates (managed edge)").action(async (opts) => {
2688
2775
  if (opts.foreground) return runForeground(opts);
2689
2776
  startService(opts);
2690
2777
  });
2691
- function startService(opts) {
2778
+ function startService(opts, runOpts = {}) {
2779
+ const publicUrl = opts.publicUrl ? normalizePublicUrl(opts.publicUrl) : void 0;
2780
+ const port = String(opts.port || "4000");
2781
+ const dashPort = String(opts.dashboardPort || "3001");
2692
2782
  const flags = {
2693
2783
  port: opts.port,
2694
2784
  dataDir: opts.dataDir,
2695
2785
  dashboardPort: opts.dashboardPort,
2696
2786
  ui: opts.ui,
2697
- uiVersion: opts.uiVersion
2787
+ uiVersion: opts.uiVersion,
2788
+ publicUrl,
2789
+ trustProxy: opts.trustProxy || opts.managedEdge,
2790
+ // managed edge = OpenResty sets XFF
2791
+ managedEdge: opts.managedEdge,
2792
+ acmeEmail: opts.acmeEmail
2698
2793
  };
2699
2794
  if (opts.dryRun) {
2700
2795
  const p = preview(flags);
@@ -2705,19 +2800,23 @@ function startService(opts) {
2705
2800
 
2706
2801
  `) + p.content + "\n"
2707
2802
  );
2708
- return;
2803
+ return { port, dashPort, publicUrl };
2709
2804
  }
2710
2805
  try {
2711
2806
  const res = installAndStart(flags);
2712
- const port = String(opts.port || "4000");
2713
- const dashPort = String(opts.dashboardPort || "3001");
2714
- console.log(
2715
- chalk5.green("\n \u2714 Openship is running as a service.\n") + chalk5.dim(` API: http://localhost:${port}/api
2716
- `) + (opts.ui !== false ? chalk5.dim(` Dashboard: http://localhost:${dashPort}
2717
- `) : "") + chalk5.dim(` ${res.detail}
2807
+ if (!runOpts.quiet) {
2808
+ const dashboardLine = publicUrl ? chalk5.dim(` Dashboard: ${publicUrl} (login required)
2809
+ `) : chalk5.dim(` Dashboard: http://localhost:${dashPort} (login required)
2810
+ `);
2811
+ console.log(
2812
+ chalk5.green("\n \u2714 Openship is running as a service.\n") + (opts.ui !== false ? dashboardLine : "") + (publicUrl ? chalk5.dim(" API is proxied through the dashboard (not exposed). Point your reverse proxy / DNS at the dashboard port.\n") : chalk5.dim(` API: http://localhost:${port}/api
2813
+ `)) + chalk5.dim(` ${res.detail}
2718
2814
  `) + chalk5.dim(" Starts on boot and auto-restarts. Stop with `openship stop`.\n")
2719
- );
2815
+ );
2816
+ }
2817
+ return { port, dashPort, publicUrl };
2720
2818
  } catch (e) {
2819
+ if (runOpts.quiet) throw e;
2721
2820
  console.error(
2722
2821
  chalk5.red(`
2723
2822
  Couldn't install the service: ${e.message}
@@ -2735,26 +2834,40 @@ async function runForeground(opts) {
2735
2834
  process.exit(1);
2736
2835
  }
2737
2836
  const port = String(opts.port || "4000");
2837
+ const dashPort = String(opts.dashboardPort || "3001");
2838
+ const publicUrl = opts.publicUrl ? normalizePublicUrl(opts.publicUrl) : void 0;
2839
+ const managedEdge = Boolean(opts.managedEdge && publicUrl);
2738
2840
  const dataDir = opts.dataDir || join5(OS_DIR2, "data");
2739
2841
  mkdirSync5(dataDir, { recursive: true });
2740
2842
  const env = {
2741
2843
  ...process.env,
2742
2844
  PORT: port,
2743
2845
  NODE_ENV: "production",
2744
- // desktop mode → in-process job runner (no Redis) + loopback zero-auth,
2745
- // so a local single-user box needs no PAT over 127.0.0.1.
2846
+ // desktop mode → in-process job runner (no Redis).
2746
2847
  DEPLOY_MODE: "desktop",
2747
2848
  OPENSHIP_TARGET: "local",
2748
2849
  OPENSHIP_JOB_RUNNER: "in-process",
2749
- OPENSHIP_ALLOW_ZERO_AUTH: "true",
2750
2850
  PGLITE_DATA_DIR: dataDir,
2751
2851
  OPENSHIP_MIGRATIONS_DIR: join5(SERVER_DIR, "migrations"),
2752
2852
  OPENSHIP_PGLITE_ASSETS_DIR: join5(SERVER_DIR, "pglite"),
2753
2853
  BETTER_AUTH_SECRET: ensureAuthSecret()
2754
2854
  };
2855
+ env.OPENSHIP_REQUIRE_AUTH = "true";
2856
+ env.INTERNAL_TOKEN = ensureInternalToken();
2857
+ env.OPENSHIP_API_HOST = "127.0.0.1";
2858
+ delete env.OPENSHIP_ALLOW_ZERO_AUTH;
2859
+ if (publicUrl) {
2860
+ env.OPENSHIP_PUBLIC_URL = publicUrl;
2861
+ }
2862
+ if (opts.trustProxy || managedEdge) env.TRUST_PROXY = "true";
2863
+ if (managedEdge) {
2864
+ env.OPENSHIP_MANAGED_EDGE = "true";
2865
+ env.OPENSHIP_DASHBOARD_PORT = dashPort;
2866
+ if (opts.acmeEmail) env.OPENSHIP_ACME_EMAIL = opts.acmeEmail;
2867
+ }
2755
2868
  delete env.DATABASE_URL;
2756
2869
  delete env.POSTGRES_URL;
2757
- const spinner2 = ora(`Starting Openship on http://localhost:${port} \u2026`).start();
2870
+ const spinner3 = ora(`Starting Openship on http://localhost:${port} \u2026`).start();
2758
2871
  const child = spawn(process.execPath, [serverEntry], { env, stdio: ["ignore", "pipe", "pipe"] });
2759
2872
  let buffered = "";
2760
2873
  const buffer = (d) => {
@@ -2764,7 +2877,7 @@ async function runForeground(opts) {
2764
2877
  child.stderr.on("data", buffer);
2765
2878
  child.on("exit", (code) => {
2766
2879
  if (code && code !== 0) {
2767
- spinner2.fail(`Openship server exited (code ${code})`);
2880
+ spinner3.fail(`Openship server exited (code ${code})`);
2768
2881
  process.stderr.write(buffered.slice(-2e3));
2769
2882
  process.exit(code);
2770
2883
  }
@@ -2783,12 +2896,12 @@ async function runForeground(opts) {
2783
2896
  }
2784
2897
  }
2785
2898
  if (!healthy) {
2786
- spinner2.fail("Openship did not become healthy in time");
2899
+ spinner3.fail("Openship did not become healthy in time");
2787
2900
  process.stderr.write(buffered.slice(-2e3));
2788
2901
  child.kill("SIGTERM");
2789
2902
  process.exit(1);
2790
2903
  }
2791
- spinner2.succeed(`Openship API running at http://localhost:${port}`);
2904
+ spinner3.succeed(`Openship API running at http://localhost:${port}`);
2792
2905
  const children = [child];
2793
2906
  const stopAll = () => {
2794
2907
  for (const c of children) {
@@ -2806,11 +2919,10 @@ async function runForeground(opts) {
2806
2919
  };
2807
2920
  let dashboardUrl = null;
2808
2921
  if (opts.ui !== false) {
2809
- const dashPort = String(opts.dashboardPort || "3001");
2810
2922
  const uiSpinner = ora("Preparing the dashboard\u2026").start();
2811
2923
  try {
2812
2924
  const bundle = await ensureDashboard({
2813
- tag: opts.uiVersion || `v${"0.1.11"}`,
2925
+ tag: opts.uiVersion || `v${"0.2.1"}`,
2814
2926
  onProgress: (received, total) => {
2815
2927
  if (total) {
2816
2928
  uiSpinner.text = `Downloading dashboard\u2026 ${Math.round(received / total * 100)}%`;
@@ -2825,12 +2937,18 @@ async function runForeground(opts) {
2825
2937
  NODE_ENV: "production",
2826
2938
  OPENSHIP_TARGET: "local",
2827
2939
  PORT: dashPort,
2828
- HOSTNAME: "127.0.0.1",
2829
- // The dashboard reads this (SSR) and mirrors it into the browser as
2830
- // window.__OPENSHIP_API_ORIGIN__ so both target our local API. It
2831
- // does NOT read API_INTERNAL_URL using that leaves it defaulting
2832
- // to :4000 (possibly a different instance).
2833
- OPENSHIP_LOCAL_API_URL: `http://127.0.0.1:${port}`
2940
+ // Reachable remotely when public; loopback-only otherwise. Under
2941
+ // managed edge the local OpenResty fronts the dashboard, so it stays
2942
+ // on loopback even though there's a public URL.
2943
+ HOSTNAME: publicUrl && !managedEdge ? "0.0.0.0" : "127.0.0.1",
2944
+ // The dashboard's same-origin proxy (NEXT_PUBLIC_API_PROXY, baked
2945
+ // into the release build) forwards /api/proxy/* to this address, so
2946
+ // the browser never needs to know where the API lives. Set in every
2947
+ // mode; loopback because the dashboard runs on the same box.
2948
+ INTERNAL_API_URL: `http://127.0.0.1:${port}`,
2949
+ // Public URL feeds the SSR proxy-origin resolver; local mode keeps
2950
+ // the window.__OPENSHIP_API_ORIGIN__ fallback for direct API calls.
2951
+ ...publicUrl ? { OPENSHIP_PUBLIC_URL: publicUrl } : { OPENSHIP_LOCAL_API_URL: `http://127.0.0.1:${port}` }
2834
2952
  },
2835
2953
  stdio: ["ignore", "pipe", "pipe"]
2836
2954
  });
@@ -2854,7 +2972,7 @@ async function runForeground(opts) {
2854
2972
  }
2855
2973
  }
2856
2974
  if (dashUp) {
2857
- dashboardUrl = `http://localhost:${dashPort}`;
2975
+ dashboardUrl = publicUrl ?? `http://localhost:${dashPort}`;
2858
2976
  uiSpinner.succeed(`Dashboard running at ${dashboardUrl}`);
2859
2977
  dash.stdout.off("data", onDash);
2860
2978
  dash.stderr.off("data", onDash);
@@ -2873,12 +2991,20 @@ async function runForeground(opts) {
2873
2991
  );
2874
2992
  }
2875
2993
  }
2876
- console.log(
2877
- chalk5.dim(` API: http://localhost:${port}/api
2878
- `) + (dashboardUrl ? chalk5.dim(` Dashboard: ${dashboardUrl}
2994
+ if (publicUrl) {
2995
+ console.log(
2996
+ (dashboardUrl ? chalk5.dim(` Dashboard: ${dashboardUrl} (login required)
2997
+ `) : "") + chalk5.dim(" API is proxied through the dashboard (bound to loopback, not exposed).\n") + chalk5.dim(` Data: ${dataDir}
2998
+ `) + (managedEdge ? chalk5.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") : chalk5.dim(" Point your reverse proxy / DNS at the dashboard port. Stop with Ctrl-C.\n"))
2999
+ );
3000
+ } else {
3001
+ console.log(
3002
+ chalk5.dim(` API: http://localhost:${port}/api
3003
+ `) + (dashboardUrl ? chalk5.dim(` Dashboard: ${dashboardUrl} (login required)
2879
3004
  `) : "") + chalk5.dim(` Data: ${dataDir}
2880
- `) + chalk5.dim(" Local access needs no token (loopback). Stop with Ctrl-C.\n")
2881
- );
3005
+ `) + chalk5.dim(" Log in with your admin account (run `openship` to create one). Stop with Ctrl-C.\n")
3006
+ );
3007
+ }
2882
3008
  child.stdout.off("data", buffer);
2883
3009
  child.stderr.off("data", buffer);
2884
3010
  child.stdout.on("data", (d) => process.stdout.write(d));
@@ -3402,7 +3528,7 @@ var deployCommand = new Command10("deploy").description("Trigger a deployment fo
3402
3528
  let deploymentId;
3403
3529
  let payload;
3404
3530
  if (!inGitRepo && !gitOnlyFlags) {
3405
- const spinner2 = isJsonMode() ? null : ora2("Deploying folder").start();
3531
+ const spinner3 = isJsonMode() ? null : ora2("Deploying folder").start();
3406
3532
  try {
3407
3533
  const result2 = await deployFolder({
3408
3534
  cwd: process.cwd(),
@@ -3410,14 +3536,14 @@ var deployCommand = new Command10("deploy").description("Trigger a deployment fo
3410
3536
  projectId: opts.project || link?.projectId,
3411
3537
  environment: env,
3412
3538
  onStep: (m) => {
3413
- if (spinner2) spinner2.text = m;
3539
+ if (spinner3) spinner3.text = m;
3414
3540
  }
3415
3541
  });
3416
3542
  deploymentId = result2.deploymentId;
3417
3543
  payload = { success: true, deployment_id: result2.deploymentId, project_id: result2.projectId };
3418
- spinner2?.succeed(deploymentId ? `Deployment queued: ${deploymentId}` : "Deployment queued");
3544
+ spinner3?.succeed(deploymentId ? `Deployment queued: ${deploymentId}` : "Deployment queued");
3419
3545
  } catch (e) {
3420
- spinner2?.fail("Folder deploy failed");
3546
+ spinner3?.fail("Folder deploy failed");
3421
3547
  err(e instanceof ApiError ? e.message : String(e));
3422
3548
  process.exit(1);
3423
3549
  }
@@ -3439,7 +3565,7 @@ var deployCommand = new Command10("deploy").description("Trigger a deployment fo
3439
3565
  smartRoute: opts.smartRoute || void 0,
3440
3566
  refresh: opts.refresh || void 0
3441
3567
  };
3442
- const spinner2 = isJsonMode() ? null : ora2("Triggering deployment").start();
3568
+ const spinner3 = isJsonMode() ? null : ora2("Triggering deployment").start();
3443
3569
  let res;
3444
3570
  try {
3445
3571
  res = await apiRequest("/deployments", {
@@ -3447,13 +3573,13 @@ var deployCommand = new Command10("deploy").description("Trigger a deployment fo
3447
3573
  body: JSON.stringify(body)
3448
3574
  });
3449
3575
  } catch (e) {
3450
- spinner2?.fail("Deployment failed to start");
3576
+ spinner3?.fail("Deployment failed to start");
3451
3577
  err(e instanceof ApiError ? e.message : String(e));
3452
3578
  process.exit(1);
3453
3579
  }
3454
3580
  deploymentId = res.data?.deployment_id;
3455
3581
  payload = res.data ?? res;
3456
- spinner2?.succeed(deploymentId ? `Deployment queued: ${deploymentId}` : "Deployment queued");
3582
+ spinner3?.succeed(deploymentId ? `Deployment queued: ${deploymentId}` : "Deployment queued");
3457
3583
  }
3458
3584
  if (isJsonMode() && !opts.watch) {
3459
3585
  printJson(payload ?? {});
@@ -3585,7 +3711,7 @@ var cancel = new Command11("cancel").description("Cancel an in-progress deployme
3585
3711
  report(res, `Cancelled ${id}`);
3586
3712
  })
3587
3713
  );
3588
- var restart = new Command11("restart").description("Restart a deployment's container").argument("<id>", "Deployment ID").action(
3714
+ var restart2 = new Command11("restart").description("Restart a deployment's container").argument("<id>", "Deployment ID").action(
3589
3715
  run2(async (id) => {
3590
3716
  const res = await apiRequest(`/deployments/${id}/restart`, { method: "POST" });
3591
3717
  report(res, `Restarted ${id}`);
@@ -3632,7 +3758,7 @@ var sslRenew = new Command11("renew").description("Renew (issue) an SSL certific
3632
3758
  })
3633
3759
  );
3634
3760
  var ssl = new Command11("ssl").description("SSL certificate operations").addCommand(sslStatus).addCommand(sslRenew);
3635
- var deploymentCommand = new Command11("deployment").alias("deployments").description("Manage deployments (list, inspect, redeploy, rollback, \u2026)").addCommand(list).addCommand(get).addCommand(info2).addCommand(usage).addCommand(redeploy).addCommand(rollback).addCommand(pin).addCommand(cancel).addCommand(restart).addCommand(reject).addCommand(keep).addCommand(rm).addCommand(ssl);
3761
+ var deploymentCommand = new Command11("deployment").alias("deployments").description("Manage deployments (list, inspect, redeploy, rollback, \u2026)").addCommand(list).addCommand(get).addCommand(info2).addCommand(usage).addCommand(redeploy).addCommand(rollback).addCommand(pin).addCommand(cancel).addCommand(restart2).addCommand(reject).addCommand(keep).addCommand(rm).addCommand(ssl);
3636
3762
 
3637
3763
  // src/commands/logs.ts
3638
3764
  import { Command as Command12 } from "commander";
@@ -4666,8 +4792,8 @@ serviceCommand.addCommand(execCmd);
4666
4792
  import { Command as Command15 } from "commander";
4667
4793
  import chalk11 from "chalk";
4668
4794
  import ora3 from "ora";
4669
- function spin(text) {
4670
- return isJsonMode() ? null : ora3(text).start();
4795
+ function spin(text2) {
4796
+ return isJsonMode() ? null : ora3(text2).start();
4671
4797
  }
4672
4798
  function fail2(e) {
4673
4799
  if (e instanceof ApiError) {
@@ -4925,31 +5051,31 @@ server.command("rm <id>").alias("remove").description("Delete a server").action(
4925
5051
  );
4926
5052
  server.command("test-connection").alias("test").description("Test an SSH connection without saving it").requiredOption("--host <host>", "SSH host / IP").option("--port <port>", "SSH port", "22").option("--user <user>", "SSH user", "root").option("--auth-method <method>", "Auth method (password|key|agent)").option("--password <password>", "SSH password").option("--key-path <path>", "Path to private key").option("--key-passphrase <passphrase>", "Private key passphrase").option("--jump-host <host>", "SSH jump / bastion host").option("--ssh-args <args>", "Extra raw ssh args").action(
4927
5053
  guard(async (o) => {
4928
- const spinner2 = isJsonMode() ? null : ora4(`Connecting to ${o.host}\u2026`).start();
5054
+ const spinner3 = isJsonMode() ? null : ora4(`Connecting to ${o.host}\u2026`).start();
4929
5055
  try {
4930
5056
  const res = await apiRequest("/system/test-connection", {
4931
5057
  method: "POST",
4932
5058
  body: JSON.stringify(connBody(o))
4933
5059
  });
4934
- spinner2?.stop();
5060
+ spinner3?.stop();
4935
5061
  if (isJsonMode()) return printJson(res);
4936
5062
  if (res.ok) return ok(` ${res.message}`);
4937
5063
  err(` ${res.message}`);
4938
5064
  process.exit(1);
4939
5065
  } catch (e) {
4940
- spinner2?.stop();
5066
+ spinner3?.stop();
4941
5067
  throw e;
4942
5068
  }
4943
5069
  })
4944
5070
  );
4945
5071
  server.command("check <serverId>").description("Run component health checks against a saved server").option("-c, --component <name...>", "Limit to specific components").action(
4946
5072
  guard(async (serverId, o) => {
4947
- const spinner2 = isJsonMode() ? null : ora4("Checking components\u2026").start();
5073
+ const spinner3 = isJsonMode() ? null : ora4("Checking components\u2026").start();
4948
5074
  const res = await apiRequest("/system/check", {
4949
5075
  method: "POST",
4950
5076
  body: JSON.stringify({ serverId, components: o.component })
4951
5077
  });
4952
- spinner2?.stop();
5078
+ spinner3?.stop();
4953
5079
  if (isJsonMode()) return printJson(res);
4954
5080
  printTable(
4955
5081
  res.components.map((c) => ({
@@ -5005,15 +5131,15 @@ server.command("install <serverId>").description("Install components on a server
5005
5131
  }
5006
5132
  const results = [];
5007
5133
  for (const component of components) {
5008
- const spinner2 = isJsonMode() ? null : ora4(`Installing ${component}\u2026`).start();
5134
+ const spinner3 = isJsonMode() ? null : ora4(`Installing ${component}\u2026`).start();
5009
5135
  const res = await apiRequest(
5010
5136
  "/system/install",
5011
5137
  { method: "POST", body: JSON.stringify({ serverId, component }) }
5012
5138
  );
5013
5139
  results.push(res);
5014
- if (isJsonMode()) spinner2?.stop();
5015
- else if (res.success) spinner2?.succeed(`${component} installed${res.version ? ` (${res.version})` : ""}`);
5016
- else spinner2?.fail(`${component} failed: ${res.error ?? "unknown error"}`);
5140
+ if (isJsonMode()) spinner3?.stop();
5141
+ else if (res.success) spinner3?.succeed(`${component} installed${res.version ? ` (${res.version})` : ""}`);
5142
+ else spinner3?.fail(`${component} failed: ${res.error ?? "unknown error"}`);
5017
5143
  }
5018
5144
  if (isJsonMode()) printJson(results);
5019
5145
  })
@@ -5128,8 +5254,8 @@ function report2(obj, human) {
5128
5254
  if (isJsonMode()) printJson(obj);
5129
5255
  else human();
5130
5256
  }
5131
- function spinner(text) {
5132
- return isJsonMode() ? null : ora5(text).start();
5257
+ function spinner(text2) {
5258
+ return isJsonMode() ? null : ora5(text2).start();
5133
5259
  }
5134
5260
  async function confirm2(message, yes) {
5135
5261
  if (yes) return true;
@@ -5230,13 +5356,13 @@ var upgradeToAuthCommand = new Command17("upgrade-to-auth").description("Promote
5230
5356
  await guarded(async () => {
5231
5357
  const name = opts.name;
5232
5358
  const email = opts.email;
5233
- let password = opts.password;
5234
- if (!password) {
5359
+ let password2 = opts.password;
5360
+ if (!password2) {
5235
5361
  if (!process.stdin.isTTY || isJsonMode()) {
5236
5362
  err("\n --password is required in non-interactive mode.\n");
5237
5363
  process.exit(1);
5238
5364
  }
5239
- password = await promptHidden(" New password: ");
5365
+ password2 = await promptHidden(" New password: ");
5240
5366
  }
5241
5367
  const res = await apiRequest(
5242
5368
  "/system/upgrade-to-auth",
@@ -5245,7 +5371,7 @@ var upgradeToAuthCommand = new Command17("upgrade-to-auth").description("Promote
5245
5371
  body: JSON.stringify({
5246
5372
  name,
5247
5373
  email,
5248
- password,
5374
+ password: password2,
5249
5375
  useOwnMailServer: opts.useOwnMailServer === true
5250
5376
  })
5251
5377
  }
@@ -5484,8 +5610,8 @@ function guard2(fn) {
5484
5610
  }
5485
5611
  };
5486
5612
  }
5487
- function spin2(text) {
5488
- return isJsonMode() ? null : ora6(text).start();
5613
+ function spin2(text2) {
5614
+ return isJsonMode() ? null : ora6(text2).start();
5489
5615
  }
5490
5616
  function safeParse4(data) {
5491
5617
  try {
@@ -5747,24 +5873,24 @@ var postmasterCmd = new Command18("postmaster").description("Manage the postmast
5747
5873
  postmasterCmd.addCommand(
5748
5874
  new Command18("set-password").description("Rotate the postmaster password").argument("<serverId>", "Mail server ID").option("--password <password>", "New password (min 12 chars); prompted if omitted").action(
5749
5875
  guard2(async (serverId, opts) => {
5750
- let password = opts.password;
5751
- if (!password) {
5876
+ let password2 = opts.password;
5877
+ if (!password2) {
5752
5878
  if (isJsonMode()) {
5753
5879
  err(" --password is required in JSON mode.");
5754
5880
  process.exit(1);
5755
5881
  }
5756
5882
  const rl = createInterface7({ input: input6, output: output6 });
5757
- password = (await rl.question(" New postmaster password (min 12 chars): ")).trim();
5883
+ password2 = (await rl.question(" New postmaster password (min 12 chars): ")).trim();
5758
5884
  rl.close();
5759
5885
  }
5760
- if (!password || password.length < 12) {
5886
+ if (!password2 || password2.length < 12) {
5761
5887
  err(" Password must be at least 12 characters.");
5762
5888
  process.exit(1);
5763
5889
  }
5764
5890
  const sp = spin2("Updating postmaster password\u2026");
5765
5891
  const res = await apiRequest("/mail/credentials/postmaster", {
5766
5892
  method: "POST",
5767
- body: JSON.stringify({ serverId, password })
5893
+ body: JSON.stringify({ serverId, password: password2 })
5768
5894
  });
5769
5895
  sp?.succeed("Postmaster password updated.");
5770
5896
  if (isJsonMode()) printJson(res);
@@ -5820,7 +5946,7 @@ function fmtBytes2(n) {
5820
5946
  return `${v.toFixed(i === 0 ? 0 : 1)}${units[i]}`;
5821
5947
  }
5822
5948
  async function followStream(path2, label) {
5823
- const spinner2 = isJsonMode() ? null : ora7(`${label}: connecting\u2026`).start();
5949
+ const spinner3 = isJsonMode() ? null : ora7(`${label}: connecting\u2026`).start();
5824
5950
  let status = "unknown";
5825
5951
  try {
5826
5952
  for await (const ev of sseRequest(path2)) {
@@ -5835,28 +5961,28 @@ async function followStream(path2, label) {
5835
5961
  if (type === "snapshot") {
5836
5962
  const rec = payload.run ?? payload.restore;
5837
5963
  status = rec?.status ?? status;
5838
- if (spinner2) spinner2.text = `${label}: ${status}`;
5964
+ if (spinner3) spinner3.text = `${label}: ${status}`;
5839
5965
  } else if (type === "transition") {
5840
5966
  status = payload.status ?? status;
5841
5967
  const bytes = payload.bytesTransferred ?? payload.bytesRestored;
5842
- if (spinner2) spinner2.text = `${label}: ${status}${bytes ? ` (${fmtBytes2(bytes)})` : ""}`;
5968
+ if (spinner3) spinner3.text = `${label}: ${status}${bytes ? ` (${fmtBytes2(bytes)})` : ""}`;
5843
5969
  } else if (type === "progress") {
5844
5970
  const artifact = payload.currentArtifact ?? "working";
5845
5971
  const bytes = payload.bytesTransferred;
5846
- if (spinner2) spinner2.text = `${label}: ${artifact} ${fmtBytes2(bytes)}`.trimEnd();
5972
+ if (spinner3) spinner3.text = `${label}: ${artifact} ${fmtBytes2(bytes)}`.trimEnd();
5847
5973
  } else if (type === "complete") {
5848
5974
  status = payload.status ?? status;
5849
5975
  const errMsg = payload.errorMessage;
5850
- if (status === "succeeded") spinner2?.succeed(`${label} succeeded`);
5851
- else spinner2?.fail(`${label} ${status}${errMsg ? `: ${errMsg}` : ""}`);
5976
+ if (status === "succeeded") spinner3?.succeed(`${label} succeeded`);
5977
+ else spinner3?.fail(`${label} ${status}${errMsg ? `: ${errMsg}` : ""}`);
5852
5978
  break;
5853
5979
  }
5854
5980
  }
5855
5981
  } catch (e) {
5856
- spinner2?.stop();
5982
+ spinner3?.stop();
5857
5983
  throw e;
5858
5984
  }
5859
- spinner2?.stop();
5985
+ spinner3?.stop();
5860
5986
  return status;
5861
5987
  }
5862
5988
  var policyCmd = new Command19("policy").description("Backup policies (schedules) for a project");
@@ -6104,7 +6230,7 @@ destinationCmd.command("create").description("Create a backup destination").requ
6104
6230
  );
6105
6231
  destinationCmd.command("preflight").description("Verify a destination (write + read + delete a probe object)").argument("<destinationId>", "Destination ID").action(
6106
6232
  (destinationId) => guard3(async () => {
6107
- const spinner2 = isJsonMode() ? null : ora7("Running preflight\u2026").start();
6233
+ const spinner3 = isJsonMode() ? null : ora7("Running preflight\u2026").start();
6108
6234
  const { data } = await apiRequest(
6109
6235
  `/backup-destinations/${encodeURIComponent(destinationId)}/preflight`,
6110
6236
  { method: "POST", body: JSON.stringify({}) }
@@ -6113,9 +6239,9 @@ destinationCmd.command("preflight").description("Verify a destination (write + r
6113
6239
  printJson(data);
6114
6240
  return;
6115
6241
  }
6116
- if (data.ok) spinner2?.succeed("Destination reachable");
6242
+ if (data.ok) spinner3?.succeed("Destination reachable");
6117
6243
  else {
6118
- spinner2?.fail(`Preflight failed: ${data.reason ?? "unknown"}`);
6244
+ spinner3?.fail(`Preflight failed: ${data.reason ?? "unknown"}`);
6119
6245
  process.exit(1);
6120
6246
  }
6121
6247
  })
@@ -6129,8 +6255,8 @@ import chalk15 from "chalk";
6129
6255
  // src/lib/cmd-helpers.ts
6130
6256
  import chalk14 from "chalk";
6131
6257
  import ora8 from "ora";
6132
- function spin3(text) {
6133
- return isJsonMode() ? null : ora8(text).start();
6258
+ function spin3(text2) {
6259
+ return isJsonMode() ? null : ora8(text2).start();
6134
6260
  }
6135
6261
  function fail3(e) {
6136
6262
  if (e instanceof ApiError) {
@@ -6239,10 +6365,10 @@ var apiCommand = new Command21("api").description("Make an authenticated request
6239
6365
  if (opts.data) init.body = opts.data;
6240
6366
  try {
6241
6367
  const res = await apiRaw(url, init);
6242
- const text = await res.text();
6243
- let body = text;
6368
+ const text2 = await res.text();
6369
+ let body = text2;
6244
6370
  try {
6245
- body = JSON.parse(text);
6371
+ body = JSON.parse(text2);
6246
6372
  } catch {
6247
6373
  }
6248
6374
  if (typeof body === "string") {
@@ -6350,7 +6476,7 @@ var installCommand = new Command22("install").description("Download and install
6350
6476
  err(e.message);
6351
6477
  process.exit(1);
6352
6478
  }
6353
- const spin4 = (text) => isJsonMode() ? null : ora9(text).start();
6479
+ const spin4 = (text2) => isJsonMode() ? null : ora9(text2).start();
6354
6480
  let tag;
6355
6481
  try {
6356
6482
  if (opts.version) {
@@ -6465,7 +6591,7 @@ function detectPackageManager2(override) {
6465
6591
  return hasBun ? "bun" : "npm";
6466
6592
  }
6467
6593
  var updateCommand = new Command23("update").description("Update the Openship CLI + bundled server to the latest release").option("--check", "Only report the current + latest version; don't install").option("--via <manager>", "Package manager to update with: bun | npm").action(async (opts) => {
6468
- const current = "0.1.11";
6594
+ const current = "0.2.1";
6469
6595
  let latest;
6470
6596
  try {
6471
6597
  latest = (await resolveLatestTag()).replace(/^v/, "");
@@ -6499,8 +6625,11 @@ var updateCommand = new Command23("update").description("Update the Openship CLI
6499
6625
  process.exitCode = 1;
6500
6626
  return;
6501
6627
  }
6628
+ const { restarted } = restart();
6502
6629
  if (isJsonMode()) {
6503
- printJson({ updated: true, from: current, to: latest, via: pm });
6630
+ printJson({ updated: true, from: current, to: latest, via: pm, restarted });
6631
+ } else if (restarted) {
6632
+ ok(`Updated to v${latest} and restarted the service \u2014 you're on the new version.`);
6504
6633
  } else {
6505
6634
  ok(`Updated to v${latest}. Restart the server to run the new version: openship up`);
6506
6635
  }
@@ -6589,10 +6718,457 @@ var cacheCommand = new Command24("cache").description("Manage the local download
6589
6718
  process.exit(1);
6590
6719
  }).addCommand(pathCmd).addCommand(listCmd6).addCommand(verifyCmd2).addCommand(cleanCmd);
6591
6720
 
6721
+ // src/commands/wizard.ts
6722
+ import chalk16 from "chalk";
6723
+ import open from "open";
6724
+ import { createServer } from "http";
6725
+ import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
6726
+ import {
6727
+ intro,
6728
+ outro,
6729
+ text,
6730
+ password,
6731
+ select,
6732
+ confirm as confirm3,
6733
+ spinner as spinner2,
6734
+ note,
6735
+ log,
6736
+ cancel as cancel2,
6737
+ isCancel
6738
+ } from "@clack/prompts";
6739
+ function ensure(value) {
6740
+ if (isCancel(value)) {
6741
+ cancel2("Setup cancelled.");
6742
+ process.exit(0);
6743
+ }
6744
+ return value;
6745
+ }
6746
+ var SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
6747
+ async function internalGet(port, path2) {
6748
+ try {
6749
+ const res = await fetch(`http://127.0.0.1:${port}${path2}`, {
6750
+ headers: { "X-Internal-Token": ensureInternalToken() },
6751
+ signal: AbortSignal.timeout(1e4)
6752
+ });
6753
+ if (!res.ok) return null;
6754
+ return await res.json();
6755
+ } catch {
6756
+ return null;
6757
+ }
6758
+ }
6759
+ async function internalPost(port, path2, body) {
6760
+ try {
6761
+ const res = await fetch(`http://127.0.0.1:${port}${path2}`, {
6762
+ method: "POST",
6763
+ headers: { "Content-Type": "application/json", "X-Internal-Token": ensureInternalToken() },
6764
+ body: JSON.stringify(body),
6765
+ signal: AbortSignal.timeout(3e4)
6766
+ });
6767
+ const data = await res.json().catch(() => ({}));
6768
+ return { ok: res.ok, data };
6769
+ } catch (err2) {
6770
+ return { ok: false, data: { error: err2.message } };
6771
+ }
6772
+ }
6773
+ async function bootstrapAdmin(apiPort, admin) {
6774
+ const { ok: ok3, data } = await internalPost(apiPort, "/api/system/bootstrap-admin", admin);
6775
+ if (ok3) return { ok: true };
6776
+ if (data?.error === "An admin account already exists") return { ok: true, message: "already-exists" };
6777
+ return { ok: false, message: data?.error || "failed" };
6778
+ }
6779
+ async function waitHealthy(apiPort, seconds = 90) {
6780
+ for (let i = 0; i < seconds; i++) {
6781
+ await new Promise((r) => setTimeout(r, 1e3));
6782
+ try {
6783
+ await fetch(`http://127.0.0.1:${apiPort}/api/health`, { signal: AbortSignal.timeout(2e3) });
6784
+ return true;
6785
+ } catch {
6786
+ }
6787
+ }
6788
+ return false;
6789
+ }
6790
+ async function detectPublicIp() {
6791
+ for (const url of ["https://api.ipify.org", "https://ifconfig.me/ip"]) {
6792
+ try {
6793
+ const res = await fetch(url, { signal: AbortSignal.timeout(3e3) });
6794
+ if (!res.ok) continue;
6795
+ const ip = (await res.text()).trim();
6796
+ if (/^[0-9.]+$/.test(ip) || ip.includes(":")) return ip;
6797
+ } catch {
6798
+ }
6799
+ }
6800
+ return null;
6801
+ }
6802
+ var b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6803
+ async function connectOpenshipCloud(port) {
6804
+ const already = await internalGet(port, "/api/system/cloud-status");
6805
+ if (already?.connected) {
6806
+ log.success(`Already connected to Openship Cloud${already.user?.email ? ` as ${already.user.email}` : ""}.`);
6807
+ return true;
6808
+ }
6809
+ const capsEnv = await internalGet(port, "/api/health/env");
6810
+ const cloudApiUrl = capsEnv?.cloudApiUrl;
6811
+ if (!cloudApiUrl) {
6812
+ log.error("Couldn't discover the Openship Cloud URL \u2014 free domain unavailable. Use a custom domain instead.");
6813
+ return false;
6814
+ }
6815
+ const verifier = b64url(randomBytes2(32));
6816
+ const challenge = b64url(createHash2("sha256").update(verifier).digest());
6817
+ const state = b64url(randomBytes2(16));
6818
+ const codePromise = new Promise((resolve2) => {
6819
+ const server2 = createServer((req, res2) => {
6820
+ const u = new URL(req.url || "/", "http://127.0.0.1");
6821
+ if (!u.pathname.startsWith("/callback")) {
6822
+ res2.writeHead(404).end();
6823
+ return;
6824
+ }
6825
+ const code2 = u.searchParams.get("code");
6826
+ const gotState = u.searchParams.get("state");
6827
+ res2.writeHead(200, { "Content-Type": "text/html" }).end(
6828
+ "<html><body style='font:16px system-ui;padding:3rem;text-align:center'><h2>Openship Cloud connected</h2><p>You can close this window and return to your terminal.</p></body></html>"
6829
+ );
6830
+ server2.close();
6831
+ resolve2(code2 && gotState === state ? code2 : null);
6832
+ });
6833
+ server2.on("error", () => resolve2(null));
6834
+ server2.listen(0, "127.0.0.1", () => {
6835
+ const cbPort = server2.address().port;
6836
+ const redirect = `http://127.0.0.1:${cbPort}/callback`;
6837
+ const handoff = `${cloudApiUrl.replace(/\/$/, "")}/api/cloud/connect-handoff?redirect=${encodeURIComponent(redirect)}&state=${state}&code_challenge=${challenge}`;
6838
+ note(handoff, "Open this URL to authorize (opening your browser\u2026)");
6839
+ void open(handoff).catch(() => {
6840
+ });
6841
+ });
6842
+ setTimeout(() => {
6843
+ try {
6844
+ server2.close();
6845
+ } catch {
6846
+ }
6847
+ resolve2(null);
6848
+ }, 3e5);
6849
+ });
6850
+ const s = spinner2();
6851
+ s.start("Waiting for Openship Cloud authorization in your browser");
6852
+ const code = await codePromise;
6853
+ if (!code) {
6854
+ s.stop("Openship Cloud wasn't authorized.", 1);
6855
+ return false;
6856
+ }
6857
+ s.message("Linking this instance to Openship Cloud");
6858
+ const res = await internalPost(port, "/api/system/cloud-connect", { code, codeVerifier: verifier });
6859
+ if (!res.ok) {
6860
+ s.stop(`Couldn't link Openship Cloud: ${res.data?.error || "failed"}`, 1);
6861
+ return false;
6862
+ }
6863
+ s.stop("Connected to Openship Cloud.");
6864
+ return true;
6865
+ }
6866
+ async function promptLocalAdmin() {
6867
+ const name = ensure(await text({ message: "Your name", validate: (v) => v?.trim() ? void 0 : "Required" })).trim();
6868
+ const email = ensure(
6869
+ await text({
6870
+ message: "Email",
6871
+ placeholder: "you@example.com",
6872
+ validate: (v) => v?.includes("@") ? void 0 : "Enter a valid email"
6873
+ })
6874
+ ).trim().toLowerCase();
6875
+ const pw = ensure(
6876
+ await password({ message: "Password", validate: (v) => v && v.length >= 8 ? void 0 : "At least 8 characters" })
6877
+ );
6878
+ ensure(await password({ message: "Confirm password", validate: (v) => v === pw ? void 0 : "Passwords don't match" }));
6879
+ return { name, email, password: pw };
6880
+ }
6881
+ async function streamProvision(port, sessionId, s) {
6882
+ let ok3 = false;
6883
+ try {
6884
+ const res = await fetch(`http://127.0.0.1:${port}/api/system/self-register/stream?id=${sessionId}`, {
6885
+ headers: { "X-Internal-Token": ensureInternalToken() },
6886
+ signal: AbortSignal.timeout(3e5)
6887
+ });
6888
+ if (!res.ok || !res.body) return false;
6889
+ const reader = res.body.getReader();
6890
+ const decoder = new TextDecoder();
6891
+ let buffer = "";
6892
+ for (; ; ) {
6893
+ const { value, done } = await reader.read();
6894
+ if (done) break;
6895
+ buffer += decoder.decode(value, { stream: true });
6896
+ let sep;
6897
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
6898
+ const frame = buffer.slice(0, sep);
6899
+ buffer = buffer.slice(sep + 2);
6900
+ const event = /event:\s*(.*)/.exec(frame)?.[1]?.trim();
6901
+ const dataRaw = /data:\s*([\s\S]*)/.exec(frame)?.[1]?.trim();
6902
+ if (!event) continue;
6903
+ if (event === "log" && dataRaw) {
6904
+ try {
6905
+ const d = JSON.parse(dataRaw);
6906
+ if (d.message) s.message(String(d.message).replace(/\s+/g, " ").slice(0, 68));
6907
+ } catch {
6908
+ }
6909
+ } else if (event === "complete" && dataRaw) {
6910
+ try {
6911
+ ok3 = JSON.parse(dataRaw).status === "completed";
6912
+ } catch {
6913
+ }
6914
+ } else if (event === "end") {
6915
+ return ok3;
6916
+ }
6917
+ }
6918
+ }
6919
+ } catch {
6920
+ return ok3;
6921
+ }
6922
+ return ok3;
6923
+ }
6924
+ async function runWizard() {
6925
+ intro(`${chalk16.bgCyan(chalk16.black(" Openship "))}${chalk16.dim(" setup")}`);
6926
+ log.message(
6927
+ chalk16.dim(
6928
+ "Deploy Openship on this machine \u2014 a few questions, then it installs itself\nas a service, registers as an app, and prints the URL to log in."
6929
+ )
6930
+ );
6931
+ let publicUrl;
6932
+ let behindProxy = false;
6933
+ let managedEdge = false;
6934
+ let domainPlan = { type: "none" };
6935
+ const reach = ensure(
6936
+ await select({
6937
+ message: "How should this instance be reachable?",
6938
+ initialValue: "private",
6939
+ options: [
6940
+ { value: "private", label: "Private", hint: "this machine only (localhost)" },
6941
+ { value: "public", label: "Public", hint: "a server / VPS, reachable from other machines" }
6942
+ ]
6943
+ })
6944
+ );
6945
+ if (reach === "public") {
6946
+ const canManage = process.platform === "linux";
6947
+ const domainType = ensure(
6948
+ await select({
6949
+ message: "How do you want a domain + HTTPS?",
6950
+ initialValue: "free",
6951
+ options: [
6952
+ { value: "free", label: "Free domain", hint: "name.opsh.io via Openship Cloud \u2014 HTTPS handled for you" },
6953
+ ...canManage ? [{ value: "custom", label: "Custom domain", hint: "your domain + free Let's Encrypt on this box" }] : [],
6954
+ { value: "byo", label: "Bring your own", hint: "your domain, behind your own reverse proxy" }
6955
+ ]
6956
+ })
6957
+ );
6958
+ if (domainType === "free") {
6959
+ const slug = ensure(
6960
+ await text({
6961
+ message: "Choose your subdomain",
6962
+ placeholder: "my-openship",
6963
+ validate: (v) => v && SLUG_RE.test(v.trim().toLowerCase()) ? void 0 : "Lowercase letters, digits, hyphens"
6964
+ })
6965
+ ).trim().toLowerCase();
6966
+ const s2 = spinner2();
6967
+ s2.start("Detecting this server's public IP");
6968
+ const publicHost = await detectPublicIp();
6969
+ s2.stop(publicHost ? `Public IP: ${chalk16.bold(publicHost)}` : "Couldn't detect the public IP automatically.");
6970
+ publicUrl = `https://${slug}.opsh.io`;
6971
+ behindProxy = true;
6972
+ domainPlan = { type: "free", slug, publicHost };
6973
+ } else if (domainType === "custom") {
6974
+ const raw = ensure(
6975
+ await text({
6976
+ message: "Your domain",
6977
+ placeholder: "ops.example.com",
6978
+ validate: (v) => v && normalizeUrl(v) ? void 0 : "Enter a valid domain"
6979
+ })
6980
+ );
6981
+ publicUrl = normalizeUrl(raw).replace(/^http:/i, "https:");
6982
+ const hostname = new URL(publicUrl).hostname;
6983
+ managedEdge = true;
6984
+ behindProxy = true;
6985
+ if (typeof process.getuid === "function" && process.getuid() !== 0) {
6986
+ log.warn("Managed HTTPS installs OpenResty + certbot \u2014 that needs root. Re-run with sudo if it can't install.");
6987
+ }
6988
+ const s2 = spinner2();
6989
+ s2.start("Detecting this server's public IP");
6990
+ const ip = await detectPublicIp();
6991
+ s2.stop(ip ? `Public IP: ${chalk16.bold(ip)}` : "Couldn't detect the public IP automatically.");
6992
+ note(
6993
+ `Add a DNS ${chalk16.bold("A record")}:
6994
+
6995
+ ${chalk16.cyan(hostname)} \u2192 ${chalk16.cyan(ip ?? "<this server's public IP>")}
6996
+
6997
+ ` + chalk16.dim("HTTPS is issued automatically once DNS resolves (it retries for a couple minutes)."),
6998
+ "DNS"
6999
+ );
7000
+ ensure(await confirm3({ message: "A record set? (continue either way \u2014 it retries)", initialValue: true }));
7001
+ domainPlan = { type: "custom", hostname };
7002
+ } else {
7003
+ const raw = ensure(
7004
+ await text({
7005
+ message: "Your domain (served behind your proxy)",
7006
+ placeholder: "ops.example.com",
7007
+ validate: (v) => v && normalizeUrl(v) ? void 0 : "Enter a valid domain"
7008
+ })
7009
+ );
7010
+ publicUrl = normalizeUrl(raw);
7011
+ behindProxy = true;
7012
+ if (publicUrl.startsWith("http://")) {
7013
+ log.warn("Serving over plain HTTP sends passwords in cleartext \u2014 put HTTPS in front before real use.");
7014
+ }
7015
+ domainPlan = { type: "byo", hostname: new URL(publicUrl).hostname };
7016
+ }
7017
+ }
7018
+ const isCloudDomain = domainPlan.type === "free";
7019
+ const admin = isCloudDomain ? null : await promptLocalAdmin();
7020
+ const s = spinner2();
7021
+ s.start("Installing Openship as a service");
7022
+ let started;
7023
+ try {
7024
+ started = startService(
7025
+ { publicUrl, trustProxy: behindProxy, managedEdge, acmeEmail: managedEdge ? admin?.email : void 0 },
7026
+ { quiet: true }
7027
+ );
7028
+ } catch (e) {
7029
+ s.stop("Couldn't install the service.", 1);
7030
+ log.error(e.message);
7031
+ log.info("Run `openship up --foreground` to run it attached and see the error.");
7032
+ process.exit(1);
7033
+ }
7034
+ s.message("Waiting for Openship to come up");
7035
+ if (!await waitHealthy(started.port)) {
7036
+ s.stop("Openship didn't become healthy in time.", 1);
7037
+ log.info("Check logs: `openship logs` (or `openship up --foreground`).");
7038
+ process.exit(1);
7039
+ }
7040
+ if (admin) {
7041
+ s.message("Creating your admin account");
7042
+ const adminRes = await bootstrapAdmin(started.port, admin);
7043
+ if (!adminRes.ok) {
7044
+ s.stop(`Couldn't create the admin account: ${adminRes.message}`, 1);
7045
+ process.exit(1);
7046
+ }
7047
+ s.stop(
7048
+ adminRes.message === "already-exists" ? "An admin already exists \u2014 use your existing login." : `Admin account created for ${admin.email}.`
7049
+ );
7050
+ } else {
7051
+ s.stop("Openship is up.");
7052
+ }
7053
+ let liveUrl = publicUrl ?? `http://localhost:${started.dashPort}`;
7054
+ const port = started.port;
7055
+ if (domainPlan.type === "free") {
7056
+ const linked = await connectOpenshipCloud(port);
7057
+ if (!linked) {
7058
+ log.warn("Openship Cloud wasn't connected \u2014 set up a local admin instead. You can add the free domain later in Settings \u2192 Cloud.");
7059
+ const fb = await promptLocalAdmin();
7060
+ const abr = await bootstrapAdmin(port, fb);
7061
+ if (!abr.ok) {
7062
+ log.error(`Couldn't create the admin account: ${abr.message}`);
7063
+ process.exit(1);
7064
+ }
7065
+ await internalPost(port, "/api/system/self-register", { domainType: "byo" });
7066
+ } else {
7067
+ const s2 = spinner2();
7068
+ s2.start("Registering your free domain with Openship Cloud");
7069
+ const res = await internalPost(port, "/api/system/self-register", {
7070
+ domainType: "free",
7071
+ slug: domainPlan.slug,
7072
+ publicHost: domainPlan.publicHost,
7073
+ dashPort: Number(started.dashPort)
7074
+ });
7075
+ if (res.ok && res.data?.url) {
7076
+ liveUrl = res.data.url;
7077
+ s2.stop(`Free domain live: ${res.data.url}`);
7078
+ } else {
7079
+ s2.stop(`Couldn't register the free domain: ${res.data?.error || "failed"}`, 1);
7080
+ }
7081
+ }
7082
+ } else if (domainPlan.type === "custom") {
7083
+ let edgeTakeover = false;
7084
+ let edgeMigrate = false;
7085
+ let proceedCustom = true;
7086
+ const pf = await internalPost(port, "/api/system/self-edge/preflight", {});
7087
+ const status = pf.ok ? pf.data?.status : void 0;
7088
+ const importable = pf.ok && Array.isArray(pf.data?.sites) ? pf.data.sites.length : 0;
7089
+ if (status && !status.canProceedClean && status.occupants?.length) {
7090
+ const owner = status.occupants.map((o) => o.command ?? `port ${o.port}`).join(", ");
7091
+ const known = status.classification === "known";
7092
+ const choice = ensure(
7093
+ await select({
7094
+ message: known ? `An existing reverse proxy (${owner}) is serving ports 80/443.` : `Ports 80/443 are in use by ${owner}, which we couldn't identify.`,
7095
+ options: [
7096
+ ...importable > 0 ? [{
7097
+ value: "migrate",
7098
+ label: `Migrate ${importable} site${importable === 1 ? "" : "s"} & take over`,
7099
+ hint: "import the existing sites into Openship, then take 80/443"
7100
+ }] : [],
7101
+ {
7102
+ value: "override",
7103
+ label: "Stop it & take over 80/443",
7104
+ hint: known ? "the existing sites stop being served" : "may interrupt a running service"
7105
+ },
7106
+ { value: "cancel", label: "Cancel \u2014 leave it running" }
7107
+ ],
7108
+ // Per product decision: unknown owner pre-selects takeover; a known
7109
+ // proxy defaults to cancel so the user chooses deliberately.
7110
+ initialValue: known ? "cancel" : "override"
7111
+ })
7112
+ );
7113
+ if (choice === "cancel") proceedCustom = false;
7114
+ else if (choice === "migrate") edgeMigrate = true;
7115
+ else edgeTakeover = true;
7116
+ }
7117
+ if (!proceedCustom) {
7118
+ log.warn(
7119
+ "Left the existing proxy on 80/443 running. Registering Openship without managed HTTPS \u2014 front it with your proxy, or re-run setup to take over."
7120
+ );
7121
+ await internalPost(port, "/api/system/self-register", {
7122
+ domainType: "byo",
7123
+ hostname: domainPlan.hostname
7124
+ });
7125
+ liveUrl = `https://${domainPlan.hostname}`;
7126
+ } else {
7127
+ const res = await internalPost(port, "/api/system/self-register", {
7128
+ domainType: "custom",
7129
+ hostname: domainPlan.hostname,
7130
+ dashPort: Number(started.dashPort),
7131
+ acmeEmail: admin?.email,
7132
+ edgeTakeover,
7133
+ edgeMigrate
7134
+ });
7135
+ if (res.ok && res.data?.sessionId) {
7136
+ const s2 = spinner2();
7137
+ s2.start("Issuing HTTPS certificate (OpenResty + Let's Encrypt)");
7138
+ const done = await streamProvision(port, res.data.sessionId, s2);
7139
+ liveUrl = res.data.url ?? liveUrl;
7140
+ if (done) s2.stop(`HTTPS ready: ${liveUrl}`);
7141
+ else s2.stop("HTTPS isn't ready yet \u2014 it retries on reboot; the site serves over HTTP meanwhile.", 1);
7142
+ } else {
7143
+ log.warn(`Couldn't start domain provisioning: ${res.data?.error || "failed"}`);
7144
+ }
7145
+ }
7146
+ } else if (domainPlan.type === "byo") {
7147
+ const res = await internalPost(port, "/api/system/self-register", {
7148
+ domainType: "byo",
7149
+ hostname: domainPlan.hostname
7150
+ });
7151
+ if (res.ok && res.data?.url) liveUrl = res.data.url;
7152
+ } else {
7153
+ await internalPost(port, "/api/system/self-register", { domainType: "byo" });
7154
+ }
7155
+ note(
7156
+ `${chalk16.bold(liveUrl)}
7157
+
7158
+ ` + chalk16.dim(`${admin ? `Log in as ${admin.email}` : "Log in with Openship Cloud"}. Openship now appears under your Apps, and runs as a service (restarts on boot).`),
7159
+ "Openship is live"
7160
+ );
7161
+ outro(
7162
+ domainPlan.type === "byo" ? chalk16.dim("Point your reverse proxy at the dashboard port above.") : chalk16.green("Happy shipping.")
7163
+ );
7164
+ }
7165
+
6592
7166
  // src/index.ts
6593
7167
  var program = new Command25();
6594
- program.name("openship").description("Openship CLI \u2014 install, run, and manage Openship from your terminal").version("0.1.11").option("--json", "Machine-readable JSON output (stdout data only)").hook("preAction", (thisCommand) => {
7168
+ program.name("openship").description("Openship CLI \u2014 install, run, and manage Openship from your terminal").version("0.2.1").option("--json", "Machine-readable JSON output (stdout data only)").hook("preAction", (thisCommand) => {
6595
7169
  if (thisCommand.opts().json) setJsonMode(true);
7170
+ }).action(async () => {
7171
+ await runWizard();
6596
7172
  });
6597
7173
  program.addCommand(upCommand);
6598
7174
  program.addCommand(stopCommand);