@openparachute/hub 0.5.13-rc.13 → 0.5.13-rc.21

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.
@@ -2709,3 +2709,231 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
2709
2709
  }
2710
2710
  });
2711
2711
  });
2712
+
2713
+ // --- hub#342 UI pass: "Start using your vault" lead tile + Use it now ---
2714
+
2715
+ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
2716
+ let h: Harness;
2717
+ beforeEach(() => {
2718
+ h = makeHarness();
2719
+ _resetOperationsRegistryForTests();
2720
+ });
2721
+ afterEach(() => h.cleanup());
2722
+
2723
+ test("when only vault is installed, the lead tile links to vault admin", async () => {
2724
+ const db = openHubDb(hubDbPath(h.dir));
2725
+ try {
2726
+ const user = await createUser(db, "owner", "pw");
2727
+ writeManifest(
2728
+ {
2729
+ services: [
2730
+ {
2731
+ name: "parachute-vault",
2732
+ version: "0.1.0",
2733
+ port: 1940,
2734
+ paths: ["/vault/default"],
2735
+ health: "/health",
2736
+ },
2737
+ ],
2738
+ },
2739
+ h.manifestPath,
2740
+ );
2741
+ setSetting(db, "setup_expose_mode", "localhost");
2742
+ const { createSession } = await import("../sessions.ts");
2743
+ const session = createSession(db, { userId: user.id });
2744
+ const res = handleSetupGet(
2745
+ req("/admin/setup?just_finished=1", {
2746
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2747
+ }),
2748
+ {
2749
+ db,
2750
+ manifestPath: h.manifestPath,
2751
+ configDir: h.dir,
2752
+ issuer: "https://hub.example",
2753
+ registry: getDefaultOperationsRegistry(),
2754
+ },
2755
+ );
2756
+ const html = await res.text();
2757
+ // Section heading present + primary CTA points at the vault's own admin.
2758
+ expect(html).toContain("Start using your vault");
2759
+ expect(html).toContain('href="/vault/default/admin/"');
2760
+ // Lead tile precedes the MCP / install tiles (it's the lead).
2761
+ const startIdx = html.indexOf("Start using your vault");
2762
+ const installIdx = html.indexOf("What's next?");
2763
+ expect(startIdx).toBeLessThan(installIdx);
2764
+ } finally {
2765
+ db.close();
2766
+ }
2767
+ });
2768
+
2769
+ test("when app is also installed, the lead tile links to /app/notes/", async () => {
2770
+ const db = openHubDb(hubDbPath(h.dir));
2771
+ try {
2772
+ const user = await createUser(db, "owner", "pw");
2773
+ writeManifest(
2774
+ {
2775
+ services: [
2776
+ {
2777
+ name: "parachute-vault",
2778
+ version: "0.1.0",
2779
+ port: 1940,
2780
+ paths: ["/vault/default"],
2781
+ health: "/health",
2782
+ },
2783
+ {
2784
+ name: "parachute-app",
2785
+ version: "0.2.0",
2786
+ port: 1946,
2787
+ paths: ["/app"],
2788
+ health: "/app/healthz",
2789
+ },
2790
+ ],
2791
+ },
2792
+ h.manifestPath,
2793
+ );
2794
+ setSetting(db, "setup_expose_mode", "localhost");
2795
+ const { createSession } = await import("../sessions.ts");
2796
+ const session = createSession(db, { userId: user.id });
2797
+ const res = handleSetupGet(
2798
+ req("/admin/setup?just_finished=1", {
2799
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2800
+ }),
2801
+ {
2802
+ db,
2803
+ manifestPath: h.manifestPath,
2804
+ configDir: h.dir,
2805
+ issuer: "https://hub.example",
2806
+ registry: getDefaultOperationsRegistry(),
2807
+ },
2808
+ );
2809
+ const html = await res.text();
2810
+ expect(html).toContain("Start using your vault");
2811
+ // App installed → primary CTA links to Notes-as-UI inside App.
2812
+ expect(html).toContain('href="/app/notes/"');
2813
+ expect(html).toContain("Open Notes");
2814
+ } finally {
2815
+ db.close();
2816
+ }
2817
+ });
2818
+
2819
+ test("succeeded install op renders a 'Use it now' link pointing at the module's surface", async () => {
2820
+ const db = openHubDb(hubDbPath(h.dir));
2821
+ try {
2822
+ const user = await createUser(db, "owner", "pw");
2823
+ writeManifest(
2824
+ {
2825
+ services: [
2826
+ {
2827
+ name: "parachute-vault",
2828
+ version: "0.1.0",
2829
+ port: 1940,
2830
+ paths: ["/vault/default"],
2831
+ health: "/health",
2832
+ },
2833
+ ],
2834
+ },
2835
+ h.manifestPath,
2836
+ );
2837
+ setSetting(db, "setup_expose_mode", "localhost");
2838
+ const reg = getDefaultOperationsRegistry();
2839
+ const op = reg.create("install", "app");
2840
+ reg.update(op.id, { status: "succeeded" }, "installed @openparachute/app");
2841
+ const { createSession } = await import("../sessions.ts");
2842
+ const session = createSession(db, { userId: user.id });
2843
+ const res = handleSetupGet(
2844
+ req(`/admin/setup?just_finished=1&op_app=${op.id}`, {
2845
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2846
+ }),
2847
+ {
2848
+ db,
2849
+ manifestPath: h.manifestPath,
2850
+ configDir: h.dir,
2851
+ issuer: "https://hub.example",
2852
+ registry: reg,
2853
+ },
2854
+ );
2855
+ const html = await res.text();
2856
+ expect(html).toContain("status: succeeded");
2857
+ // Primary "Use it now" link goes to the app's surface; secondary
2858
+ // "Manage modules" link still present.
2859
+ expect(html).toContain(">Use it now<");
2860
+ expect(html).toContain('href="/app/notes/"');
2861
+ expect(html).toContain(">Manage modules<");
2862
+ } finally {
2863
+ db.close();
2864
+ }
2865
+ });
2866
+
2867
+ test("'Already installed' tile gains a 'Use it now' link too", async () => {
2868
+ const db = openHubDb(hubDbPath(h.dir));
2869
+ try {
2870
+ const user = await createUser(db, "owner", "pw");
2871
+ writeManifest(
2872
+ {
2873
+ services: [
2874
+ {
2875
+ name: "parachute-vault",
2876
+ version: "0.1.0",
2877
+ port: 1940,
2878
+ paths: ["/vault/default"],
2879
+ health: "/health",
2880
+ },
2881
+ {
2882
+ name: "parachute-app",
2883
+ version: "0.2.0",
2884
+ port: 1946,
2885
+ paths: ["/app"],
2886
+ health: "/app/healthz",
2887
+ },
2888
+ ],
2889
+ },
2890
+ h.manifestPath,
2891
+ );
2892
+ setSetting(db, "setup_expose_mode", "localhost");
2893
+ const { createSession } = await import("../sessions.ts");
2894
+ const session = createSession(db, { userId: user.id });
2895
+ const res = handleSetupGet(
2896
+ req("/admin/setup?just_finished=1", {
2897
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2898
+ }),
2899
+ {
2900
+ db,
2901
+ manifestPath: h.manifestPath,
2902
+ configDir: h.dir,
2903
+ issuer: "https://hub.example",
2904
+ registry: getDefaultOperationsRegistry(),
2905
+ },
2906
+ );
2907
+ const html = await res.text();
2908
+ expect(html).toContain("Already installed");
2909
+ // App's already-installed tile carries the Use it now link.
2910
+ expect(html).toContain('href="/app/notes/"');
2911
+ } finally {
2912
+ db.close();
2913
+ }
2914
+ });
2915
+
2916
+ test("install-log CSS includes overflow-wrap so long lines wrap in the card", async () => {
2917
+ // Smoke test for the CSS fold (hub#342): the .op-log block sets
2918
+ // overflow-x:auto and the .log-lines li set white-space:pre-wrap +
2919
+ // overflow-wrap:anywhere. These are the three properties Aaron's
2920
+ // bug report flagged — without them long install logs blow up the
2921
+ // wizard layout.
2922
+ const db = openHubDb(hubDbPath(h.dir));
2923
+ try {
2924
+ const res = handleSetupGet(req("/admin/setup"), {
2925
+ db,
2926
+ manifestPath: h.manifestPath,
2927
+ configDir: h.dir,
2928
+ issuer: "https://hub.example",
2929
+ registry: getDefaultOperationsRegistry(),
2930
+ });
2931
+ const html = await res.text();
2932
+ expect(html).toContain("overflow-x: auto");
2933
+ expect(html).toContain("white-space: pre-wrap");
2934
+ expect(html).toContain("overflow-wrap: anywhere");
2935
+ } finally {
2936
+ db.close();
2937
+ }
2938
+ });
2939
+ });
@@ -686,12 +686,12 @@ describe("status", () => {
686
686
  try {
687
687
  upsertService(
688
688
  {
689
- name: "agent",
689
+ name: "someapp",
690
690
  port: 1946,
691
- paths: ["/agent"],
692
- health: "/agent/health",
691
+ paths: ["/someapp"],
692
+ health: "/someapp/health",
693
693
  version: "0.1.4-rc.1",
694
- // No installDir; agent isn't in FIRST_PARTY_FALLBACKS by short name,
694
+ // No installDir; someapp isn't in FIRST_PARTY_FALLBACKS by short name,
695
695
  // and the fallback bun-global lookup needs a known package name.
696
696
  },
697
697
  path,
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { UpgradeRunner } from "../commands/upgrade.ts";
6
- import { upgrade } from "../commands/upgrade.ts";
6
+ import { compareVersions, detectChannel, upgrade } from "../commands/upgrade.ts";
7
7
  import { upsertService } from "../services-manifest.ts";
8
8
 
9
9
  interface RunCall {
@@ -288,7 +288,13 @@ describe("parachute upgrade", () => {
288
288
  }
289
289
  });
290
290
 
291
- test("bun-linked frontend: runs bun run build before restart", async () => {
291
+ // hub#301 Phase C/D (#330): `kind` retired and the bun-linked
292
+ // `kind === "frontend"` build branch retires with it. Notes-daemon's
293
+ // `prepublishOnly` builds dist at publish time so consumers don't need a
294
+ // post-install rebuild; this test pins the new behavior — even a
295
+ // historical frontend module (with a `build` script in package.json)
296
+ // does NOT trigger `bun run build` during an upgrade.
297
+ test("bun-linked: no bun run build invoked (kind branch retired in #330)", async () => {
292
298
  const h = makeHarness();
293
299
  try {
294
300
  const installDir = join(h.installRoot, "notes");
@@ -341,7 +347,7 @@ describe("parachute upgrade", () => {
341
347
  log: () => {},
342
348
  });
343
349
  expect(code).toBe(0);
344
- expect(ranBuild.value).toBe(true);
350
+ expect(ranBuild.value).toBe(false);
345
351
  } finally {
346
352
  h.cleanup();
347
353
  }
@@ -781,4 +787,357 @@ describe("parachute upgrade", () => {
781
787
  h.cleanup();
782
788
  }
783
789
  });
790
+
791
+ // -------------------------------------------------------------------------
792
+ // hub#332 — channel preservation + downgrade refusal
793
+ // -------------------------------------------------------------------------
794
+
795
+ test("detectChannel: rc suffixes → 'rc', everything else → 'latest'", () => {
796
+ expect(detectChannel("0.5.13-rc.13")).toBe("rc");
797
+ expect(detectChannel("0.5.13-rc.1")).toBe("rc");
798
+ expect(detectChannel("0.5.13-rc")).toBe("rc"); // no .N suffix
799
+ expect(detectChannel("0.5.10")).toBe("latest");
800
+ expect(detectChannel("1.0.0")).toBe("latest");
801
+ expect(detectChannel("0.5.13-beta.1")).toBe("latest"); // not rc
802
+ expect(detectChannel("0.5.13-alpha")).toBe("latest");
803
+ });
804
+
805
+ test("compareVersions: stable > matching rc; later rc > earlier rc", () => {
806
+ // Stable beats prerelease at equal triple (semver §11.4.3)
807
+ expect(compareVersions("0.5.13", "0.5.13-rc.13")).toBeGreaterThan(0);
808
+ expect(compareVersions("0.5.13-rc.13", "0.5.13")).toBeLessThan(0);
809
+ // Aaron's reproducer: 0.5.10 < 0.5.13-rc.13 (lower triple beats prerelease tail)
810
+ expect(compareVersions("0.5.10", "0.5.13-rc.13")).toBeLessThan(0);
811
+ expect(compareVersions("0.5.13-rc.14", "0.5.13-rc.13")).toBeGreaterThan(0);
812
+ expect(compareVersions("0.5.13-rc.13", "0.5.13-rc.13")).toBe(0);
813
+ // Patch difference dominates prerelease tail
814
+ expect(compareVersions("0.5.14", "0.5.13-rc.99")).toBeGreaterThan(0);
815
+ // Garbage in → null
816
+ expect(compareVersions("not-a-version", "0.5.10")).toBeNull();
817
+ });
818
+
819
+ test("auto-detects rc channel: installed 0.5.13-rc.13 → bun add -g @openparachute/hub@rc", async () => {
820
+ const h = makeHarness();
821
+ try {
822
+ const hubInstallDir = join(h.installRoot, "hub");
823
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.13-rc.13" });
824
+
825
+ const seenCmd: string[][] = [];
826
+ const runner: UpgradeRunner = {
827
+ async run(cmd) {
828
+ seenCmd.push([...cmd]);
829
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
830
+ writePackageJson(hubInstallDir, {
831
+ name: "@openparachute/hub",
832
+ version: "0.5.13-rc.14",
833
+ });
834
+ }
835
+ return 0;
836
+ },
837
+ async capture(cmd) {
838
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
839
+ return { code: 128, stdout: "" };
840
+ }
841
+ return { code: 0, stdout: "" };
842
+ },
843
+ };
844
+
845
+ const code = await upgrade("hub", {
846
+ manifestPath: h.manifestPath,
847
+ configDir: h.configDir,
848
+ runner,
849
+ findGlobalInstall: (pkg) =>
850
+ pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
851
+ restartFn: async () => 0,
852
+ // No resolveChannelVersion → npm view stub returns empty → guard skipped
853
+ log: () => {},
854
+ });
855
+ expect(code).toBe(0);
856
+ const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
857
+ expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
858
+ } finally {
859
+ h.cleanup();
860
+ }
861
+ });
862
+
863
+ test("auto-detects stable channel: installed 0.5.10 → bun add -g @openparachute/hub@latest", async () => {
864
+ const h = makeHarness();
865
+ try {
866
+ const hubInstallDir = join(h.installRoot, "hub");
867
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.10" });
868
+
869
+ const seenCmd: string[][] = [];
870
+ const runner: UpgradeRunner = {
871
+ async run(cmd) {
872
+ seenCmd.push([...cmd]);
873
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
874
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.11" });
875
+ }
876
+ return 0;
877
+ },
878
+ async capture(cmd) {
879
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
880
+ return { code: 128, stdout: "" };
881
+ }
882
+ return { code: 0, stdout: "" };
883
+ },
884
+ };
885
+
886
+ await upgrade("hub", {
887
+ manifestPath: h.manifestPath,
888
+ configDir: h.configDir,
889
+ runner,
890
+ findGlobalInstall: (pkg) =>
891
+ pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
892
+ restartFn: async () => 0,
893
+ log: () => {},
894
+ });
895
+ const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
896
+ expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@latest"]);
897
+ } finally {
898
+ h.cleanup();
899
+ }
900
+ });
901
+
902
+ test("--channel rc overrides stable detection", async () => {
903
+ const h = makeHarness();
904
+ try {
905
+ const hubInstallDir = join(h.installRoot, "hub");
906
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.10" });
907
+
908
+ const seenCmd: string[][] = [];
909
+ const runner: UpgradeRunner = {
910
+ async run(cmd) {
911
+ seenCmd.push([...cmd]);
912
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
913
+ writePackageJson(hubInstallDir, {
914
+ name: "@openparachute/hub",
915
+ version: "0.5.13-rc.14",
916
+ });
917
+ }
918
+ return 0;
919
+ },
920
+ async capture(cmd) {
921
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
922
+ return { code: 128, stdout: "" };
923
+ }
924
+ return { code: 0, stdout: "" };
925
+ },
926
+ };
927
+
928
+ await upgrade("hub", {
929
+ manifestPath: h.manifestPath,
930
+ configDir: h.configDir,
931
+ runner,
932
+ findGlobalInstall: (pkg) =>
933
+ pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
934
+ restartFn: async () => 0,
935
+ channel: "rc",
936
+ log: () => {},
937
+ });
938
+ const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
939
+ expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
940
+ } finally {
941
+ h.cleanup();
942
+ }
943
+ });
944
+
945
+ test("refuses downgrade: installed 0.5.13-rc.13, @rc resolves to 0.5.10 → abort", async () => {
946
+ // Aaron's exact reproducer modulo the channel fix — once channel detection
947
+ // lands, @rc is the right tag; if @rc itself somehow resolves backward we
948
+ // still refuse.
949
+ const h = makeHarness();
950
+ try {
951
+ const hubInstallDir = join(h.installRoot, "hub");
952
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.13-rc.13" });
953
+
954
+ const seenCmd: string[][] = [];
955
+ const runner: UpgradeRunner = {
956
+ async run(cmd) {
957
+ seenCmd.push([...cmd]);
958
+ return 0;
959
+ },
960
+ async capture(cmd) {
961
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
962
+ return { code: 128, stdout: "" };
963
+ }
964
+ return { code: 0, stdout: "" };
965
+ },
966
+ };
967
+
968
+ const logs: string[] = [];
969
+ let restartCalled = false;
970
+ const code = await upgrade("hub", {
971
+ manifestPath: h.manifestPath,
972
+ configDir: h.configDir,
973
+ runner,
974
+ findGlobalInstall: (pkg) =>
975
+ pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
976
+ restartFn: async () => {
977
+ restartCalled = true;
978
+ return 0;
979
+ },
980
+ resolveChannelVersion: async (_pkg, _channel) => "0.5.10",
981
+ log: (l) => logs.push(l),
982
+ });
983
+ expect(code).toBe(1);
984
+ expect(restartCalled).toBe(false);
985
+ // bun add was never run
986
+ const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
987
+ expect(addCall).toBeUndefined();
988
+ const joined = logs.join("\n");
989
+ expect(joined).toMatch(/refusing to downgrade/);
990
+ expect(joined).toMatch(/installed 0\.5\.13-rc\.13/);
991
+ expect(joined).toMatch(/0\.5\.10/);
992
+ expect(joined).toMatch(/--allow-downgrade/);
993
+ } finally {
994
+ h.cleanup();
995
+ }
996
+ });
997
+
998
+ test("--allow-downgrade bypasses the refusal", async () => {
999
+ const h = makeHarness();
1000
+ try {
1001
+ const hubInstallDir = join(h.installRoot, "hub");
1002
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.13-rc.13" });
1003
+
1004
+ const seenCmd: string[][] = [];
1005
+ const runner: UpgradeRunner = {
1006
+ async run(cmd) {
1007
+ seenCmd.push([...cmd]);
1008
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
1009
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.10" });
1010
+ }
1011
+ return 0;
1012
+ },
1013
+ async capture(cmd) {
1014
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
1015
+ return { code: 128, stdout: "" };
1016
+ }
1017
+ return { code: 0, stdout: "" };
1018
+ },
1019
+ };
1020
+
1021
+ const code = await upgrade("hub", {
1022
+ manifestPath: h.manifestPath,
1023
+ configDir: h.configDir,
1024
+ runner,
1025
+ findGlobalInstall: (pkg) =>
1026
+ pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
1027
+ restartFn: async () => 0,
1028
+ resolveChannelVersion: async () => "0.5.10",
1029
+ allowDowngrade: true,
1030
+ log: () => {},
1031
+ });
1032
+ expect(code).toBe(0);
1033
+ const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
1034
+ // Channel was auto-detected as `rc` from the rc-suffixed installed version
1035
+ expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
1036
+ } finally {
1037
+ h.cleanup();
1038
+ }
1039
+ });
1040
+
1041
+ test("Aaron's reproducer: installed 0.5.13-rc.13 → upgrades via @rc, version goes UP not down", async () => {
1042
+ // This is the exact bug from hub#332. Before the fix, `parachute upgrade
1043
+ // hub` ran `bun add -g @openparachute/hub@latest` which (because @latest
1044
+ // pointed at 0.5.10) silently downgraded an rc.13 install. With auto-
1045
+ // channel detection: rc.13 → @rc → 0.5.13-rc.14, version increases.
1046
+ const h = makeHarness();
1047
+ try {
1048
+ const hubInstallDir = join(h.installRoot, "hub");
1049
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.13-rc.13" });
1050
+
1051
+ const seenCmd: string[][] = [];
1052
+ const runner: UpgradeRunner = {
1053
+ async run(cmd) {
1054
+ seenCmd.push([...cmd]);
1055
+ if (
1056
+ cmd[0] === "bun" &&
1057
+ cmd[1] === "add" &&
1058
+ cmd[2] === "-g" &&
1059
+ cmd[3] === "@openparachute/hub@rc"
1060
+ ) {
1061
+ writePackageJson(hubInstallDir, {
1062
+ name: "@openparachute/hub",
1063
+ version: "0.5.13-rc.14",
1064
+ });
1065
+ }
1066
+ return 0;
1067
+ },
1068
+ async capture(cmd) {
1069
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
1070
+ return { code: 128, stdout: "" };
1071
+ }
1072
+ return { code: 0, stdout: "" };
1073
+ },
1074
+ };
1075
+
1076
+ const logs: string[] = [];
1077
+ let restartedShort: string | undefined;
1078
+ const code = await upgrade("hub", {
1079
+ manifestPath: h.manifestPath,
1080
+ configDir: h.configDir,
1081
+ runner,
1082
+ findGlobalInstall: (pkg) =>
1083
+ pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
1084
+ restartFn: async (svc) => {
1085
+ restartedShort = svc;
1086
+ return 0;
1087
+ },
1088
+ // @rc resolves to a HIGHER version than installed — no downgrade.
1089
+ resolveChannelVersion: async (_pkg, channel) =>
1090
+ channel === "rc" ? "0.5.13-rc.14" : "0.5.10",
1091
+ log: (l) => logs.push(l),
1092
+ });
1093
+ expect(code).toBe(0);
1094
+ expect(restartedShort).toBe("hub");
1095
+ const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
1096
+ expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
1097
+ const joined = logs.join("\n");
1098
+ // Version went UP (rc.13 → rc.14), not DOWN as in the original report
1099
+ expect(joined).toMatch(/0\.5\.13-rc\.13 → 0\.5\.13-rc\.14/);
1100
+ } finally {
1101
+ h.cleanup();
1102
+ }
1103
+ });
1104
+
1105
+ test("--tag still overrides everything (back-compat for programmatic pin)", async () => {
1106
+ const h = makeHarness();
1107
+ try {
1108
+ const hubInstallDir = join(h.installRoot, "hub");
1109
+ writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.13-rc.13" });
1110
+
1111
+ const seenCmd: string[][] = [];
1112
+ const runner: UpgradeRunner = {
1113
+ async run(cmd) {
1114
+ seenCmd.push([...cmd]);
1115
+ return 0;
1116
+ },
1117
+ async capture(cmd) {
1118
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
1119
+ return { code: 128, stdout: "" };
1120
+ }
1121
+ return { code: 0, stdout: "" };
1122
+ },
1123
+ };
1124
+
1125
+ await upgrade("hub", {
1126
+ manifestPath: h.manifestPath,
1127
+ configDir: h.configDir,
1128
+ runner,
1129
+ findGlobalInstall: (pkg) =>
1130
+ pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
1131
+ restartFn: async () => 0,
1132
+ // Programmatic `tag` wins over auto-detection AND --channel.
1133
+ tag: "next",
1134
+ channel: "rc",
1135
+ log: () => {},
1136
+ });
1137
+ const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
1138
+ expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@next"]);
1139
+ } finally {
1140
+ h.cleanup();
1141
+ }
1142
+ });
784
1143
  });