@openparachute/hub 0.5.1 → 0.5.7
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/package.json +1 -1
- package/src/__tests__/admin-handlers.test.ts +92 -0
- package/src/__tests__/expose-2fa-warning.test.ts +125 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +1227 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +13 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +737 -1
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +367 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +173 -0
- package/src/admin-handlers.ts +38 -13
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +28 -1
- package/src/help.ts +3 -3
- package/src/hub-server.ts +266 -32
- package/src/module-manifest.ts +19 -0
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +249 -12
- package/src/oauth-ui.ts +167 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +163 -0
- package/src/service-spec.ts +66 -13
- package/src/services-manifest.ts +83 -3
- package/src/sessions.ts +19 -0
|
@@ -264,9 +264,11 @@ describe("install", () => {
|
|
|
264
264
|
|
|
265
265
|
test("CLI overrides a non-canonical port written by init when canonical is free", async () => {
|
|
266
266
|
// Pre-#53 the CLI deferred to whatever port the service's init wrote
|
|
267
|
-
// (e.g. 5173, Vite's dev default for notes). With
|
|
268
|
-
// the canonical slot wins when free:
|
|
269
|
-
//
|
|
267
|
+
// (e.g. 5173, Vite's dev default for notes). With hub-as-port-authority
|
|
268
|
+
// the canonical slot wins when free: services.json is updated to the
|
|
269
|
+
// canonical port (post-hub#206 the install path no longer touches .env;
|
|
270
|
+
// services.json is the single source of truth at boot per the 4-tier
|
|
271
|
+
// resolvePort ladder in scribe/agent).
|
|
270
272
|
const { path, configDir, cleanup } = makeTempPath();
|
|
271
273
|
try {
|
|
272
274
|
const logs: string[] = [];
|
|
@@ -1047,11 +1049,14 @@ describe("install", () => {
|
|
|
1047
1049
|
}
|
|
1048
1050
|
});
|
|
1049
1051
|
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
// the
|
|
1054
|
-
|
|
1052
|
+
// Hub-as-port-authority (#53), services.json-is-authoritative (#206).
|
|
1053
|
+
// Install picks the service's port up front and reflects it in
|
|
1054
|
+
// services.json. Pre-#206 it also wrote `PORT=<port>` into the service's
|
|
1055
|
+
// `.env`; post-#206 it doesn't — services.json is the single source of
|
|
1056
|
+
// truth at boot per the 4-tier resolvePort ladder in scribe#41 / agent#146
|
|
1057
|
+
// / agent#148, so the duplicate `.env` PORT was at best dead weight and
|
|
1058
|
+
// at worst a source of drift on re-install.
|
|
1059
|
+
test("install reflects canonical port in services.json without writing PORT to .env (hub#206)", async () => {
|
|
1055
1060
|
const { path, configDir, cleanup } = makeTempPath();
|
|
1056
1061
|
try {
|
|
1057
1062
|
const code = await install("vault", {
|
|
@@ -1064,34 +1069,38 @@ describe("install", () => {
|
|
|
1064
1069
|
log: () => {},
|
|
1065
1070
|
});
|
|
1066
1071
|
expect(code).toBe(0);
|
|
1067
|
-
|
|
1068
|
-
|
|
1072
|
+
// services.json is authoritative — that's where the port lives.
|
|
1073
|
+
const entry = findService("parachute-vault", path);
|
|
1074
|
+
expect(entry?.port).toBe(1940);
|
|
1075
|
+
// .env should NOT have a PORT line. The directory may not even
|
|
1076
|
+
// exist (nothing in this test path writes to the service's config
|
|
1077
|
+
// dir); if it does, the file shouldn't carry PORT.
|
|
1078
|
+
const envPath = join(configDir, "vault", ".env");
|
|
1079
|
+
if (existsSync(envPath)) {
|
|
1080
|
+
expect(readFileSync(envPath, "utf8")).not.toMatch(/^PORT=/m);
|
|
1081
|
+
}
|
|
1069
1082
|
} finally {
|
|
1070
1083
|
cleanup();
|
|
1071
1084
|
}
|
|
1072
1085
|
});
|
|
1073
1086
|
|
|
1074
|
-
test("install
|
|
1087
|
+
test("install does NOT preserve a pre-existing PORT in .env across re-installs (hub#206)", async () => {
|
|
1088
|
+
// Pre-#206 a stale `.env` PORT survived a re-install: an operator
|
|
1089
|
+
// who edited services.json to fix a duplicate would get re-stamped
|
|
1090
|
+
// by the .env on the next `parachute install`. Post-#206 services.json
|
|
1091
|
+
// is authoritative; the install path leaves `.env` alone but
|
|
1092
|
+
// services.json reflects the freshly-assigned port. The stale `.env`
|
|
1093
|
+
// PORT is harmless because the boot-time resolvePort ladder reads
|
|
1094
|
+
// services.json before falling through to the bare PORT env tier.
|
|
1075
1095
|
const { path, configDir, cleanup } = makeTempPath();
|
|
1076
1096
|
try {
|
|
1077
|
-
// First install assigns canonical 1940.
|
|
1078
|
-
await install("vault", {
|
|
1079
|
-
runner: async () => 0,
|
|
1080
|
-
manifestPath: path,
|
|
1081
|
-
configDir,
|
|
1082
|
-
startService: async () => 0,
|
|
1083
|
-
isLinked: () => false,
|
|
1084
|
-
portProbe: async () => false,
|
|
1085
|
-
log: () => {},
|
|
1086
|
-
});
|
|
1087
|
-
// Hand-edit .env to use a custom port (operator override).
|
|
1088
1097
|
const envPath = join(configDir, "vault", ".env");
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1098
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
1099
|
+
mkdirSync(join(configDir, "vault"), { recursive: true });
|
|
1100
|
+
// Pre-existing .env with an operator-edited (now-stale) PORT.
|
|
1101
|
+
const before = "PORT=1947\nOTHER=keepme\n";
|
|
1102
|
+
writeFileSync(envPath, before);
|
|
1093
1103
|
|
|
1094
|
-
// Second install must preserve the operator's choice, not stomp it.
|
|
1095
1104
|
await install("vault", {
|
|
1096
1105
|
runner: async () => 0,
|
|
1097
1106
|
manifestPath: path,
|
|
@@ -1101,13 +1110,20 @@ describe("install", () => {
|
|
|
1101
1110
|
portProbe: async () => false,
|
|
1102
1111
|
log: () => {},
|
|
1103
1112
|
});
|
|
1104
|
-
|
|
1113
|
+
|
|
1114
|
+
// services.json gets the freshly-assigned canonical port (1940),
|
|
1115
|
+
// NOT the stale 1947 from .env.
|
|
1116
|
+
const entry = findService("parachute-vault", path);
|
|
1117
|
+
expect(entry?.port).toBe(1940);
|
|
1118
|
+
// .env is bit-for-bit untouched: the stale PORT stays, OTHER stays,
|
|
1119
|
+
// and we did NOT rewrite the file with a new PORT line.
|
|
1120
|
+
expect(readFileSync(envPath, "utf8")).toBe(before);
|
|
1105
1121
|
} finally {
|
|
1106
1122
|
cleanup();
|
|
1107
1123
|
}
|
|
1108
1124
|
});
|
|
1109
1125
|
|
|
1110
|
-
test("install falls back inside the canonical range when the slot is occupied", async () => {
|
|
1126
|
+
test("install falls back inside the canonical range when the slot is occupied (hub#206 — no .env write)", async () => {
|
|
1111
1127
|
const { path, configDir, cleanup } = makeTempPath();
|
|
1112
1128
|
try {
|
|
1113
1129
|
// Pretend something else is on 1940.
|
|
@@ -1133,11 +1149,14 @@ describe("install", () => {
|
|
|
1133
1149
|
});
|
|
1134
1150
|
expect(code).toBe(0);
|
|
1135
1151
|
// First reservation slot is 1944.
|
|
1136
|
-
const envText = readFileSync(join(configDir, "vault", ".env"), "utf8");
|
|
1137
|
-
expect(envText).toContain("PORT=1944");
|
|
1138
1152
|
const entry = findService("parachute-vault", path);
|
|
1139
1153
|
expect(entry?.port).toBe(1944);
|
|
1140
1154
|
expect(logs.join("\n")).toMatch(/canonical port 1940 is in use/);
|
|
1155
|
+
// .env is not touched.
|
|
1156
|
+
const envPath = join(configDir, "vault", ".env");
|
|
1157
|
+
if (existsSync(envPath)) {
|
|
1158
|
+
expect(readFileSync(envPath, "utf8")).not.toMatch(/^PORT=/m);
|
|
1159
|
+
}
|
|
1141
1160
|
} finally {
|
|
1142
1161
|
cleanup();
|
|
1143
1162
|
}
|
|
@@ -248,7 +248,11 @@ describe("parachute start", () => {
|
|
|
248
248
|
configDir: h.configDir,
|
|
249
249
|
manifestPath: h.manifestPath,
|
|
250
250
|
spawner,
|
|
251
|
-
alive
|
|
251
|
+
// Stale 4242 is dead; the freshly spawned 7777 is alive — the
|
|
252
|
+
// post-spawn settle (hub#194) calls alive(pid) on the new pid,
|
|
253
|
+
// so we differentiate per-pid rather than blanket-false.
|
|
254
|
+
alive: (pid) => pid === 7777,
|
|
255
|
+
sleep: async () => {},
|
|
252
256
|
log: () => {},
|
|
253
257
|
});
|
|
254
258
|
expect(code).toBe(0);
|
|
@@ -629,6 +633,94 @@ describe("parachute start", () => {
|
|
|
629
633
|
}
|
|
630
634
|
});
|
|
631
635
|
|
|
636
|
+
test("hub#194: reports failure when child dies before the settle window", async () => {
|
|
637
|
+
// The bug: `parachute start notes` reported `✓ notes started (pid X)`
|
|
638
|
+
// but notes-serve crashed milliseconds later on a Bun.resolveSync
|
|
639
|
+
// failure, leaving tailnet `/notes/` 502'ing. Fix: after spawn, sleep
|
|
640
|
+
// ~250ms then re-check alive(pid). If dead, clear pidfile, log
|
|
641
|
+
// failure, return non-zero. This regression test pins the post-fix
|
|
642
|
+
// shape with a stub alive that always reports dead and a fast settle.
|
|
643
|
+
const h = makeHarness();
|
|
644
|
+
try {
|
|
645
|
+
seedVault(h.manifestPath);
|
|
646
|
+
const spawner = makeSpawner([4242]);
|
|
647
|
+
const lines: string[] = [];
|
|
648
|
+
const code = await start("vault", {
|
|
649
|
+
configDir: h.configDir,
|
|
650
|
+
manifestPath: h.manifestPath,
|
|
651
|
+
spawner,
|
|
652
|
+
alive: () => false, // child dies immediately after spawn
|
|
653
|
+
sleep: async () => {}, // skip the real wait in tests
|
|
654
|
+
startSettleMs: 1, // any non-zero value engages the check
|
|
655
|
+
log: (l) => lines.push(l),
|
|
656
|
+
});
|
|
657
|
+
expect(code).toBe(1);
|
|
658
|
+
expect(spawner.calls).toHaveLength(1);
|
|
659
|
+
// pidfile is cleared so a follow-up `start` doesn't report
|
|
660
|
+
// already-running against a corpse.
|
|
661
|
+
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
662
|
+
const out = lines.join("\n");
|
|
663
|
+
expect(out).toMatch(/✗ vault failed to start/);
|
|
664
|
+
expect(out).toMatch(/exited within 1ms/);
|
|
665
|
+
expect(out).toMatch(/Tail the log/);
|
|
666
|
+
expect(out).not.toMatch(/✓ vault started/);
|
|
667
|
+
} finally {
|
|
668
|
+
h.cleanup();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("hub#194: settle path passes when child stays alive past the window", async () => {
|
|
673
|
+
// Companion to the above — verifies the success-path shape doesn't
|
|
674
|
+
// regress. Stub alive returns true so the post-spawn check passes,
|
|
675
|
+
// and we still see the `✓ ... started` line.
|
|
676
|
+
const h = makeHarness();
|
|
677
|
+
try {
|
|
678
|
+
seedVault(h.manifestPath);
|
|
679
|
+
const spawner = makeSpawner([4242]);
|
|
680
|
+
const lines: string[] = [];
|
|
681
|
+
const code = await start("vault", {
|
|
682
|
+
configDir: h.configDir,
|
|
683
|
+
manifestPath: h.manifestPath,
|
|
684
|
+
spawner,
|
|
685
|
+
alive: () => true,
|
|
686
|
+
sleep: async () => {},
|
|
687
|
+
startSettleMs: 1,
|
|
688
|
+
log: (l) => lines.push(l),
|
|
689
|
+
});
|
|
690
|
+
expect(code).toBe(0);
|
|
691
|
+
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
692
|
+
expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
|
|
693
|
+
} finally {
|
|
694
|
+
h.cleanup();
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("hub#194: settle skipped when startSettleMs is 0", async () => {
|
|
699
|
+
// Defense — don't regress the test-default policy. With a stub
|
|
700
|
+
// spawner and no `alive` override, the resolved settle is 0 (see
|
|
701
|
+
// resolve() in lifecycle.ts), so the post-spawn check is bypassed
|
|
702
|
+
// entirely and even an `alive: () => false` doesn't matter.
|
|
703
|
+
const h = makeHarness();
|
|
704
|
+
try {
|
|
705
|
+
seedVault(h.manifestPath);
|
|
706
|
+
const spawner = makeSpawner([4242]);
|
|
707
|
+
const code = await start("vault", {
|
|
708
|
+
configDir: h.configDir,
|
|
709
|
+
manifestPath: h.manifestPath,
|
|
710
|
+
spawner,
|
|
711
|
+
startSettleMs: 0,
|
|
712
|
+
// intentionally omit alive — defaultAlive against a fake pid
|
|
713
|
+
// would normally report dead, but startSettleMs: 0 skips the
|
|
714
|
+
// call entirely.
|
|
715
|
+
log: () => {},
|
|
716
|
+
});
|
|
717
|
+
expect(code).toBe(0);
|
|
718
|
+
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
719
|
+
} finally {
|
|
720
|
+
h.cleanup();
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
632
724
|
test("third-party with no startCmd in module.json reports lifecycle-unsupported", async () => {
|
|
633
725
|
const h = makeHarness();
|
|
634
726
|
try {
|
|
@@ -802,7 +894,10 @@ describe("parachute restart", () => {
|
|
|
802
894
|
manifestPath: h.manifestPath,
|
|
803
895
|
spawner,
|
|
804
896
|
kill: (pid, sig) => killed.push([pid, sig]),
|
|
805
|
-
|
|
897
|
+
// Stale 4242 is dead (stop's stale-pid path skips the kill);
|
|
898
|
+
// freshly spawned 7777 is alive past the post-spawn settle
|
|
899
|
+
// (hub#194). Per-pid differentiation rather than blanket-false.
|
|
900
|
+
alive: (pid) => pid === 7777,
|
|
806
901
|
sleep: async () => {},
|
|
807
902
|
log: () => {},
|
|
808
903
|
});
|
|
@@ -130,6 +130,19 @@ describe("validateModuleManifest", () => {
|
|
|
130
130
|
const m = validateModuleManifest(VALID, "x");
|
|
131
131
|
expect(m.managementUrl).toBeUndefined();
|
|
132
132
|
});
|
|
133
|
+
|
|
134
|
+
test("stripPrefix accepts boolean true and false; rejects non-boolean", () => {
|
|
135
|
+
expect(validateModuleManifest({ ...VALID, stripPrefix: true }, "x").stripPrefix).toBe(true);
|
|
136
|
+
expect(validateModuleManifest({ ...VALID, stripPrefix: false }, "x").stripPrefix).toBe(false);
|
|
137
|
+
expect(() => validateModuleManifest({ ...VALID, stripPrefix: "yes" }, "x")).toThrow(
|
|
138
|
+
/stripPrefix/,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("stripPrefix absent stays absent", () => {
|
|
143
|
+
const m = validateModuleManifest(VALID, "x");
|
|
144
|
+
expect(m.stripPrefix).toBeUndefined();
|
|
145
|
+
});
|
|
133
146
|
});
|
|
134
147
|
|
|
135
148
|
describe("readModuleManifest", () => {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
normalizeMount,
|
|
7
|
+
notesDistCandidates,
|
|
8
|
+
notesFetch,
|
|
9
|
+
resolveNotesDistFrom,
|
|
10
|
+
} from "../notes-serve.ts";
|
|
6
11
|
|
|
7
12
|
interface Harness {
|
|
8
13
|
dir: string;
|
|
@@ -133,3 +138,150 @@ describe("notesFetch with empty mount (root deployment)", () => {
|
|
|
133
138
|
}
|
|
134
139
|
});
|
|
135
140
|
});
|
|
141
|
+
|
|
142
|
+
describe("notesDistCandidates", () => {
|
|
143
|
+
test("returns cwd, then global node_modules, then global root", () => {
|
|
144
|
+
const cands = notesDistCandidates("/some/cwd", "/home/user");
|
|
145
|
+
expect(cands).toEqual([
|
|
146
|
+
"/some/cwd",
|
|
147
|
+
"/home/user/.bun/install/global/node_modules",
|
|
148
|
+
"/home/user/.bun/install/global",
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `resolveNotesDistFrom` is the hub#194 fix — when the cwd-relative resolve
|
|
155
|
+
* fails (hub repo dir doesn't depend on @openparachute/notes), we walk down
|
|
156
|
+
* to bun's global install dirs before giving up. Tests use a stub
|
|
157
|
+
* `resolveSync` so we can drive the candidate order without writing real
|
|
158
|
+
* fixtures into `~/.bun/install/global`.
|
|
159
|
+
*/
|
|
160
|
+
describe("resolveNotesDistFrom (hub#194)", () => {
|
|
161
|
+
function makeFixture(): { home: string; cleanup: () => void; pkgRoot: string; dist: string } {
|
|
162
|
+
// realpathSync — on macOS `mkdtempSync` returns a /var/folders path
|
|
163
|
+
// that resolves to /private/var/folders; we want the resolved form so
|
|
164
|
+
// string comparisons against `Bun.resolveSync` output line up.
|
|
165
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "pcli-notes-resolve-")));
|
|
166
|
+
const home = join(root, "home");
|
|
167
|
+
const pkgRoot = join(home, ".bun/install/global/node_modules/@openparachute/notes");
|
|
168
|
+
mkdirSync(pkgRoot, { recursive: true });
|
|
169
|
+
const dist = join(pkgRoot, "dist");
|
|
170
|
+
mkdirSync(dist, { recursive: true });
|
|
171
|
+
writeFileSync(join(pkgRoot, "package.json"), '{"name":"@openparachute/notes"}');
|
|
172
|
+
return { home, pkgRoot, dist, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
test("first-candidate (cwd) hit returns its dist immediately", () => {
|
|
176
|
+
const f = makeFixture();
|
|
177
|
+
try {
|
|
178
|
+
const calls: string[] = [];
|
|
179
|
+
const out = resolveNotesDistFrom({
|
|
180
|
+
cwd: "/cwd-with-notes",
|
|
181
|
+
home: f.home,
|
|
182
|
+
resolveSync: (specifier, base) => {
|
|
183
|
+
calls.push(base);
|
|
184
|
+
if (base === "/cwd-with-notes") {
|
|
185
|
+
return "/cwd-with-notes/node_modules/@openparachute/notes/package.json";
|
|
186
|
+
}
|
|
187
|
+
throw new Error(`unexpected base: ${base}`);
|
|
188
|
+
},
|
|
189
|
+
existsSync: (p) => p === "/cwd-with-notes/node_modules/@openparachute/notes/dist",
|
|
190
|
+
});
|
|
191
|
+
expect(out).toBe("/cwd-with-notes/node_modules/@openparachute/notes/dist");
|
|
192
|
+
// Only the cwd candidate should be probed — we short-circuit on hit.
|
|
193
|
+
expect(calls).toEqual(["/cwd-with-notes"]);
|
|
194
|
+
} finally {
|
|
195
|
+
f.cleanup();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("falls through to global node_modules when cwd resolve fails (hub#194 root cause)", () => {
|
|
200
|
+
// The exact scenario from hub#194: hub repo's cwd has no dependency on
|
|
201
|
+
// notes, so the first candidate throws ResolveMessage. Bun does NOT
|
|
202
|
+
// auto-consult ~/.bun/install/global, so we have to try it explicitly.
|
|
203
|
+
const f = makeFixture();
|
|
204
|
+
try {
|
|
205
|
+
const calls: string[] = [];
|
|
206
|
+
const out = resolveNotesDistFrom({
|
|
207
|
+
cwd: "/hub-repo-cwd-without-notes",
|
|
208
|
+
home: f.home,
|
|
209
|
+
resolveSync: (specifier, base) => {
|
|
210
|
+
calls.push(base);
|
|
211
|
+
if (base === "/hub-repo-cwd-without-notes") {
|
|
212
|
+
throw new Error(`Cannot find module '${specifier}' from '${base}'`);
|
|
213
|
+
}
|
|
214
|
+
// Real Bun.resolveSync against the global node_modules dir
|
|
215
|
+
// resolves into the package's package.json.
|
|
216
|
+
return Bun.resolveSync(specifier, base);
|
|
217
|
+
},
|
|
218
|
+
// Use real existsSync — the fixture has dist/ on disk.
|
|
219
|
+
});
|
|
220
|
+
expect(out).toBe(f.dist);
|
|
221
|
+
// Both candidates probed, in order.
|
|
222
|
+
expect(calls[0]).toBe("/hub-repo-cwd-without-notes");
|
|
223
|
+
expect(calls[1]).toBe(join(f.home, ".bun/install/global/node_modules"));
|
|
224
|
+
} finally {
|
|
225
|
+
f.cleanup();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("falls through past global node_modules to the older global root layout", () => {
|
|
230
|
+
// Defensive: older Bun versions used a flatter global layout. We probe
|
|
231
|
+
// both. This test forces the first two candidates to fail and pins
|
|
232
|
+
// that the third is reached.
|
|
233
|
+
const probed: string[] = [];
|
|
234
|
+
expect(() =>
|
|
235
|
+
resolveNotesDistFrom({
|
|
236
|
+
cwd: "/cwd",
|
|
237
|
+
home: "/h",
|
|
238
|
+
resolveSync: (_specifier, base) => {
|
|
239
|
+
probed.push(base);
|
|
240
|
+
throw new Error(`Cannot find module from '${base}'`);
|
|
241
|
+
},
|
|
242
|
+
}),
|
|
243
|
+
).toThrow(/Could not resolve @openparachute\/notes from any of/);
|
|
244
|
+
expect(probed).toEqual([
|
|
245
|
+
"/cwd",
|
|
246
|
+
"/h/.bun/install/global/node_modules",
|
|
247
|
+
"/h/.bun/install/global",
|
|
248
|
+
]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("error message names every candidate that was tried", () => {
|
|
252
|
+
let caught: unknown;
|
|
253
|
+
try {
|
|
254
|
+
resolveNotesDistFrom({
|
|
255
|
+
cwd: "/probe-cwd",
|
|
256
|
+
home: "/probe-home",
|
|
257
|
+
resolveSync: (_specifier, base) => {
|
|
258
|
+
throw new Error(`Cannot find module from '${base}'`);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
} catch (err) {
|
|
262
|
+
caught = err;
|
|
263
|
+
}
|
|
264
|
+
expect(caught).toBeInstanceOf(Error);
|
|
265
|
+
const msg = (caught as Error).message;
|
|
266
|
+
expect(msg).toContain("/probe-cwd");
|
|
267
|
+
expect(msg).toContain("/probe-home/.bun/install/global/node_modules");
|
|
268
|
+
expect(msg).toContain("/probe-home/.bun/install/global");
|
|
269
|
+
// Hint operators at the actionable next step.
|
|
270
|
+
expect(msg).toMatch(/bun add -g @openparachute\/notes|parachute install notes/);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("resolved package without dist/ throws a hard error (no fallthrough)", () => {
|
|
274
|
+
// If the package resolves but lacks a dist/ directory, that's a
|
|
275
|
+
// packaging issue — falling through to other candidates would just
|
|
276
|
+
// re-resolve the same package. Surface the problem with the resolved
|
|
277
|
+
// path so the operator can file the right issue against the package.
|
|
278
|
+
expect(() =>
|
|
279
|
+
resolveNotesDistFrom({
|
|
280
|
+
cwd: "/cwd-with-notes",
|
|
281
|
+
home: "/h",
|
|
282
|
+
resolveSync: () => "/cwd-with-notes/node_modules/@openparachute/notes/package.json",
|
|
283
|
+
existsSync: () => false,
|
|
284
|
+
}),
|
|
285
|
+
).toThrow(/has no dist\/ directory/);
|
|
286
|
+
});
|
|
287
|
+
});
|