openship 0.2.1 → 0.2.2

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.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command25 } from "commander";
4
+ import { Command as Command26 } from "commander";
5
5
 
6
6
  // src/lib/output.ts
7
7
  import chalk from "chalk";
@@ -905,10 +905,17 @@ var PLANS = {
905
905
  description: "Get started for free",
906
906
  price: { monthly: 0, annual: 0 },
907
907
  stripePriceId: { monthly: null, annual: null },
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,
908
+ // Public dollar `price` on the paid tiers is `null` the UI renders
909
+ // "coming soon" and the Subscribe CTA stays disabled. Cloud pricing is
910
+ // intentionally NOT published yet. `monthlyCredits` (the CPU-time/credit
911
+ // quota pushed to Oblien) is real so tiers still enforce — it's an internal
912
+ // number, never shown as a price. NOTE (tune before launch): these credit
913
+ // numbers are placeholders sized to Oblien's 10,000,000-credit ceiling; the
914
+ // true credit-per-$/per-cpu-minute rate is Oblien-configured. 1 openship
915
+ // credit = 1 Oblien credit = 1000 milli (the wrapper divides by 1000 at the
916
+ // Oblien boundary). Self-hosted is free and surfaces none of these numbers.
917
+ monthlyCredits: 5e5,
918
+ // 500 credits — free-tier allowance
912
919
  oblienLimits: {
913
920
  max_workspaces: 1,
914
921
  max_vcpus: 2,
@@ -927,12 +934,13 @@ var PLANS = {
927
934
  name: "Pro",
928
935
  description: "For solo builders shipping production workloads",
929
936
  price: { monthly: null, annual: null },
930
- // coming soon
937
+ // coming soon — pricing not published
931
938
  stripePriceId: {
932
939
  monthly: process.env.STRIPE_PRICE_PRO_MONTHLY ?? "price_pro_monthly_placeholder",
933
940
  annual: process.env.STRIPE_PRICE_PRO_ANNUAL ?? "price_pro_annual_placeholder"
934
941
  },
935
- monthlyCredits: null,
942
+ monthlyCredits: 1e7,
943
+ // 10,000 credits/mo (placeholder — tune before launch)
936
944
  oblienLimits: {
937
945
  max_workspaces: 10,
938
946
  max_vcpus: 16,
@@ -951,12 +959,13 @@ var PLANS = {
951
959
  name: "Team",
952
960
  description: "For teams collaborating on shared infra",
953
961
  price: { monthly: null, annual: null },
954
- // coming soon
962
+ // coming soon — pricing not published
955
963
  stripePriceId: {
956
964
  monthly: process.env.STRIPE_PRICE_TEAM_MONTHLY ?? "price_team_monthly_placeholder",
957
965
  annual: process.env.STRIPE_PRICE_TEAM_ANNUAL ?? "price_team_annual_placeholder"
958
966
  },
959
- monthlyCredits: null,
967
+ monthlyCredits: 6e7,
968
+ // 60,000 credits/mo (placeholder — tune before launch)
960
969
  oblienLimits: {
961
970
  max_workspaces: 50,
962
971
  max_vcpus: 64,
@@ -2371,9 +2380,9 @@ import chalk5 from "chalk";
2371
2380
  import ora from "ora";
2372
2381
  import { spawn } from "child_process";
2373
2382
  import { randomBytes } from "crypto";
2374
- import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync4 } from "fs";
2375
- import { homedir as homedir4 } from "os";
2376
- import { dirname as dirname2, join as join5 } from "path";
2383
+ import { createWriteStream as createWriteStream2, existsSync as existsSync5, mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
2384
+ import { homedir as homedir5 } from "os";
2385
+ import { dirname as dirname2, join as join6 } from "path";
2377
2386
  import { fileURLToPath } from "url";
2378
2387
 
2379
2388
  // src/lib/dashboard.ts
@@ -2480,16 +2489,29 @@ function assetName(tag) {
2480
2489
  async function ensureDashboard(opts = {}) {
2481
2490
  const override = process.env.OPENSHIP_DASHBOARD_DIR?.trim();
2482
2491
  if (override) {
2483
- const cwd2 = join3(override, "apps", "dashboard");
2484
- const entry2 = join3(cwd2, "server.js");
2485
- if (!existsSync2(entry2)) {
2492
+ const cwd = join3(override, "apps", "dashboard");
2493
+ const entry = join3(cwd, "server.js");
2494
+ if (!existsSync2(entry)) {
2486
2495
  throw new Error(
2487
- `OPENSHIP_DASHBOARD_DIR=${override} but ${entry2} is missing \u2014 build the dashboard standalone first (see docs).`
2496
+ `OPENSHIP_DASHBOARD_DIR=${override} but ${entry} is missing \u2014 build the dashboard standalone first (see docs).`
2488
2497
  );
2489
2498
  }
2490
- return { tag: "local", entry: entry2, cwd: cwd2 };
2499
+ return { tag: "local", entry, cwd };
2500
+ }
2501
+ const requested = opts.tag ?? await resolveLatestTag();
2502
+ try {
2503
+ return await fetchBundle(requested, opts.onProgress);
2504
+ } catch (err2) {
2505
+ if (opts.tag && /\b404\b/.test(err2?.message ?? "")) {
2506
+ const latest = await resolveLatestTag();
2507
+ if (latest && latest !== requested) {
2508
+ return await fetchBundle(latest, opts.onProgress);
2509
+ }
2510
+ }
2511
+ throw err2;
2491
2512
  }
2492
- const tag = opts.tag ?? await resolveLatestTag();
2513
+ }
2514
+ async function fetchBundle(tag, onProgress) {
2493
2515
  const dir = join3(DASHBOARD_CACHE, tag);
2494
2516
  const cwd = join3(dir, "apps", "dashboard");
2495
2517
  const entry = join3(cwd, "server.js");
@@ -2501,7 +2523,7 @@ async function ensureDashboard(opts = {}) {
2501
2523
  mkdirSync3(dir, { recursive: true });
2502
2524
  const name = assetName(tag);
2503
2525
  const tarball = join3(dir, name);
2504
- const { sha256 } = await downloadToFile(assetUrl(tag, name), tarball, opts.onProgress);
2526
+ const { sha256 } = await downloadToFile(assetUrl(tag, name), tarball, onProgress);
2505
2527
  const expected = await expectedSha256(tag, name);
2506
2528
  if (!expected) {
2507
2529
  throw new Error(
@@ -2527,7 +2549,7 @@ async function ensureDashboard(opts = {}) {
2527
2549
 
2528
2550
  // src/lib/service.ts
2529
2551
  import { spawnSync as spawnSync2 } from "child_process";
2530
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, rmSync as rmSync2 } from "fs";
2552
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, rmSync as rmSync2, readFileSync as readFileSync2 } from "fs";
2531
2553
  import { homedir as homedir3 } from "os";
2532
2554
  import { join as join4, resolve } from "path";
2533
2555
  var HOME = homedir3();
@@ -2563,6 +2585,19 @@ function run(cmd, args) {
2563
2585
  const r = spawnSync2(cmd, args, { encoding: "utf8" });
2564
2586
  return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
2565
2587
  }
2588
+ function sleepSync(ms) {
2589
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2590
+ }
2591
+ function launchdPid() {
2592
+ const r = run("launchctl", ["list", MAC_LABEL]);
2593
+ if (!r.ok) return null;
2594
+ const m = r.out.match(/"PID"\s*=\s*(\d+)/);
2595
+ return m ? Number(m[1]) : null;
2596
+ }
2597
+ function launchdLastExit() {
2598
+ const m = run("launchctl", ["list", MAC_LABEL]).out.match(/"LastExitStatus"\s*=\s*(-?\d+)/);
2599
+ return m ? Number(m[1]) : null;
2600
+ }
2566
2601
  function isRoot() {
2567
2602
  return typeof process.getuid === "function" && process.getuid() === 0;
2568
2603
  }
@@ -2683,7 +2718,16 @@ function restart() {
2683
2718
  if (!existsSync3(MAC_PLIST)) return { restarted: false, detail: "no launchd agent installed" };
2684
2719
  const uid = String(process.getuid?.() ?? "");
2685
2720
  const r = run("launchctl", ["kickstart", "-k", `gui/${uid}/${MAC_LABEL}`]);
2686
- return { restarted: r.ok, detail: r.ok ? `restarted ${MAC_LABEL}` : r.out };
2721
+ if (!r.ok) return { restarted: false, detail: r.out || "launchctl kickstart failed" };
2722
+ for (let i = 0; i < 6; i++) {
2723
+ sleepSync(500);
2724
+ if (launchdPid() != null) return { restarted: true, detail: `restarted ${MAC_LABEL}` };
2725
+ }
2726
+ const exit = launchdLastExit();
2727
+ return {
2728
+ restarted: false,
2729
+ detail: `${MAC_LABEL} was killed but didn't stay up${exit != null ? ` (last exit ${exit})` : ""} \u2014 check logs in ${LOG_DIR}`
2730
+ };
2687
2731
  }
2688
2732
  if (kind === "systemd-user" || kind === "systemd-system") {
2689
2733
  const sysArgs = kind === "systemd-user" ? ["--user"] : [];
@@ -2699,27 +2743,163 @@ function restart() {
2699
2743
  }
2700
2744
  return { restarted: false, detail: "no supported service manager" };
2701
2745
  }
2746
+ function serviceStatus() {
2747
+ const kind = detectKind();
2748
+ if (kind === "launchd") {
2749
+ return { kind, installed: existsSync3(MAC_PLIST), running: launchdPid() != null };
2750
+ }
2751
+ if (kind === "systemd-user" || kind === "systemd-system") {
2752
+ const sysArgs = kind === "systemd-user" ? ["--user"] : [];
2753
+ const unitPath = kind === "systemd-user" ? join4(HOME, ".config/systemd/user", `${SYSTEMD_NAME}.service`) : `/etc/systemd/system/${SYSTEMD_NAME}.service`;
2754
+ return {
2755
+ kind,
2756
+ installed: existsSync3(unitPath),
2757
+ running: run("systemctl", [...sysArgs, "is-active", SYSTEMD_NAME]).out.trim() === "active"
2758
+ };
2759
+ }
2760
+ if (kind === "schtasks") {
2761
+ const q = run("schtasks", ["/Query", "/TN", WIN_TASK]);
2762
+ return { kind, installed: q.ok, running: q.ok && /Running/i.test(q.out) };
2763
+ }
2764
+ return { kind, installed: false, running: false };
2765
+ }
2766
+ function sweepOrphanPorts() {
2767
+ if (process.platform === "win32") return;
2768
+ let ports = [];
2769
+ try {
2770
+ const raw = readFileSync2(join4(HOME, ".openship", "ports.json"), "utf8");
2771
+ const p = JSON.parse(raw);
2772
+ ports = [p.api, p.dashboard].filter((n) => typeof n === "number");
2773
+ } catch {
2774
+ return;
2775
+ }
2776
+ const self = process.pid;
2777
+ for (const port of ports) {
2778
+ const q = run("lsof", ["-ti", `tcp:${port}`]);
2779
+ if (!q.ok) continue;
2780
+ for (const token of q.out.split(/\s+/).filter(Boolean)) {
2781
+ const pid = Number(token);
2782
+ if (Number.isInteger(pid) && pid > 1 && pid !== self) {
2783
+ try {
2784
+ process.kill(pid, "SIGKILL");
2785
+ } catch {
2786
+ }
2787
+ }
2788
+ }
2789
+ }
2790
+ }
2702
2791
  function stop() {
2703
2792
  const kind = detectKind();
2793
+ let result;
2704
2794
  if (kind === "launchd") {
2705
2795
  run("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/${MAC_LABEL}`]);
2706
2796
  if (existsSync3(MAC_PLIST)) rmSync2(MAC_PLIST, { force: true });
2707
- return { kind, detail: `launchd agent ${MAC_LABEL} stopped + removed` };
2708
- }
2709
- if (kind === "systemd-user" || kind === "systemd-system") {
2797
+ result = { kind, detail: `launchd agent ${MAC_LABEL} stopped + removed` };
2798
+ } else if (kind === "systemd-user" || kind === "systemd-system") {
2710
2799
  const sysArgs = kind === "systemd-user" ? ["--user"] : [];
2711
2800
  run("systemctl", [...sysArgs, "disable", "--now", SYSTEMD_NAME]);
2712
2801
  const unitPath = kind === "systemd-user" ? join4(HOME, ".config/systemd/user", `${SYSTEMD_NAME}.service`) : `/etc/systemd/system/${SYSTEMD_NAME}.service`;
2713
2802
  if (existsSync3(unitPath)) rmSync2(unitPath, { force: true });
2714
2803
  run("systemctl", [...sysArgs, "daemon-reload"]);
2715
- return { kind, detail: `systemd unit ${SYSTEMD_NAME} stopped + disabled` };
2716
- }
2717
- if (kind === "schtasks") {
2804
+ result = { kind, detail: `systemd unit ${SYSTEMD_NAME} stopped + disabled` };
2805
+ } else if (kind === "schtasks") {
2718
2806
  run("schtasks", ["/End", "/TN", WIN_TASK]);
2719
2807
  run("schtasks", ["/Delete", "/TN", WIN_TASK, "/F"]);
2720
- return { kind, detail: `Scheduled Task ${WIN_TASK} stopped + removed` };
2808
+ result = { kind, detail: `Scheduled Task ${WIN_TASK} stopped + removed` };
2809
+ } else {
2810
+ result = { kind, detail: "no supported service manager \u2014 nothing to stop" };
2721
2811
  }
2722
- return { kind, detail: "no supported service manager \u2014 nothing to stop" };
2812
+ sweepOrphanPorts();
2813
+ return result;
2814
+ }
2815
+
2816
+ // src/lib/ports.ts
2817
+ import { createServer } from "net";
2818
+ import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
2819
+ import { homedir as homedir4 } from "os";
2820
+ import { join as join5 } from "path";
2821
+ var OS_DIR2 = join5(homedir4(), ".openship");
2822
+ var PORTS_FILE = join5(OS_DIR2, "ports.json");
2823
+ var INSTANCE_FILE = join5(OS_DIR2, "instance.json");
2824
+ function saveInstanceUrl(publicUrl) {
2825
+ try {
2826
+ if (!existsSync4(OS_DIR2)) mkdirSync5(OS_DIR2, { recursive: true, mode: 448 });
2827
+ writeFileSync4(INSTANCE_FILE, JSON.stringify({ publicUrl: publicUrl ?? null }));
2828
+ } catch {
2829
+ }
2830
+ }
2831
+ function readInstanceUrl() {
2832
+ try {
2833
+ return JSON.parse(readFileSync3(INSTANCE_FILE, "utf8")).publicUrl ?? null;
2834
+ } catch {
2835
+ return null;
2836
+ }
2837
+ }
2838
+ var DEFAULT_API = 4e3;
2839
+ var DEFAULT_DASHBOARD = 3001;
2840
+ function isPortFree(port) {
2841
+ return new Promise((resolve2) => {
2842
+ const srv = createServer();
2843
+ srv.once("error", () => resolve2(false));
2844
+ srv.listen(port, "127.0.0.1", () => srv.close(() => resolve2(true)));
2845
+ });
2846
+ }
2847
+ async function waitPortFree(port, opts = {}) {
2848
+ const timeoutMs = opts.timeoutMs ?? 6e3;
2849
+ const intervalMs = opts.intervalMs ?? 250;
2850
+ const deadline = Date.now() + timeoutMs;
2851
+ for (; ; ) {
2852
+ if (await isPortFree(port)) return true;
2853
+ if (Date.now() >= deadline) return false;
2854
+ await new Promise((r) => setTimeout(r, intervalMs));
2855
+ }
2856
+ }
2857
+ function getFreePort() {
2858
+ return new Promise((resolve2, reject2) => {
2859
+ const srv = createServer();
2860
+ srv.once("error", reject2);
2861
+ srv.listen(0, "127.0.0.1", () => {
2862
+ const addr = srv.address();
2863
+ const port = addr && typeof addr === "object" ? addr.port : 0;
2864
+ srv.close(() => port ? resolve2(port) : reject2(new Error("no free port")));
2865
+ });
2866
+ });
2867
+ }
2868
+ function loadStoredPorts() {
2869
+ try {
2870
+ return JSON.parse(readFileSync3(PORTS_FILE, "utf-8"));
2871
+ } catch {
2872
+ return {};
2873
+ }
2874
+ }
2875
+ function saveStoredPorts(api, dashboard) {
2876
+ try {
2877
+ if (!existsSync4(OS_DIR2)) mkdirSync5(OS_DIR2, { recursive: true, mode: 448 });
2878
+ writeFileSync4(PORTS_FILE, JSON.stringify({ api, dashboard }));
2879
+ } catch {
2880
+ }
2881
+ }
2882
+ async function resolvePorts(prefs) {
2883
+ const stored = loadStoredPorts();
2884
+ const apiPref = prefs.api ?? stored.api ?? DEFAULT_API;
2885
+ const dashPref = prefs.dashboard ?? stored.dashboard ?? DEFAULT_DASHBOARD;
2886
+ const apiRemembered = prefs.api === void 0 && stored.api === apiPref;
2887
+ const dashRemembered = prefs.dashboard === void 0 && stored.dashboard === dashPref;
2888
+ let api;
2889
+ if (await isPortFree(apiPref)) api = apiPref;
2890
+ else if (apiRemembered && await waitPortFree(apiPref)) api = apiPref;
2891
+ else api = await getFreePort();
2892
+ let dashboard;
2893
+ if (dashPref !== api && await isPortFree(dashPref)) dashboard = dashPref;
2894
+ else if (dashPref !== api && dashRemembered && await waitPortFree(dashPref)) dashboard = dashPref;
2895
+ else dashboard = await getFreePort();
2896
+ if (dashboard === api) dashboard = await getFreePort();
2897
+ saveStoredPorts(api, dashboard);
2898
+ return {
2899
+ api,
2900
+ dashboard,
2901
+ switched: { api: api !== apiPref, dashboard: dashboard !== dashPref }
2902
+ };
2723
2903
  }
2724
2904
 
2725
2905
  // src/commands/up.ts
@@ -2744,22 +2924,22 @@ function normalizePublicUrl(raw) {
2744
2924
  return url;
2745
2925
  }
2746
2926
  var DIST_DIR = dirname2(fileURLToPath(import.meta.url));
2747
- var SERVER_DIR = join5(DIST_DIR, "server");
2748
- var OS_DIR2 = join5(homedir4(), ".openship");
2927
+ var SERVER_DIR = join6(DIST_DIR, "server");
2928
+ var OS_DIR3 = join6(homedir5(), ".openship");
2749
2929
  function ensureAuthSecret() {
2750
- const path2 = join5(OS_DIR2, "auth-secret");
2751
- if (existsSync4(path2)) return readFileSync2(path2, "utf8").trim();
2752
- mkdirSync5(OS_DIR2, { recursive: true, mode: 448 });
2930
+ const path2 = join6(OS_DIR3, "auth-secret");
2931
+ if (existsSync5(path2)) return readFileSync4(path2, "utf8").trim();
2932
+ mkdirSync6(OS_DIR3, { recursive: true, mode: 448 });
2753
2933
  const secret = randomBytes(32).toString("hex");
2754
- writeFileSync4(path2, secret, { mode: 384 });
2934
+ writeFileSync5(path2, secret, { mode: 384 });
2755
2935
  return secret;
2756
2936
  }
2757
2937
  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 });
2938
+ const path2 = join6(OS_DIR3, "internal-token");
2939
+ if (existsSync5(path2)) return readFileSync4(path2, "utf8").trim();
2940
+ mkdirSync6(OS_DIR3, { recursive: true, mode: 448 });
2761
2941
  const token = randomBytes(32).toString("hex");
2762
- writeFileSync4(path2, token, { mode: 384 });
2942
+ writeFileSync5(path2, token, { mode: 384 });
2763
2943
  return token;
2764
2944
  }
2765
2945
  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(
@@ -2773,16 +2953,45 @@ var upCommand = new Command4("up").description("Start Openship as a persistent s
2773
2953
  "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
2954
  ).option("--acme-email <email>", "Contact email for Let's Encrypt certificates (managed edge)").action(async (opts) => {
2775
2955
  if (opts.foreground) return runForeground(opts);
2776
- startService(opts);
2956
+ await startService(opts);
2777
2957
  });
2778
- function startService(opts, runOpts = {}) {
2958
+ async function startService(opts, runOpts = {}) {
2779
2959
  const publicUrl = opts.publicUrl ? normalizePublicUrl(opts.publicUrl) : void 0;
2780
- const port = String(opts.port || "4000");
2781
- const dashPort = String(opts.dashboardPort || "3001");
2960
+ if (opts.dryRun) {
2961
+ const p = preview({
2962
+ port: opts.port,
2963
+ dataDir: opts.dataDir,
2964
+ dashboardPort: opts.dashboardPort,
2965
+ ui: opts.ui,
2966
+ uiVersion: opts.uiVersion,
2967
+ publicUrl,
2968
+ trustProxy: opts.trustProxy || opts.managedEdge,
2969
+ managedEdge: opts.managedEdge,
2970
+ acmeEmail: opts.acmeEmail
2971
+ });
2972
+ console.log(
2973
+ chalk5.dim(`
2974
+ service manager: ${p.kind}
2975
+ path: ${p.path}
2976
+
2977
+ `) + p.content + "\n"
2978
+ );
2979
+ return {
2980
+ port: String(opts.port || "4000"),
2981
+ dashPort: String(opts.dashboardPort || "3001"),
2982
+ publicUrl
2983
+ };
2984
+ }
2985
+ const resolved = await resolvePorts({
2986
+ api: opts.port ? Number(opts.port) : void 0,
2987
+ dashboard: opts.dashboardPort ? Number(opts.dashboardPort) : void 0
2988
+ });
2989
+ const port = String(resolved.api);
2990
+ const dashPort = String(resolved.dashboard);
2782
2991
  const flags = {
2783
- port: opts.port,
2992
+ port,
2784
2993
  dataDir: opts.dataDir,
2785
- dashboardPort: opts.dashboardPort,
2994
+ dashboardPort: dashPort,
2786
2995
  ui: opts.ui,
2787
2996
  uiVersion: opts.uiVersion,
2788
2997
  publicUrl,
@@ -2791,20 +3000,15 @@ function startService(opts, runOpts = {}) {
2791
3000
  managedEdge: opts.managedEdge,
2792
3001
  acmeEmail: opts.acmeEmail
2793
3002
  };
2794
- if (opts.dryRun) {
2795
- const p = preview(flags);
2796
- console.log(
2797
- chalk5.dim(`
2798
- service manager: ${p.kind}
2799
- path: ${p.path}
2800
-
2801
- `) + p.content + "\n"
2802
- );
2803
- return { port, dashPort, publicUrl };
2804
- }
2805
3003
  try {
2806
3004
  const res = installAndStart(flags);
2807
3005
  if (!runOpts.quiet) {
3006
+ if (resolved.switched.api || resolved.switched.dashboard) {
3007
+ console.log(
3008
+ chalk5.yellow(`
3009
+ A preferred port was busy \u2014 using API ${port}, dashboard ${dashPort}.`)
3010
+ );
3011
+ }
2808
3012
  const dashboardLine = publicUrl ? chalk5.dim(` Dashboard: ${publicUrl} (login required)
2809
3013
  `) : chalk5.dim(` Dashboard: http://localhost:${dashPort} (login required)
2810
3014
  `);
@@ -2826,19 +3030,27 @@ function startService(opts, runOpts = {}) {
2826
3030
  }
2827
3031
  }
2828
3032
  async function runForeground(opts) {
2829
- const serverEntry = join5(SERVER_DIR, "index.js");
2830
- if (!existsSync4(serverEntry)) {
3033
+ const serverEntry = join6(SERVER_DIR, "index.js");
3034
+ if (!existsSync5(serverEntry)) {
2831
3035
  console.error(
2832
3036
  chalk5.red("\n Bundled server not found in this install.") + chalk5.dim("\n Reinstall with `openship update` (or `npm i -g openship`).\n")
2833
3037
  );
2834
3038
  process.exit(1);
2835
3039
  }
2836
- const port = String(opts.port || "4000");
2837
- const dashPort = String(opts.dashboardPort || "3001");
3040
+ const resolved = await resolvePorts({
3041
+ api: opts.port ? Number(opts.port) : void 0,
3042
+ dashboard: opts.dashboardPort ? Number(opts.dashboardPort) : void 0
3043
+ });
3044
+ const port = String(resolved.api);
3045
+ const dashPort = String(resolved.dashboard);
2838
3046
  const publicUrl = opts.publicUrl ? normalizePublicUrl(opts.publicUrl) : void 0;
2839
3047
  const managedEdge = Boolean(opts.managedEdge && publicUrl);
2840
- const dataDir = opts.dataDir || join5(OS_DIR2, "data");
2841
- mkdirSync5(dataDir, { recursive: true });
3048
+ const dataDir = opts.dataDir || join6(OS_DIR3, "data");
3049
+ mkdirSync6(dataDir, { recursive: true });
3050
+ const logDir = join6(OS_DIR3, "logs");
3051
+ mkdirSync6(logDir, { recursive: true });
3052
+ const instanceLogPath = join6(logDir, "instance.log");
3053
+ const instanceLog = createWriteStream2(instanceLogPath, { flags: "w" });
2842
3054
  const env = {
2843
3055
  ...process.env,
2844
3056
  PORT: port,
@@ -2848,13 +3060,15 @@ async function runForeground(opts) {
2848
3060
  OPENSHIP_TARGET: "local",
2849
3061
  OPENSHIP_JOB_RUNNER: "in-process",
2850
3062
  PGLITE_DATA_DIR: dataDir,
2851
- OPENSHIP_MIGRATIONS_DIR: join5(SERVER_DIR, "migrations"),
2852
- OPENSHIP_PGLITE_ASSETS_DIR: join5(SERVER_DIR, "pglite"),
3063
+ OPENSHIP_MIGRATIONS_DIR: join6(SERVER_DIR, "migrations"),
3064
+ OPENSHIP_PGLITE_ASSETS_DIR: join6(SERVER_DIR, "pglite"),
2853
3065
  BETTER_AUTH_SECRET: ensureAuthSecret()
2854
3066
  };
2855
3067
  env.OPENSHIP_REQUIRE_AUTH = "true";
2856
3068
  env.INTERNAL_TOKEN = ensureInternalToken();
2857
3069
  env.OPENSHIP_API_HOST = "127.0.0.1";
3070
+ env.OPENSHIP_DASHBOARD_PORT = dashPort;
3071
+ env.OPENSHIP_INSTANCE_LOG = instanceLogPath;
2858
3072
  delete env.OPENSHIP_ALLOW_ZERO_AUTH;
2859
3073
  if (publicUrl) {
2860
3074
  env.OPENSHIP_PUBLIC_URL = publicUrl;
@@ -2868,7 +3082,13 @@ async function runForeground(opts) {
2868
3082
  delete env.DATABASE_URL;
2869
3083
  delete env.POSTGRES_URL;
2870
3084
  const spinner3 = ora(`Starting Openship on http://localhost:${port} \u2026`).start();
2871
- const child = spawn(process.execPath, [serverEntry], { env, stdio: ["ignore", "pipe", "pipe"] });
3085
+ const child = spawn(process.execPath, [serverEntry], {
3086
+ env,
3087
+ stdio: ["ignore", "pipe", "pipe"],
3088
+ detached: process.platform !== "win32"
3089
+ });
3090
+ child.stdout.on("data", (d) => instanceLog.write(d));
3091
+ child.stderr.on("data", (d) => instanceLog.write(d));
2872
3092
  let buffered = "";
2873
3093
  const buffer = (d) => {
2874
3094
  buffered += d.toString();
@@ -2903,26 +3123,33 @@ async function runForeground(opts) {
2903
3123
  }
2904
3124
  spinner3.succeed(`Openship API running at http://localhost:${port}`);
2905
3125
  const children = [child];
2906
- const stopAll = () => {
2907
- for (const c of children) {
2908
- try {
2909
- c.kill("SIGTERM");
2910
- } catch {
2911
- }
2912
- setTimeout(() => {
2913
- try {
2914
- c.kill("SIGKILL");
2915
- } catch {
2916
- }
2917
- }, 5e3).unref?.();
3126
+ const killTree = (c, sig) => {
3127
+ try {
3128
+ if (c.pid && process.platform !== "win32") process.kill(-c.pid, sig);
3129
+ else c.kill(sig);
3130
+ } catch {
2918
3131
  }
2919
3132
  };
3133
+ let stopping = false;
3134
+ const stopAll = (exitCode = 0) => {
3135
+ if (stopping) return;
3136
+ stopping = true;
3137
+ try {
3138
+ instanceLog.end();
3139
+ } catch {
3140
+ }
3141
+ for (const c of children) killTree(c, "SIGTERM");
3142
+ setTimeout(() => {
3143
+ for (const c of children) killTree(c, "SIGKILL");
3144
+ process.exit(exitCode);
3145
+ }, 1500);
3146
+ };
2920
3147
  let dashboardUrl = null;
2921
3148
  if (opts.ui !== false) {
2922
3149
  const uiSpinner = ora("Preparing the dashboard\u2026").start();
2923
3150
  try {
2924
3151
  const bundle = await ensureDashboard({
2925
- tag: opts.uiVersion || `v${"0.2.1"}`,
3152
+ tag: opts.uiVersion || `v${"0.2.2"}`,
2926
3153
  onProgress: (received, total) => {
2927
3154
  if (total) {
2928
3155
  uiSpinner.text = `Downloading dashboard\u2026 ${Math.round(received / total * 100)}%`;
@@ -2932,6 +3159,7 @@ async function runForeground(opts) {
2932
3159
  uiSpinner.text = "Starting the dashboard\u2026";
2933
3160
  const dash = spawn(process.execPath, [bundle.entry], {
2934
3161
  cwd: bundle.cwd,
3162
+ detached: process.platform !== "win32",
2935
3163
  env: {
2936
3164
  ...process.env,
2937
3165
  NODE_ENV: "production",
@@ -2946,13 +3174,24 @@ async function runForeground(opts) {
2946
3174
  // the browser never needs to know where the API lives. Set in every
2947
3175
  // mode; loopback because the dashboard runs on the same box.
2948
3176
  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}` }
3177
+ // ALWAYS tell the dashboard the real loopback API origin. The API port
3178
+ // is dynamic, so a browser opened on THIS box must learn it via
3179
+ // window.__OPENSHIP_API_ORIGIN__ (layout.tsx) otherwise it falls back
3180
+ // to the static default :4000 and every call 404s. Use `localhost` (NOT
3181
+ // 127.0.0.1) to MATCH the host the dashboard is opened on — a host-only
3182
+ // SameSite session cookie set on 127.0.0.1 is never sent to localhost
3183
+ // (they're different sites to a browser), which is the login-reload loop.
3184
+ // Older dashboards use this origin verbatim; newer ones align it anyway.
3185
+ // `localhost` still reaches the 127.0.0.1-bound API. In proxy mode this
3186
+ // is just a fallback (sameOriginProxyOrigin wins for remote browsers).
3187
+ OPENSHIP_LOCAL_API_URL: `http://localhost:${port}`,
3188
+ ...publicUrl ? { OPENSHIP_PUBLIC_URL: publicUrl } : {}
2952
3189
  },
2953
3190
  stdio: ["ignore", "pipe", "pipe"]
2954
3191
  });
2955
3192
  children.push(dash);
3193
+ dash.stdout.on("data", (d) => instanceLog.write(d));
3194
+ dash.stderr.on("data", (d) => instanceLog.write(d));
2956
3195
  let dashBuf = "";
2957
3196
  const onDash = (d) => {
2958
3197
  dashBuf += d.toString();
@@ -3009,12 +3248,9 @@ async function runForeground(opts) {
3009
3248
  child.stderr.off("data", buffer);
3010
3249
  child.stdout.on("data", (d) => process.stdout.write(d));
3011
3250
  child.stderr.on("data", (d) => process.stderr.write(d));
3012
- process.on("SIGINT", stopAll);
3013
- process.on("SIGTERM", stopAll);
3014
- child.on("exit", (code) => {
3015
- stopAll();
3016
- process.exit(code ?? 0);
3017
- });
3251
+ process.on("SIGINT", () => stopAll(0));
3252
+ process.on("SIGTERM", () => stopAll(0));
3253
+ child.on("exit", (code) => stopAll(code ?? 0));
3018
3254
  }
3019
3255
 
3020
3256
  // src/commands/stop.ts
@@ -3035,15 +3271,15 @@ var stopCommand = new Command5("stop").description("Stop the Openship service (s
3035
3271
 
3036
3272
  // src/commands/init.ts
3037
3273
  import { Command as Command6 } from "commander";
3038
- import { existsSync as existsSync5, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
3274
+ import { existsSync as existsSync6, mkdirSync as mkdirSync7, writeFileSync as writeFileSync6 } from "fs";
3039
3275
  import { createInterface as createInterface2 } from "readline/promises";
3040
3276
  import { stdin as input2, stdout as output2 } from "process";
3041
- import { join as join6 } from "path";
3277
+ import { join as join7 } from "path";
3042
3278
  var initCommand = new Command6("init").description("Link the current directory to an Openship project (.openship/project.json)").option("--project <id>", "Project id to link (skips the picker)").option("--environment <name>", "Default deploy environment", "production").option("--dir <path>", "Directory to initialize", process.cwd()).option("--force", "Overwrite an existing project link").option("-y, --yes", "Non-interactive: fail instead of prompting").action(async (opts) => {
3043
3279
  const root = opts.dir || process.cwd();
3044
- const linkDir = join6(root, ".openship");
3045
- const linkPath = join6(linkDir, "project.json");
3046
- if (existsSync5(linkPath) && !opts.force) {
3280
+ const linkDir = join7(root, ".openship");
3281
+ const linkPath = join7(linkDir, "project.json");
3282
+ if (existsSync6(linkPath) && !opts.force) {
3047
3283
  err(`Already linked (${linkPath}). Re-run with --force to overwrite.`);
3048
3284
  process.exit(1);
3049
3285
  }
@@ -3096,8 +3332,8 @@ var initCommand = new Command6("init").description("Link the current directory t
3096
3332
  context: getActiveContext(),
3097
3333
  defaults: { environment: opts.environment || "production" }
3098
3334
  };
3099
- mkdirSync6(linkDir, { recursive: true });
3100
- writeFileSync5(linkPath, JSON.stringify(link, null, 2) + "\n");
3335
+ mkdirSync7(linkDir, { recursive: true });
3336
+ writeFileSync6(linkPath, JSON.stringify(link, null, 2) + "\n");
3101
3337
  if (isJsonMode()) {
3102
3338
  printJson({ path: linkPath, link });
3103
3339
  return;
@@ -3161,39 +3397,53 @@ var contextCommand = new Command7("context").alias("ctx").description("Manage co
3161
3397
  // src/commands/status.ts
3162
3398
  import { Command as Command8 } from "commander";
3163
3399
  import chalk7 from "chalk";
3164
- var statusCommand = new Command8("status").description("Show the active context's API health and deployment info").action(async () => {
3400
+ import { readFileSync as readFileSync5 } from "fs";
3401
+ import { homedir as homedir6 } from "os";
3402
+ import { join as join8 } from "path";
3403
+ function readPorts() {
3404
+ try {
3405
+ return JSON.parse(readFileSync5(join8(homedir6(), ".openship", "ports.json"), "utf8"));
3406
+ } catch {
3407
+ return {};
3408
+ }
3409
+ }
3410
+ var statusCommand = new Command8("status").description("Show the local Openship service (installed/running, ports) and the active context's API health").action(async () => {
3165
3411
  const context = getActiveContext();
3166
3412
  const apiUrl = getApiUrl2();
3167
- let health;
3168
- let envInfo;
3413
+ const svc = serviceStatus();
3414
+ const ports = readPorts();
3415
+ let health = null;
3416
+ let envInfo = null;
3417
+ let reachable = true;
3418
+ let unreachableMsg = "";
3169
3419
  try {
3170
3420
  health = await apiRequest("/health", { signal: AbortSignal.timeout(8e3) });
3171
3421
  envInfo = await apiRequest("/health/env", { signal: AbortSignal.timeout(8e3) });
3172
3422
  } catch (e) {
3173
- if (isJsonMode()) {
3174
- printJson({ context, apiUrl, reachable: false });
3175
- } else {
3176
- const msg = e instanceof ApiError ? e.message : e.message;
3177
- err(`
3178
- Cannot reach the API at ${apiUrl}: ${msg}
3179
- `);
3180
- }
3181
- process.exit(1);
3423
+ reachable = false;
3424
+ unreachableMsg = e instanceof ApiError ? e.message : e.message;
3182
3425
  }
3183
3426
  if (isJsonMode()) {
3184
- printJson({ context, apiUrl, reachable: true, health, env: envInfo });
3185
- return;
3427
+ printJson({ context, apiUrl, service: svc, ports, reachable, health, env: envInfo });
3428
+ process.exit(reachable ? 0 : 1);
3186
3429
  }
3187
3430
  const row = (label, value) => ` ${chalk7.dim(label.padEnd(14))}${value ?? chalk7.dim("-")}
3188
3431
  `;
3189
- process.stdout.write(
3190
- chalk7.bold("\n Openship status\n\n") + row("Context", context) + row("API", apiUrl) + row("Health", chalk7.green(health.status ?? "ok")) + row("Mode", envInfo.selfHosted ? "self-hosted" : "cloud") + row("Deploy", envInfo.deployMode) + row("Auth", envInfo.authMode) + row("Team", envInfo.teamMode) + (envInfo.hostDomain ? row("Host domain", envInfo.hostDomain) : "") + (envInfo.machineName ? row("Machine", envInfo.machineName) : "") + "\n"
3191
- );
3432
+ const serviceState = svc.running ? chalk7.green("running") : svc.installed ? chalk7.yellow("installed \xB7 stopped") : chalk7.dim("not installed");
3433
+ let out = chalk7.bold("\n Openship status\n\n") + row("Service", serviceState) + row("Manager", svc.kind === "unsupported" ? chalk7.dim("none") : svc.kind) + (ports.api ? row("API port", ports.api) : "") + (ports.dashboard ? row("Dashboard port", ports.dashboard) : "") + row("Context", context) + row("API", apiUrl);
3434
+ if (reachable && health && envInfo) {
3435
+ out += row("Health", chalk7.green(health.status ?? "ok")) + row("Mode", envInfo.selfHosted ? "self-hosted" : "cloud") + row("Deploy", envInfo.deployMode) + row("Auth", envInfo.authMode) + row("Team", envInfo.teamMode) + (envInfo.hostDomain ? row("Host domain", envInfo.hostDomain) : "") + (envInfo.machineName ? row("Machine", envInfo.machineName) : "");
3436
+ } else {
3437
+ out += row("Health", chalk7.red("not reachable")) + chalk7.dim(` ${unreachableMsg}
3438
+ `) + chalk7.dim(svc.running ? " (service is up \u2014 it may still be starting)\n" : " Start it with `openship up`.\n");
3439
+ }
3440
+ process.stdout.write(out + "\n");
3441
+ if (!reachable) process.exit(1);
3192
3442
  });
3193
3443
 
3194
3444
  // src/commands/doctor.ts
3195
3445
  import { Command as Command9 } from "commander";
3196
- import { existsSync as existsSync6 } from "fs";
3446
+ import { existsSync as existsSync7 } from "fs";
3197
3447
  import { execFileSync } from "child_process";
3198
3448
  import chalk8 from "chalk";
3199
3449
  function bunVersion() {
@@ -3207,7 +3457,7 @@ function bunVersion() {
3207
3457
  }
3208
3458
  var doctorCommand = new Command9("doctor").description("Diagnose the CLI setup (config, active context, runtime)").action(async () => {
3209
3459
  const checks = [];
3210
- const hasConfig = existsSync6(CONFIG_PATH);
3460
+ const hasConfig = existsSync7(CONFIG_PATH);
3211
3461
  checks.push({
3212
3462
  name: "config",
3213
3463
  status: hasConfig ? "pass" : "warn",
@@ -3260,15 +3510,15 @@ import { execFileSync as execFileSync3 } from "child_process";
3260
3510
  import ora2 from "ora";
3261
3511
 
3262
3512
  // src/lib/project-link.ts
3263
- import { readFileSync as readFileSync3, existsSync as existsSync7 } from "fs";
3264
- import { join as join7, dirname as dirname3, parse } from "path";
3265
- var LINK_REL = join7(".openship", "project.json");
3513
+ import { readFileSync as readFileSync6, existsSync as existsSync8 } from "fs";
3514
+ import { join as join9, dirname as dirname3, parse } from "path";
3515
+ var LINK_REL = join9(".openship", "project.json");
3266
3516
  function findProjectLinkPath(from = process.cwd()) {
3267
3517
  let dir = from;
3268
3518
  const root = parse(dir).root;
3269
3519
  for (; ; ) {
3270
- const candidate = join7(dir, LINK_REL);
3271
- if (existsSync7(candidate)) return candidate;
3520
+ const candidate = join9(dir, LINK_REL);
3521
+ if (existsSync8(candidate)) return candidate;
3272
3522
  if (dir === root) return null;
3273
3523
  dir = dirname3(dir);
3274
3524
  }
@@ -3277,7 +3527,7 @@ function readProjectLink(from) {
3277
3527
  const path2 = findProjectLinkPath(from);
3278
3528
  if (!path2) return null;
3279
3529
  try {
3280
- return JSON.parse(readFileSync3(path2, "utf8"));
3530
+ return JSON.parse(readFileSync6(path2, "utf8"));
3281
3531
  } catch {
3282
3532
  return null;
3283
3533
  }
@@ -3285,21 +3535,21 @@ function readProjectLink(from) {
3285
3535
 
3286
3536
  // src/lib/folder-deploy.ts
3287
3537
  import { execFileSync as execFileSync2 } from "child_process";
3288
- import { readFileSync as readFileSync4, existsSync as existsSync8, rmSync as rmSync3 } from "fs";
3538
+ import { readFileSync as readFileSync7, existsSync as existsSync9, rmSync as rmSync3 } from "fs";
3289
3539
  import { tmpdir } from "os";
3290
- import { join as join8, basename } from "path";
3540
+ import { join as join10, basename } from "path";
3291
3541
  function detectPackageManager(dir) {
3292
- if (existsSync8(join8(dir, "bun.lockb")) || existsSync8(join8(dir, "bun.lock"))) return "bun";
3293
- if (existsSync8(join8(dir, "pnpm-lock.yaml"))) return "pnpm";
3294
- if (existsSync8(join8(dir, "yarn.lock"))) return "yarn";
3295
- if (existsSync8(join8(dir, "package.json"))) return "npm";
3542
+ if (existsSync9(join10(dir, "bun.lockb")) || existsSync9(join10(dir, "bun.lock"))) return "bun";
3543
+ if (existsSync9(join10(dir, "pnpm-lock.yaml"))) return "pnpm";
3544
+ if (existsSync9(join10(dir, "yarn.lock"))) return "yarn";
3545
+ if (existsSync9(join10(dir, "package.json"))) return "npm";
3296
3546
  return void 0;
3297
3547
  }
3298
3548
  function detectStack(dir) {
3299
- if (existsSync8(join8(dir, "go.mod"))) return "go";
3300
- if (existsSync8(join8(dir, "Cargo.toml"))) return "rust";
3301
- if (existsSync8(join8(dir, "requirements.txt")) || existsSync8(join8(dir, "pyproject.toml"))) return "python";
3302
- if (existsSync8(join8(dir, "package.json"))) return "node";
3549
+ if (existsSync9(join10(dir, "go.mod"))) return "go";
3550
+ if (existsSync9(join10(dir, "Cargo.toml"))) return "rust";
3551
+ if (existsSync9(join10(dir, "requirements.txt")) || existsSync9(join10(dir, "pyproject.toml"))) return "python";
3552
+ if (existsSync9(join10(dir, "package.json"))) return "node";
3303
3553
  return void 0;
3304
3554
  }
3305
3555
  async function deployFolder(opts) {
@@ -3316,7 +3566,7 @@ async function deployFolder(opts) {
3316
3566
  throw new Error(session.error || "Failed to open upload session");
3317
3567
  }
3318
3568
  step("Packaging folder");
3319
- const tarball = join8(tmpdir(), `openship-upload-${session.sessionId}.tar.gz`);
3569
+ const tarball = join10(tmpdir(), `openship-upload-${session.sessionId}.tar.gz`);
3320
3570
  execFileSync2(
3321
3571
  "tar",
3322
3572
  [
@@ -3335,7 +3585,7 @@ async function deployFolder(opts) {
3335
3585
  );
3336
3586
  step("Uploading source");
3337
3587
  try {
3338
- const body = readFileSync4(tarball);
3588
+ const body = readFileSync7(tarball);
3339
3589
  const up = session.upload;
3340
3590
  const method = up.method || "POST";
3341
3591
  const res = /^https?:\/\//i.test(up.url) ? await fetch(up.url, { method, headers: up.headers, body }) : await apiRaw(`/${up.url.replace(/^\/+/, "")}`, { method, headers: up.headers, body });
@@ -5091,6 +5341,50 @@ server.command("check <serverId>").description("Run component health checks agai
5091
5341
  else err(` Missing required components: ${res.missing.join(", ") || "none"}`);
5092
5342
  })
5093
5343
  );
5344
+ server.command("update <serverId>").description("Check for and apply native-module migrations (OpenResty, \u2026)").option("-c, --component <name...>", "Limit to specific modules").option("--check", "Only report drift; don't apply").action(
5345
+ guard(async (serverId, o) => {
5346
+ const base = `/system/servers/${encodeURIComponent(serverId)}/modules`;
5347
+ await apiRequest(`${base}/scan`, { method: "POST", body: "{}" }).catch(() => {
5348
+ });
5349
+ let mods = await apiRequest(base);
5350
+ if (o.component?.length) mods = mods.filter((m) => o.component.includes(m.moduleName));
5351
+ if (o.check) {
5352
+ if (isJsonMode()) return printJson(mods);
5353
+ printTable(
5354
+ mods.map((m) => ({
5355
+ module: m.moduleName,
5356
+ installed: m.installedVersion ?? "-",
5357
+ current: m.migrationVersion ?? "-",
5358
+ available: m.availableVersion ?? "-",
5359
+ behind: m.behind ? "yes" : "no",
5360
+ consent: String(m.detail?.pendingConsent?.length ?? 0)
5361
+ })),
5362
+ ["module", "installed", "current", "available", "behind", "consent"]
5363
+ );
5364
+ return;
5365
+ }
5366
+ const behind = mods.filter((m) => m.behind);
5367
+ if (!behind.length) return ok(" All modules up to date.");
5368
+ for (const m of behind) {
5369
+ const consent = m.detail?.pendingConsent ?? [];
5370
+ if (consent.length && !isJsonMode()) {
5371
+ info(` ${m.moduleName}: includes consent migrations \u2014 ${consent.map((c) => c.warning ?? c.id).join("; ")}`);
5372
+ }
5373
+ const spinner3 = isJsonMode() ? null : ora4(`Updating ${m.moduleName}\u2026`).start();
5374
+ const res = await apiRequest(`${base}/${encodeURIComponent(m.moduleName)}/apply`, {
5375
+ method: "POST",
5376
+ body: "{}"
5377
+ });
5378
+ spinner3?.stop();
5379
+ if (isJsonMode()) {
5380
+ printJson(res);
5381
+ continue;
5382
+ }
5383
+ if (res.ok) ok(` ${m.moduleName}: ${res.fromVersion} \u2192 ${res.toVersion} (${res.appliedSteps.length} step(s))`);
5384
+ else err(` ${m.moduleName}: ${res.error ?? "update failed"}`);
5385
+ }
5386
+ })
5387
+ );
5094
5388
  server.command("install <serverId>").description("Install components on a server").requiredOption("-c, --component <name...>", `Components to install (${INSTALLABLE.join("|")})`).option("--follow", "Stream install logs live (SSE)").action(
5095
5389
  guard(async (serverId, o) => {
5096
5390
  const components = o.component;
@@ -5233,7 +5527,7 @@ var serverCommand = server;
5233
5527
  // src/commands/system.ts
5234
5528
  import { Command as Command17 } from "commander";
5235
5529
  import ora5 from "ora";
5236
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "fs";
5530
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
5237
5531
  import { createInterface as createInterface6 } from "readline/promises";
5238
5532
  import { stdin as input5, stdout as output5 } from "process";
5239
5533
  async function guarded(fn) {
@@ -5533,7 +5827,7 @@ dataTransferCommand.command("export").description("Export the entire instance to
5533
5827
  );
5534
5828
  spin4?.succeed("Export ready.");
5535
5829
  if (opts.out) {
5536
- writeFileSync6(opts.out, JSON.stringify(file));
5830
+ writeFileSync7(opts.out, JSON.stringify(file));
5537
5831
  const tables = Object.keys(file.dump?.tables ?? {}).length;
5538
5832
  report2(
5539
5833
  { out: opts.out, tables },
@@ -5559,7 +5853,7 @@ dataTransferCommand.command("import").description("Import an instance export fil
5559
5853
  }
5560
5854
  let file;
5561
5855
  try {
5562
- file = JSON.parse(readFileSync5(opts.file, "utf8"));
5856
+ file = JSON.parse(readFileSync8(opts.file, "utf8"));
5563
5857
  } catch {
5564
5858
  err(`
5565
5859
  Could not read or parse ${opts.file}.
@@ -5902,7 +6196,7 @@ var mailCommand = new Command18("mail").description("Self-hosted mail server (iR
5902
6196
  // src/commands/backup.ts
5903
6197
  import { Command as Command19 } from "commander";
5904
6198
  import ora7 from "ora";
5905
- import { readFileSync as readFileSync6 } from "fs";
6199
+ import { readFileSync as readFileSync9 } from "fs";
5906
6200
  async function guard3(fn) {
5907
6201
  try {
5908
6202
  await fn();
@@ -6195,7 +6489,7 @@ destinationCmd.command("create").description("Create a backup destination").requ
6195
6489
  let sftpPrivateKey = opts.sftpPrivateKey;
6196
6490
  if (opts.sftpPrivateKeyFile) {
6197
6491
  try {
6198
- sftpPrivateKey = readFileSync6(opts.sftpPrivateKeyFile, "utf8");
6492
+ sftpPrivateKey = readFileSync9(opts.sftpPrivateKeyFile, "utf8");
6199
6493
  } catch {
6200
6494
  throw new Error(`Cannot read key file: ${opts.sftpPrivateKeyFile}`);
6201
6495
  }
@@ -6383,12 +6677,73 @@ var apiCommand = new Command21("api").description("Make an authenticated request
6383
6677
  }
6384
6678
  });
6385
6679
 
6386
- // src/commands/install.ts
6680
+ // src/commands/reset-admin.ts
6387
6681
  import { Command as Command22 } from "commander";
6388
- import { chmodSync as chmodSync2, existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "fs";
6682
+ import chalk16 from "chalk";
6683
+ import { intro, outro, password as passwordPrompt, isCancel, cancel as cancel2, log } from "@clack/prompts";
6684
+ import { readFileSync as readFileSync10 } from "fs";
6685
+ import { homedir as homedir7 } from "os";
6686
+ import { join as join11 } from "path";
6687
+ function resolvedApiPort() {
6688
+ try {
6689
+ return JSON.parse(readFileSync10(join11(homedir7(), ".openship", "ports.json"), "utf8")).api;
6690
+ } catch {
6691
+ return void 0;
6692
+ }
6693
+ }
6694
+ var resetAdminCommand = new Command22("reset-admin-password").description("Reset the local admin login on THIS machine (no sign-in required)").option("--port <port>", "API port of the running service (default: the resolved port from ~/.openship/ports.json, else 4000)").option("--email <email>", "Also set the admin email").option("--name <name>", "Also set the admin display name").option("--password <password>", "New password (prompted if omitted)").action(async (opts) => {
6695
+ intro(chalk16.cyan("Reset Openship admin password"));
6696
+ let pw = opts.password;
6697
+ if (!pw) {
6698
+ if (!process.stdin.isTTY) {
6699
+ log.error("--password is required in non-interactive mode.");
6700
+ process.exit(1);
6701
+ }
6702
+ const entered = await passwordPrompt({
6703
+ message: "New admin password",
6704
+ validate: (v) => v && v.length >= 8 && v.length <= 128 ? void 0 : "8\u2013128 characters"
6705
+ });
6706
+ if (isCancel(entered)) {
6707
+ cancel2("Cancelled.");
6708
+ process.exit(0);
6709
+ }
6710
+ const confirm3 = await passwordPrompt({
6711
+ message: "Confirm password",
6712
+ validate: (v) => v === entered ? void 0 : "Passwords don't match"
6713
+ });
6714
+ if (isCancel(confirm3)) {
6715
+ cancel2("Cancelled.");
6716
+ process.exit(0);
6717
+ }
6718
+ pw = entered;
6719
+ }
6720
+ const port = String(opts.port || resolvedApiPort() || 4e3);
6721
+ let res;
6722
+ try {
6723
+ res = await fetch(`http://127.0.0.1:${port}/api/system/reset-admin-password`, {
6724
+ method: "POST",
6725
+ headers: { "Content-Type": "application/json", "X-Internal-Token": ensureInternalToken() },
6726
+ body: JSON.stringify({ password: pw, email: opts.email, name: opts.name })
6727
+ });
6728
+ } catch {
6729
+ log.error(`Couldn't reach the Openship API on port ${port}. Is it running? (openship status)`);
6730
+ log.info("If it's listening on another port, pass --port <n>.");
6731
+ process.exit(1);
6732
+ }
6733
+ const data = await res.json().catch(() => ({}));
6734
+ if (!res.ok || !data.ok) {
6735
+ log.error(`Reset failed: ${data.error || res.statusText}`);
6736
+ process.exit(1);
6737
+ }
6738
+ outro(chalk16.green(`Password reset. Log in as ${data.email} with your new password.`));
6739
+ });
6740
+
6741
+ // src/commands/install.ts
6742
+ import { Command as Command23 } from "commander";
6743
+ import { chmodSync as chmodSync2, existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
6389
6744
  import { spawn as spawn2, spawnSync as spawnSync4 } from "child_process";
6390
- import { homedir as homedir5 } from "os";
6391
- import { join as join9 } from "path";
6745
+ import { homedir as homedir8 } from "os";
6746
+ import { join as join12 } from "path";
6392
6747
  import ora9 from "ora";
6393
6748
  function assetForPlatform() {
6394
6749
  const { platform, arch } = process;
@@ -6400,10 +6755,10 @@ function assetForPlatform() {
6400
6755
  throw new Error(`Unsupported platform: ${platform} (${arch})`);
6401
6756
  }
6402
6757
  function installDmg(dmg) {
6403
- const homeApps = join9(homedir5(), "Applications");
6758
+ const homeApps = join12(homedir8(), "Applications");
6404
6759
  let dest = homeApps;
6405
6760
  try {
6406
- mkdirSync7(homeApps, { recursive: true });
6761
+ mkdirSync8(homeApps, { recursive: true });
6407
6762
  } catch {
6408
6763
  dest = "/Applications";
6409
6764
  }
@@ -6413,14 +6768,14 @@ function installDmg(dmg) {
6413
6768
  if (attach.status !== 0) throw new Error(`hdiutil attach failed: ${attach.stderr?.trim()}`);
6414
6769
  const mount = (attach.stdout.match(/\/Volumes\/[^\n]*/g) ?? []).pop()?.trim();
6415
6770
  if (!mount) throw new Error("Could not determine the mounted volume");
6416
- let target = join9(dest, "Openship.app");
6771
+ let target = join12(dest, "Openship.app");
6417
6772
  try {
6418
- const appInDmg = join9(mount, "Openship.app");
6419
- if (!existsSync9(appInDmg)) throw new Error("Openship.app not found in the disk image");
6773
+ const appInDmg = join12(mount, "Openship.app");
6774
+ if (!existsSync10(appInDmg)) throw new Error("Openship.app not found in the disk image");
6420
6775
  spawnSync4("rm", ["-rf", target]);
6421
6776
  let copy = spawnSync4("ditto", [appInDmg, target], { encoding: "utf8" });
6422
6777
  if (copy.status !== 0 && dest === homeApps) {
6423
- target = join9("/Applications", "Openship.app");
6778
+ target = join12("/Applications", "Openship.app");
6424
6779
  spawnSync4("rm", ["-rf", target]);
6425
6780
  copy = spawnSync4("ditto", [appInDmg, target], { encoding: "utf8" });
6426
6781
  }
@@ -6435,9 +6790,9 @@ function installAppImage(appImage) {
6435
6790
  return appImage;
6436
6791
  }
6437
6792
  function installZip(zip) {
6438
- const localAppData = process.env.LOCALAPPDATA || join9(homedir5(), "AppData", "Local");
6439
- const target = join9(localAppData, "Programs", "Openship");
6440
- mkdirSync7(target, { recursive: true });
6793
+ const localAppData = process.env.LOCALAPPDATA || join12(homedir8(), "AppData", "Local");
6794
+ const target = join12(localAppData, "Programs", "Openship");
6795
+ mkdirSync8(target, { recursive: true });
6441
6796
  const expand = spawnSync4(
6442
6797
  "powershell",
6443
6798
  [
@@ -6464,11 +6819,11 @@ function launch(kind, target) {
6464
6819
  child.unref();
6465
6820
  return;
6466
6821
  }
6467
- const exe = join9(target, "Openship.exe");
6468
- const path2 = existsSync9(exe) ? exe : target;
6822
+ const exe = join12(target, "Openship.exe");
6823
+ const path2 = existsSync10(exe) ? exe : target;
6469
6824
  spawnSync4("cmd", ["/c", "start", "", path2]);
6470
6825
  }
6471
- var installCommand = new Command22("install").description("Download and install the Openship desktop app for this OS").option("--version <tag>", "Release tag to install (e.g. v1.2.3)").option("--latest", "Install the latest release (default)").option("--force", "Re-download even if a verified copy is cached").option("--no-verify", "Skip SHA-256 verification (allowed when no sidecar exists)").option("--no-launch", "Install without launching the app").action(async (opts) => {
6826
+ var installCommand = new Command23("install").description("Download and install the Openship desktop app for this OS").option("--version <tag>", "Release tag to install (e.g. v1.2.3)").option("--latest", "Install the latest release (default)").option("--force", "Re-download even if a verified copy is cached").option("--no-verify", "Skip SHA-256 verification (allowed when no sidecar exists)").option("--no-launch", "Install without launching the app").action(async (opts) => {
6472
6827
  let asset;
6473
6828
  try {
6474
6829
  asset = assetForPlatform();
@@ -6491,13 +6846,13 @@ var installCommand = new Command22("install").description("Download and install
6491
6846
  process.exit(1);
6492
6847
  }
6493
6848
  const dir = releaseDir(tag);
6494
- const assetPath = join9(dir, asset.name);
6849
+ const assetPath = join12(dir, asset.name);
6495
6850
  const sidecarPath = `${assetPath}.sha256`;
6496
6851
  const assetUrl2 = `${RELEASES}/download/${tag}/${asset.name}`;
6497
6852
  const sidecarUrl = `${assetUrl2}.sha256`;
6498
6853
  let downloaded = false;
6499
6854
  let sha;
6500
- const cachedUsable = !opts.force && existsSync9(assetPath) && (existsSync9(sidecarPath) || opts.verify === false);
6855
+ const cachedUsable = !opts.force && existsSync10(assetPath) && (existsSync10(sidecarPath) || opts.verify === false);
6501
6856
  if (cachedUsable) {
6502
6857
  info(` Using cached ${asset.name} (${tag}).`);
6503
6858
  } else {
@@ -6519,8 +6874,8 @@ var installCommand = new Command22("install").description("Download and install
6519
6874
  const s2 = spin4("Verifying checksum\u2026");
6520
6875
  try {
6521
6876
  let sidecarBody;
6522
- if (existsSync9(sidecarPath) && !downloaded) {
6523
- sidecarBody = readFileSync7(sidecarPath, "utf8");
6877
+ if (existsSync10(sidecarPath) && !downloaded) {
6878
+ sidecarBody = readFileSync11(sidecarPath, "utf8");
6524
6879
  } else {
6525
6880
  sidecarBody = await fetchSidecar(sidecarUrl);
6526
6881
  }
@@ -6543,8 +6898,8 @@ var installCommand = new Command22("install").description("Download and install
6543
6898
  err(`Expected ${expected}, got ${actual}. The download may be corrupt or tampered with.`);
6544
6899
  process.exit(1);
6545
6900
  }
6546
- mkdirSync7(dir, { recursive: true });
6547
- writeFileSync7(sidecarPath, sidecarBody);
6901
+ mkdirSync8(dir, { recursive: true });
6902
+ writeFileSync8(sidecarPath, sidecarBody);
6548
6903
  s2?.succeed("Checksum verified");
6549
6904
  } catch (e) {
6550
6905
  s2?.fail("Verification failed");
@@ -6583,15 +6938,15 @@ var installCommand = new Command22("install").description("Download and install
6583
6938
  });
6584
6939
 
6585
6940
  // src/commands/update.ts
6586
- import { Command as Command23 } from "commander";
6941
+ import { Command as Command24 } from "commander";
6587
6942
  import { spawnSync as spawnSync5 } from "child_process";
6588
6943
  function detectPackageManager2(override) {
6589
6944
  if (override === "bun" || override === "npm") return override;
6590
6945
  const hasBun = spawnSync5("bun", ["--version"], { stdio: "ignore" }).status === 0;
6591
6946
  return hasBun ? "bun" : "npm";
6592
6947
  }
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) => {
6594
- const current = "0.2.1";
6948
+ var updateCommand = new Command24("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) => {
6949
+ const current = "0.2.2";
6595
6950
  let latest;
6596
6951
  try {
6597
6952
  latest = (await resolveLatestTag()).replace(/^v/, "");
@@ -6636,18 +6991,18 @@ var updateCommand = new Command23("update").description("Update the Openship CLI
6636
6991
  });
6637
6992
 
6638
6993
  // src/commands/cache.ts
6639
- import { Command as Command24 } from "commander";
6640
- import { existsSync as existsSync10, readdirSync, readFileSync as readFileSync8, rmSync as rmSync4, statSync } from "fs";
6641
- import { join as join10 } from "path";
6994
+ import { Command as Command25 } from "commander";
6995
+ import { existsSync as existsSync11, readdirSync, readFileSync as readFileSync12, rmSync as rmSync4, statSync } from "fs";
6996
+ import { join as join13 } from "path";
6642
6997
  function listAssets() {
6643
- if (!existsSync10(RELEASES_DIR)) return [];
6998
+ if (!existsSync11(RELEASES_DIR)) return [];
6644
6999
  const out = [];
6645
7000
  for (const tag of readdirSync(RELEASES_DIR)) {
6646
7001
  const dir = releaseDir(tag);
6647
7002
  if (!statSync(dir).isDirectory()) continue;
6648
7003
  for (const name of readdirSync(dir)) {
6649
7004
  if (name.endsWith(".sha256")) continue;
6650
- const path2 = join10(dir, name);
7005
+ const path2 = join13(dir, name);
6651
7006
  const st = statSync(path2);
6652
7007
  if (!st.isFile()) continue;
6653
7008
  out.push({
@@ -6655,17 +7010,17 @@ function listAssets() {
6655
7010
  name,
6656
7011
  path: path2,
6657
7012
  size: st.size,
6658
- hasSidecar: existsSync10(`${path2}.sha256`)
7013
+ hasSidecar: existsSync11(`${path2}.sha256`)
6659
7014
  });
6660
7015
  }
6661
7016
  }
6662
7017
  return out;
6663
7018
  }
6664
- var pathCmd = new Command24("path").description("Print the cache directory path").action(() => {
7019
+ var pathCmd = new Command25("path").description("Print the cache directory path").action(() => {
6665
7020
  if (isJsonMode()) printJson({ path: CACHE_DIR });
6666
7021
  else process.stdout.write(CACHE_DIR + "\n");
6667
7022
  });
6668
- var listCmd6 = new Command24("list").alias("ls").description("List cached release assets").action(() => {
7023
+ var listCmd6 = new Command25("list").alias("ls").description("List cached release assets").action(() => {
6669
7024
  const assets = listAssets();
6670
7025
  printTable(
6671
7026
  assets.map((a) => ({
@@ -6677,7 +7032,7 @@ var listCmd6 = new Command24("list").alias("ls").description("List cached releas
6677
7032
  ["tag", "asset", "size", "sidecar"]
6678
7033
  );
6679
7034
  });
6680
- var verifyCmd2 = new Command24("verify").description("Re-hash cached assets and compare to their .sha256 sidecar").argument("[tag]", "Only verify assets under this release tag").action(async (tag) => {
7035
+ var verifyCmd2 = new Command25("verify").description("Re-hash cached assets and compare to their .sha256 sidecar").argument("[tag]", "Only verify assets under this release tag").action(async (tag) => {
6681
7036
  const assets = listAssets().filter((a) => !tag || a.tag === tag);
6682
7037
  const results = [];
6683
7038
  let bad = 0;
@@ -6686,7 +7041,7 @@ var verifyCmd2 = new Command24("verify").description("Re-hash cached assets and
6686
7041
  results.push({ tag: a.tag, asset: a.name, result: "no-sidecar" });
6687
7042
  continue;
6688
7043
  }
6689
- const expected = parseSha256(readFileSync8(`${a.path}.sha256`, "utf8"));
7044
+ const expected = parseSha256(readFileSync12(`${a.path}.sha256`, "utf8"));
6690
7045
  const actual = await hashFile(a.path);
6691
7046
  const okMatch = expected !== null && expected === actual;
6692
7047
  if (!okMatch) bad += 1;
@@ -6700,9 +7055,9 @@ var verifyCmd2 = new Command24("verify").description("Re-hash cached assets and
6700
7055
  }
6701
7056
  if (bad > 0) process.exit(1);
6702
7057
  });
6703
- var cleanCmd = new Command24("clean").description("Delete cached release assets").argument("[tag]", "Only remove this release tag (default: all)").action((tag) => {
7058
+ var cleanCmd = new Command25("clean").description("Delete cached release assets").argument("[tag]", "Only remove this release tag (default: all)").action((tag) => {
6704
7059
  const target = tag ? releaseDir(tag) : RELEASES_DIR;
6705
- if (!existsSync10(target)) {
7060
+ if (!existsSync11(target)) {
6706
7061
  if (isJsonMode()) printJson({ removed: false, path: target });
6707
7062
  else info(` Nothing to clean (${target}).`);
6708
7063
  return;
@@ -6713,32 +7068,34 @@ var cleanCmd = new Command24("clean").description("Delete cached release assets"
6713
7068
  Removed ${target}
6714
7069
  `);
6715
7070
  });
6716
- var cacheCommand = new Command24("cache").description("Manage the local download cache (list/verify/clean/path)").action(() => {
7071
+ var cacheCommand = new Command25("cache").description("Manage the local download cache (list/verify/clean/path)").action(() => {
6717
7072
  err("Specify a subcommand: path | list | verify | clean");
6718
7073
  process.exit(1);
6719
7074
  }).addCommand(pathCmd).addCommand(listCmd6).addCommand(verifyCmd2).addCommand(cleanCmd);
6720
7075
 
6721
7076
  // src/commands/wizard.ts
6722
- import chalk16 from "chalk";
7077
+ import chalk17 from "chalk";
6723
7078
  import open from "open";
6724
- import { createServer } from "http";
7079
+ import { createServer as createServer2 } from "http";
6725
7080
  import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
7081
+ import { existsSync as existsSync12, readFileSync as readFileSync13 } from "fs";
7082
+ import { homedir as homedir9 } from "os";
7083
+ import { join as join14 } from "path";
6726
7084
  import {
6727
- intro,
6728
- outro,
7085
+ intro as intro2,
7086
+ outro as outro2,
6729
7087
  text,
6730
7088
  password,
6731
7089
  select,
6732
- confirm as confirm3,
6733
7090
  spinner as spinner2,
6734
7091
  note,
6735
- log,
6736
- cancel as cancel2,
6737
- isCancel
7092
+ log as log2,
7093
+ cancel as cancel3,
7094
+ isCancel as isCancel2
6738
7095
  } from "@clack/prompts";
6739
7096
  function ensure(value) {
6740
- if (isCancel(value)) {
6741
- cancel2("Setup cancelled.");
7097
+ if (isCancel2(value)) {
7098
+ cancel3("Setup cancelled.");
6742
7099
  process.exit(0);
6743
7100
  }
6744
7101
  return value;
@@ -6776,6 +7133,19 @@ async function bootstrapAdmin(apiPort, admin) {
6776
7133
  if (data?.error === "An admin account already exists") return { ok: true, message: "already-exists" };
6777
7134
  return { ok: false, message: data?.error || "failed" };
6778
7135
  }
7136
+ function lastServiceError() {
7137
+ for (const name of ["up.err.log", "up.log"]) {
7138
+ const p = join14(homedir9(), ".openship", "logs", name);
7139
+ if (!existsSync12(p)) continue;
7140
+ try {
7141
+ const lines = readFileSync13(p, "utf8").trim().split("\n");
7142
+ const hit = [...lines].reverse().find((l) => /error|locked|EADDRINUSE|throw|cannot/i.test(l));
7143
+ if (hit) return hit.trim().slice(0, 200);
7144
+ } catch {
7145
+ }
7146
+ }
7147
+ return null;
7148
+ }
6779
7149
  async function waitHealthy(apiPort, seconds = 90) {
6780
7150
  for (let i = 0; i < seconds; i++) {
6781
7151
  await new Promise((r) => setTimeout(r, 1e3));
@@ -6787,6 +7157,20 @@ async function waitHealthy(apiPort, seconds = 90) {
6787
7157
  }
6788
7158
  return false;
6789
7159
  }
7160
+ async function waitDashboard(dashPort, seconds = 45) {
7161
+ for (let i = 0; i < seconds; i++) {
7162
+ await new Promise((r) => setTimeout(r, 1e3));
7163
+ try {
7164
+ const res = await fetch(`http://127.0.0.1:${dashPort}/`, {
7165
+ redirect: "manual",
7166
+ signal: AbortSignal.timeout(2e3)
7167
+ });
7168
+ if (res.status > 0) return true;
7169
+ } catch {
7170
+ }
7171
+ }
7172
+ return false;
7173
+ }
6790
7174
  async function detectPublicIp() {
6791
7175
  for (const url of ["https://api.ipify.org", "https://ifconfig.me/ip"]) {
6792
7176
  try {
@@ -6803,20 +7187,26 @@ var b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g,
6803
7187
  async function connectOpenshipCloud(port) {
6804
7188
  const already = await internalGet(port, "/api/system/cloud-status");
6805
7189
  if (already?.connected) {
6806
- log.success(`Already connected to Openship Cloud${already.user?.email ? ` as ${already.user.email}` : ""}.`);
6807
- return true;
7190
+ log2.success(`Already connected to Openship Cloud${already.user?.email ? ` as ${already.user.email}` : ""}.`);
7191
+ return { email: already.user?.email ?? null };
6808
7192
  }
6809
7193
  const capsEnv = await internalGet(port, "/api/health/env");
6810
7194
  const cloudApiUrl = capsEnv?.cloudApiUrl;
6811
7195
  if (!cloudApiUrl) {
6812
- log.error("Couldn't discover the Openship Cloud URL \u2014 free domain unavailable. Use a custom domain instead.");
6813
- return false;
7196
+ log2.error("Couldn't discover the Openship Cloud URL \u2014 free domain unavailable. Use a custom domain instead.");
7197
+ return null;
6814
7198
  }
6815
7199
  const verifier = b64url(randomBytes2(32));
6816
7200
  const challenge = b64url(createHash2("sha256").update(verifier).digest());
6817
7201
  const state = b64url(randomBytes2(16));
6818
7202
  const codePromise = new Promise((resolve2) => {
6819
- const server2 = createServer((req, res2) => {
7203
+ const page = (ok3) => {
7204
+ const icon = ok3 ? '<path d="M20 6 9 17l-5-5"/>' : '<path d="M18 6 6 18M6 6l12 12"/>';
7205
+ const title = ok3 ? "Connected to Openship Cloud" : "Connection didn\u2019t complete";
7206
+ const msg = ok3 ? "Your instance is now linked to your Openship Cloud account." : "Something went wrong. Return to your terminal and run the connect step again.";
7207
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Openship</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#09090b;color:#e7e7ea;font:15px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}.card{width:min(92vw,420px);padding:40px 36px;text-align:center}.badge{margin:0 auto 20px;display:grid;place-items:center}svg{width:40px;height:40px}h1{margin:0 0 8px;font-size:19px;font-weight:600;letter-spacing:-.2px}p{margin:0;color:#9a9aa2;font-size:14px}.hint{margin-top:22px;font-size:12.5px;color:#6a6a72}</style></head><body><div class="card"><div class="badge"><svg viewBox="0 0 24 24" fill="none" stroke="#e7e7ea" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">${icon}</svg></div><h1>${title}</h1><p>${msg}</p><div class="hint">You can close this tab and return to your terminal.</div></div><script>setTimeout(function(){try{window.close()}catch(e){}},1200)</script></body></html>`;
7208
+ };
7209
+ const server2 = createServer2((req, res2) => {
6820
7210
  const u = new URL(req.url || "/", "http://127.0.0.1");
6821
7211
  if (!u.pathname.startsWith("/callback")) {
6822
7212
  res2.writeHead(404).end();
@@ -6824,11 +7214,10 @@ async function connectOpenshipCloud(port) {
6824
7214
  }
6825
7215
  const code2 = u.searchParams.get("code");
6826
7216
  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
- );
7217
+ const ok3 = !!(code2 && gotState === state);
7218
+ res2.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(page(ok3));
6830
7219
  server2.close();
6831
- resolve2(code2 && gotState === state ? code2 : null);
7220
+ resolve2(ok3 ? code2 : null);
6832
7221
  });
6833
7222
  server2.on("error", () => resolve2(null));
6834
7223
  server2.listen(0, "127.0.0.1", () => {
@@ -6852,16 +7241,16 @@ async function connectOpenshipCloud(port) {
6852
7241
  const code = await codePromise;
6853
7242
  if (!code) {
6854
7243
  s.stop("Openship Cloud wasn't authorized.", 1);
6855
- return false;
7244
+ return null;
6856
7245
  }
6857
7246
  s.message("Linking this instance to Openship Cloud");
6858
7247
  const res = await internalPost(port, "/api/system/cloud-connect", { code, codeVerifier: verifier });
6859
7248
  if (!res.ok) {
6860
7249
  s.stop(`Couldn't link Openship Cloud: ${res.data?.error || "failed"}`, 1);
6861
- return false;
7250
+ return null;
6862
7251
  }
6863
- s.stop("Connected to Openship Cloud.");
6864
- return true;
7252
+ s.stop(`Connected to Openship Cloud${res.data?.email ? ` as ${res.data.email}` : ""}.`);
7253
+ return { email: res.data?.email ?? null };
6865
7254
  }
6866
7255
  async function promptLocalAdmin() {
6867
7256
  const name = ensure(await text({ message: "Your name", validate: (v) => v?.trim() ? void 0 : "Required" })).trim();
@@ -6922,161 +7311,315 @@ async function streamProvision(port, sessionId, s) {
6922
7311
  return ok3;
6923
7312
  }
6924
7313
  async function runWizard() {
6925
- intro(`${chalk16.bgCyan(chalk16.black(" Openship "))}${chalk16.dim(" setup")}`);
6926
- log.message(
6927
- chalk16.dim(
7314
+ intro2(`${chalk17.bgCyan(chalk17.black(" Openship "))}${chalk17.dim(" setup")}`);
7315
+ log2.message(
7316
+ chalk17.dim(
6928
7317
  "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
7318
  )
6930
7319
  );
7320
+ log2.message(chalk17.dim("First, your instance login (email + password) \u2014 this is how you sign in. Domain and Openship Cloud come next and never replace it."));
7321
+ const admin = await promptLocalAdmin();
7322
+ let cloudEmail = null;
6931
7323
  let publicUrl;
6932
7324
  let behindProxy = false;
6933
7325
  let managedEdge = false;
6934
7326
  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
- ]
7327
+ const canManage = process.platform === "linux";
7328
+ const BACK = "__back__";
7329
+ let slug = "";
7330
+ let customDomainInput = "";
7331
+ let byoDomainInput = "";
7332
+ let publicHost = null;
7333
+ async function resolvePublicHost() {
7334
+ if (publicHost) return publicHost;
7335
+ const sp = spinner2();
7336
+ sp.start("Detecting this server's public IP");
7337
+ const detected = await detectPublicIp();
7338
+ if (detected) {
7339
+ sp.stop(`Public IP: ${chalk17.bold(detected)}`);
7340
+ publicHost = detected;
7341
+ return detected;
7342
+ }
7343
+ sp.stop("Couldn't detect the public IP automatically.", 1);
7344
+ publicHost = ensure(
7345
+ await text({
7346
+ message: "This server's public IP or hostname",
7347
+ placeholder: "203.0.113.10",
7348
+ validate: (v) => v?.trim() ? void 0 : "Required \u2014 the edge proxy routes traffic to this address"
6956
7349
  })
6957
- );
6958
- if (domainType === "free") {
6959
- const slug = ensure(
7350
+ ).trim();
7351
+ return publicHost;
7352
+ }
7353
+ let stage = "reach";
7354
+ log2.message(chalk17.dim("These are just starting choices \u2014 domain, Cloud, team, and the rest are all editable later in Settings."));
7355
+ planning: while (true) {
7356
+ if (stage === "reach") {
7357
+ const reach = ensure(
7358
+ await select({
7359
+ message: "How should this instance be reachable?",
7360
+ // Default to public — most people setting up on a server/VPS want a
7361
+ // domain + HTTPS; localhost-only is the deliberate opt-out.
7362
+ initialValue: "public",
7363
+ options: [
7364
+ { value: "public", label: "Public (server / VPS)", hint: "a domain + HTTPS, reachable from anywhere" },
7365
+ { value: "private", label: "This machine only", hint: "localhost \u2014 no domain, log in on this box" }
7366
+ ]
7367
+ })
7368
+ );
7369
+ if (reach === "private") {
7370
+ domainPlan = { type: "none" };
7371
+ publicUrl = void 0;
7372
+ behindProxy = false;
7373
+ managedEdge = false;
7374
+ break planning;
7375
+ }
7376
+ stage = "type";
7377
+ continue;
7378
+ }
7379
+ if (stage === "type") {
7380
+ const domainType = ensure(
7381
+ await select({
7382
+ message: "How do you want a domain + HTTPS?",
7383
+ initialValue: "free",
7384
+ options: [
7385
+ { value: "free", label: "Free domain", hint: "name.opsh.io via Openship Cloud \u2014 HTTPS handled for you" },
7386
+ ...canManage ? [{ value: "custom", label: "Custom domain", hint: "your domain + free Let's Encrypt on this box" }] : [],
7387
+ { value: "byo", label: "Bring your own", hint: "your domain, behind your own reverse proxy" },
7388
+ { value: BACK, label: "\u2190 Back" }
7389
+ ]
7390
+ })
7391
+ );
7392
+ if (domainType === BACK) {
7393
+ stage = "reach";
7394
+ continue;
7395
+ }
7396
+ stage = domainType;
7397
+ continue;
7398
+ }
7399
+ if (stage === "free") {
7400
+ slug = ensure(
6960
7401
  await text({
6961
7402
  message: "Choose your subdomain",
6962
7403
  placeholder: "my-openship",
7404
+ initialValue: slug || void 0,
6963
7405
  validate: (v) => v && SLUG_RE.test(v.trim().toLowerCase()) ? void 0 : "Lowercase letters, digits, hyphens"
6964
7406
  })
6965
7407
  ).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.");
7408
+ const host = await resolvePublicHost();
7409
+ note(
7410
+ `${chalk17.cyan(`https://${slug}.opsh.io`)}
7411
+
7412
+ ${chalk17.dim("served via")} Openship Cloud edge ${chalk17.dim("\u2192")} ${chalk17.cyan(host)}
7413
+
7414
+ ` + chalk17.dim("Openship Cloud terminates HTTPS and forwards to this server."),
7415
+ "Confirm free domain"
7416
+ );
7417
+ const go2 = ensure(
7418
+ await select({
7419
+ message: "Create this free domain?",
7420
+ options: [
7421
+ { value: "go", label: "Create it" },
7422
+ { value: BACK, label: "\u2190 Back", hint: "change subdomain or IP" }
7423
+ ]
7424
+ })
7425
+ );
7426
+ if (go2 === BACK) {
7427
+ stage = "type";
7428
+ continue;
7429
+ }
6970
7430
  publicUrl = `https://${slug}.opsh.io`;
6971
7431
  behindProxy = true;
6972
- domainPlan = { type: "free", slug, publicHost };
6973
- } else if (domainType === "custom") {
6974
- const raw = ensure(
7432
+ domainPlan = { type: "free", slug, publicHost: host };
7433
+ break planning;
7434
+ }
7435
+ if (stage === "custom") {
7436
+ const raw2 = ensure(
6975
7437
  await text({
6976
7438
  message: "Your domain",
6977
7439
  placeholder: "ops.example.com",
7440
+ initialValue: customDomainInput || void 0,
6978
7441
  validate: (v) => v && normalizeUrl(v) ? void 0 : "Enter a valid domain"
6979
7442
  })
6980
7443
  );
6981
- publicUrl = normalizeUrl(raw).replace(/^http:/i, "https:");
6982
- const hostname = new URL(publicUrl).hostname;
6983
- managedEdge = true;
6984
- behindProxy = true;
7444
+ customDomainInput = raw2;
7445
+ const url2 = normalizeUrl(raw2).replace(/^http:/i, "https:");
7446
+ const hostname2 = new URL(url2).hostname;
6985
7447
  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.");
7448
+ log2.warn("Managed HTTPS installs OpenResty + certbot \u2014 that needs root. Re-run with sudo if it can't install.");
6987
7449
  }
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.");
7450
+ const host = await resolvePublicHost();
6992
7451
  note(
6993
- `Add a DNS ${chalk16.bold("A record")}:
7452
+ `Add a DNS ${chalk17.bold("A record")}:
6994
7453
 
6995
- ${chalk16.cyan(hostname)} \u2192 ${chalk16.cyan(ip ?? "<this server's public IP>")}
7454
+ ${chalk17.cyan(hostname2)} \u2192 ${chalk17.cyan(host)}
6996
7455
 
6997
- ` + chalk16.dim("HTTPS is issued automatically once DNS resolves (it retries for a couple minutes)."),
7456
+ ` + chalk17.dim("HTTPS is issued automatically once DNS resolves (it retries for a couple minutes)."),
6998
7457
  "DNS"
6999
7458
  );
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"
7459
+ const go2 = ensure(
7460
+ await select({
7461
+ message: "A record added?",
7462
+ options: [
7463
+ { value: "go", label: "Continue", hint: "HTTPS provisions once DNS resolves \u2014 it retries" },
7464
+ { value: BACK, label: "\u2190 Back", hint: "change the domain" }
7465
+ ]
7008
7466
  })
7009
7467
  );
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.");
7468
+ if (go2 === BACK) {
7469
+ stage = "type";
7470
+ continue;
7014
7471
  }
7015
- domainPlan = { type: "byo", hostname: new URL(publicUrl).hostname };
7472
+ publicUrl = url2;
7473
+ managedEdge = true;
7474
+ behindProxy = true;
7475
+ domainPlan = { type: "custom", hostname: hostname2 };
7476
+ break planning;
7477
+ }
7478
+ const raw = ensure(
7479
+ await text({
7480
+ message: "Your domain (served behind your proxy)",
7481
+ placeholder: "ops.example.com",
7482
+ initialValue: byoDomainInput || void 0,
7483
+ validate: (v) => v && normalizeUrl(v) ? void 0 : "Enter a valid domain"
7484
+ })
7485
+ );
7486
+ byoDomainInput = raw;
7487
+ const url = normalizeUrl(raw);
7488
+ const hostname = new URL(url).hostname;
7489
+ if (url.startsWith("http://")) {
7490
+ log2.warn("Serving over plain HTTP sends passwords in cleartext \u2014 put HTTPS in front before real use.");
7491
+ }
7492
+ note(
7493
+ `${chalk17.cyan(url)}
7494
+
7495
+ ` + chalk17.dim("Point your reverse proxy at the dashboard port shown at the end."),
7496
+ "Confirm"
7497
+ );
7498
+ const go = ensure(
7499
+ await select({
7500
+ message: "Continue?",
7501
+ options: [
7502
+ { value: "go", label: "Continue" },
7503
+ { value: BACK, label: "\u2190 Back", hint: "change the domain" }
7504
+ ]
7505
+ })
7506
+ );
7507
+ if (go === BACK) {
7508
+ stage = "type";
7509
+ continue;
7016
7510
  }
7511
+ publicUrl = url;
7512
+ behindProxy = true;
7513
+ domainPlan = { type: "byo", hostname };
7514
+ break planning;
7515
+ }
7516
+ const uiTag = `v${"0.2.2"}`;
7517
+ const dl = spinner2();
7518
+ dl.start("Pulling the Openship dist from GitHub");
7519
+ try {
7520
+ await ensureDashboard({
7521
+ tag: uiTag,
7522
+ onProgress: (received, total) => {
7523
+ if (total) dl.message(`Pulling the Openship dist from GitHub \u2014 ${Math.round(received / total * 100)}%`);
7524
+ }
7525
+ });
7526
+ dl.stop("Openship dist ready.");
7527
+ } catch (e) {
7528
+ dl.stop(`Couldn't pull the Openship dist: ${e.message}`, 1);
7529
+ log2.info("Check your network / that this release published its dashboard asset, then re-run `openship`.");
7530
+ process.exit(1);
7017
7531
  }
7018
- const isCloudDomain = domainPlan.type === "free";
7019
- const admin = isCloudDomain ? null : await promptLocalAdmin();
7020
7532
  const s = spinner2();
7021
7533
  s.start("Installing Openship as a service");
7022
7534
  let started;
7023
7535
  try {
7024
- started = startService(
7025
- { publicUrl, trustProxy: behindProxy, managedEdge, acmeEmail: managedEdge ? admin?.email : void 0 },
7536
+ started = await startService(
7537
+ { publicUrl, trustProxy: behindProxy, managedEdge, acmeEmail: managedEdge ? admin.email : void 0, uiVersion: uiTag },
7026
7538
  { quiet: true }
7027
7539
  );
7028
7540
  } catch (e) {
7029
7541
  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.");
7542
+ log2.error(e.message);
7543
+ log2.info("Run `openship up --foreground` to run it attached and see the error.");
7032
7544
  process.exit(1);
7033
7545
  }
7034
- s.message("Waiting for Openship to come up");
7546
+ s.message("Waiting for the Openship API");
7035
7547
  if (!await waitHealthy(started.port)) {
7036
7548
  s.stop("Openship didn't become healthy in time.", 1);
7037
- log.info("Check logs: `openship logs` (or `openship up --foreground`).");
7549
+ const reason = lastServiceError();
7550
+ if (reason) log2.error(reason);
7551
+ if (reason && /lock/i.test(reason)) {
7552
+ log2.info("The database is locked by another instance \u2014 run `openship stop`, then re-run `openship`.");
7553
+ } else {
7554
+ log2.info("Check logs: `openship logs` (or `openship up --foreground`).");
7555
+ }
7556
+ process.exit(1);
7557
+ }
7558
+ s.message("Creating your admin account");
7559
+ const adminRes = await bootstrapAdmin(started.port, admin);
7560
+ if (!adminRes.ok) {
7561
+ s.stop(`Couldn't create the admin account: ${adminRes.message}`, 1);
7038
7562
  process.exit(1);
7039
7563
  }
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);
7564
+ if (adminRes.message === "already-exists") {
7565
+ s.message("Applying your admin login");
7566
+ const rr = await internalPost(started.port, "/api/system/reset-admin-password", {
7567
+ email: admin.email,
7568
+ name: admin.name,
7569
+ password: admin.password
7570
+ });
7571
+ if (!rr.ok) {
7572
+ s.stop(`Couldn't set your admin login: ${rr.data?.error || "failed"}`, 1);
7045
7573
  process.exit(1);
7046
7574
  }
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
7575
  }
7576
+ s.message(`Admin ready for ${admin.email}`);
7577
+ s.message("Starting the Openship dashboard");
7578
+ await waitDashboard(started.dashPort);
7579
+ s.stop("Deployed.");
7053
7580
  let liveUrl = publicUrl ?? `http://localhost:${started.dashPort}`;
7054
7581
  const port = started.port;
7055
7582
  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
- }
7583
+ const cloud = await connectOpenshipCloud(port);
7584
+ if (!cloud) {
7585
+ log2.warn("Openship Cloud wasn't connected \u2014 skipping the free domain. Your local admin login still works; add the domain later in Settings \u2192 Cloud.");
7065
7586
  await internalPost(port, "/api/system/self-register", { domainType: "byo" });
7066
7587
  } 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);
7588
+ cloudEmail = cloud.email;
7589
+ let regSlug = domainPlan.slug;
7590
+ while (true) {
7591
+ const s2 = spinner2();
7592
+ s2.start(`Registering ${chalk17.bold(`${regSlug}.opsh.io`)} with Openship Cloud`);
7593
+ const res = await internalPost(port, "/api/system/self-register", {
7594
+ domainType: "free",
7595
+ slug: regSlug,
7596
+ publicHost: domainPlan.publicHost,
7597
+ dashPort: Number(started.dashPort)
7598
+ });
7599
+ if (res.ok && res.data?.url) {
7600
+ liveUrl = res.data.url;
7601
+ s2.stop(`Free domain live: ${res.data.url}`);
7602
+ break;
7603
+ }
7604
+ s2.stop(`Couldn't register ${regSlug}.opsh.io: ${res.data?.error || "failed"}`, 1);
7605
+ const next = ensure(
7606
+ await select({
7607
+ message: "Try a different subdomain?",
7608
+ options: [
7609
+ { value: "retry", label: "Pick another subdomain" },
7610
+ { value: "skip", label: "Skip for now", hint: "log in on this server; add a domain later in Settings \u2192 Cloud" }
7611
+ ]
7612
+ })
7613
+ );
7614
+ if (next === "skip") break;
7615
+ regSlug = ensure(
7616
+ await text({
7617
+ message: "Choose your subdomain",
7618
+ placeholder: "my-openship",
7619
+ initialValue: regSlug,
7620
+ validate: (v) => v && SLUG_RE.test(v.trim().toLowerCase()) ? void 0 : "Lowercase letters, digits, hyphens"
7621
+ })
7622
+ ).trim().toLowerCase();
7080
7623
  }
7081
7624
  }
7082
7625
  } else if (domainPlan.type === "custom") {
@@ -7115,7 +7658,7 @@ async function runWizard() {
7115
7658
  else edgeTakeover = true;
7116
7659
  }
7117
7660
  if (!proceedCustom) {
7118
- log.warn(
7661
+ log2.warn(
7119
7662
  "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
7663
  );
7121
7664
  await internalPost(port, "/api/system/self-register", {
@@ -7140,7 +7683,7 @@ async function runWizard() {
7140
7683
  if (done) s2.stop(`HTTPS ready: ${liveUrl}`);
7141
7684
  else s2.stop("HTTPS isn't ready yet \u2014 it retries on reboot; the site serves over HTTP meanwhile.", 1);
7142
7685
  } else {
7143
- log.warn(`Couldn't start domain provisioning: ${res.data?.error || "failed"}`);
7686
+ log2.warn(`Couldn't start domain provisioning: ${res.data?.error || "failed"}`);
7144
7687
  }
7145
7688
  }
7146
7689
  } else if (domainPlan.type === "byo") {
@@ -7152,23 +7695,105 @@ async function runWizard() {
7152
7695
  } else {
7153
7696
  await internalPost(port, "/api/system/self-register", { domainType: "byo" });
7154
7697
  }
7698
+ saveInstanceUrl(liveUrl);
7699
+ const pad = (label) => chalk17.dim(label.padEnd(11));
7700
+ log2.success(chalk17.bold("Openship is live"));
7701
+ log2.message(
7702
+ `${pad("URL")}${chalk17.bold(liveUrl)}
7703
+ ${pad("Dashboard")}http://localhost:${started.dashPort}
7704
+ ${pad("API")}http://localhost:${started.port}
7705
+ ${pad("Login")}${admin.email} ${chalk17.dim("(email + password you set)")}
7706
+ ` + (cloudEmail ? `${pad("Cloud")}${chalk17.dim("connected as ")}${cloudEmail}${chalk17.dim(" \u2014 free domain + mail only")}
7707
+ ` : "") + `${pad("Status")}${chalk17.green("running")} ${chalk17.dim("\xB7 service (restarts on boot)")}`
7708
+ );
7709
+ log2.message(
7710
+ chalk17.dim("Sign in with the email + password you just set. Openship appears under your Apps.\n") + chalk17.dim("Change the domain, Openship Cloud, team, and everything else anytime in Settings.\n") + chalk17.dim(`Locked out? Run ${chalk17.reset("openship reset-admin-password")}${chalk17.dim(" on this machine \u2014 resets your login without signing in.")}`)
7711
+ );
7712
+ outro2(
7713
+ domainPlan.type === "byo" ? chalk17.dim("Point your reverse proxy at the dashboard port above.") : chalk17.green("Happy shipping.")
7714
+ );
7715
+ }
7716
+ function storedPorts() {
7717
+ const p = join14(homedir9(), ".openship", "ports.json");
7718
+ try {
7719
+ return existsSync12(p) ? JSON.parse(readFileSync13(p, "utf8")) : {};
7720
+ } catch {
7721
+ return {};
7722
+ }
7723
+ }
7724
+ async function runControl() {
7725
+ const svc = serviceStatus();
7726
+ const ports = storedPorts();
7727
+ const apiPort = String(ports.api ?? 4e3);
7728
+ const dashUrl = `http://localhost:${ports.dashboard ?? 3001}`;
7729
+ const publicUrl = readInstanceUrl();
7730
+ const primaryUrl = publicUrl && !/^https?:\/\/localhost/i.test(publicUrl) ? publicUrl : dashUrl;
7731
+ intro2(`${chalk17.bgCyan(chalk17.black(" Openship "))}${chalk17.dim(" control")}`);
7155
7732
  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"
7733
+ `${chalk17.dim("URL".padEnd(11))}${chalk17.bold(primaryUrl)}
7734
+ ${chalk17.dim("Service".padEnd(11))}${svc.running ? chalk17.green("running") : chalk17.yellow("stopped")}
7735
+ ${chalk17.dim("Dashboard".padEnd(11))}${dashUrl}
7736
+ ` + (ports.api ? `${chalk17.dim("API".padEnd(11))}http://localhost:${ports.api}
7737
+ ` : "") + `${chalk17.dim("Manager".padEnd(11))}${svc.kind === "unsupported" ? "none" : svc.kind}`,
7738
+ "Openship is already set up"
7160
7739
  );
7161
- outro(
7162
- domainPlan.type === "byo" ? chalk16.dim("Point your reverse proxy at the dashboard port above.") : chalk16.green("Happy shipping.")
7740
+ const action2 = ensure(
7741
+ await select({
7742
+ message: "What would you like to do?",
7743
+ options: [
7744
+ { value: "open", label: "Open the dashboard" },
7745
+ svc.running ? { value: "restart", label: "Restart the service" } : { value: "start", label: "Start the service" },
7746
+ { value: "stop", label: "Stop the service", hint: "won't restart on boot" },
7747
+ { value: "reset", label: "Reset admin password", hint: "sets a local email + password login" },
7748
+ { value: "reconfigure", label: "Re-run setup", hint: "reconfigure domain / cloud / admin" },
7749
+ { value: "quit", label: "Quit" }
7750
+ ]
7751
+ })
7163
7752
  );
7753
+ switch (action2) {
7754
+ case "open":
7755
+ await open(primaryUrl).catch(() => {
7756
+ });
7757
+ outro2(chalk17.dim(`Opening ${primaryUrl}`));
7758
+ return;
7759
+ case "start":
7760
+ await startService({});
7761
+ return;
7762
+ case "restart": {
7763
+ const r = restart();
7764
+ outro2(r.restarted ? chalk17.green("Restarted.") : chalk17.yellow(r.detail));
7765
+ return;
7766
+ }
7767
+ case "stop": {
7768
+ const r = stop();
7769
+ outro2(chalk17.green(`Stopped. ${chalk17.dim(r.detail)}`));
7770
+ return;
7771
+ }
7772
+ case "reset": {
7773
+ const pw = ensure(
7774
+ await password({ message: "New admin password", validate: (v) => v && v.length >= 8 ? void 0 : "At least 8 characters" })
7775
+ );
7776
+ const rr = await internalPost(apiPort, "/api/system/reset-admin-password", { password: pw });
7777
+ outro2(
7778
+ rr.ok ? chalk17.green(`Password reset. Sign in at ${dashUrl} with your email + new password.`) : chalk17.red(`Couldn't reset: ${rr.data?.error || "failed"}`)
7779
+ );
7780
+ return;
7781
+ }
7782
+ case "reconfigure":
7783
+ await runWizard();
7784
+ return;
7785
+ default:
7786
+ outro2(chalk17.dim("Nothing changed."));
7787
+ }
7164
7788
  }
7165
7789
 
7166
7790
  // src/index.ts
7167
- var program = new Command25();
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) => {
7791
+ var program = new Command26();
7792
+ program.name("openship").description("Openship CLI \u2014 install, run, and manage Openship from your terminal").version("0.2.2").option("--json", "Machine-readable JSON output (stdout data only)").hook("preAction", (thisCommand) => {
7169
7793
  if (thisCommand.opts().json) setJsonMode(true);
7170
7794
  }).action(async () => {
7171
- await runWizard();
7795
+ if (serviceStatus().installed) await runControl();
7796
+ else await runWizard();
7172
7797
  });
7173
7798
  program.addCommand(upCommand);
7174
7799
  program.addCommand(stopCommand);
@@ -7193,5 +7818,6 @@ program.addCommand(mailCommand);
7193
7818
  program.addCommand(backupCommand);
7194
7819
  program.addCommand(tokenCommand);
7195
7820
  program.addCommand(apiCommand);
7821
+ program.addCommand(resetAdminCommand);
7196
7822
  installCommand.addCommand(cacheCommand);
7197
7823
  program.parse();