@openparachute/hub 0.5.2 → 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.
@@ -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 CLI-as-port-authority
268
- // the canonical slot wins when free: the manifest is updated and the
269
- // .env carries PORT=<canonical> so the next daemon boot binds it.
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
- // CLI-as-port-authority (#53). Install assigns the service's port up front
1051
- // and writes `PORT=<port>` into `<configDir>/<svc>/.env`. lifecycle.start
1052
- // merges that .env into spawn env, so the next daemon boot binds the port
1053
- // the CLI picked.
1054
- test("install writes PORT=<canonical> to .env when the slot is free", async () => {
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
- const envText = readFileSync(join(configDir, "vault", ".env"), "utf8");
1068
- expect(envText).toContain("PORT=1940");
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 preserves a pre-existing PORT in .env across re-installs", async () => {
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 original = readFileSync(envPath, "utf8");
1090
- const edited = original.replace("PORT=1940", "PORT=1947");
1091
- const { writeFileSync } = await import("node:fs");
1092
- writeFileSync(envPath, edited);
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
- expect(readFileSync(envPath, "utf8")).toContain("PORT=1947");
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: () => false,
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
- alive: () => false,
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
  });
@@ -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 { normalizeMount, notesFetch } from "../notes-serve.ts";
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
+ });