jeo-code 0.12.21 → 0.12.23

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.
@@ -1,6 +1,22 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import pkg from "../../package.json";
3
3
  import { messengerProfilesPath } from "../messenger/runtime";
4
+ import {
5
+ checkLockCoverage,
6
+ detectInstallOrigin,
7
+ diffInstalled,
8
+ formatChangeLine,
9
+ formatRestoreCommands,
10
+ formatUnchangedSummary,
11
+ locateSharedGlobal,
12
+ readManifestDeps,
13
+ snapshotInstalled,
14
+ type ExternalChange,
15
+ type InstalledSnapshot,
16
+ type InstallOrigin,
17
+ type LocatedGlobal,
18
+ type LockStatus,
19
+ } from "../util/shared-global";
4
20
 
5
21
  /**
6
22
  * Compares two semver-ish version strings.
@@ -34,10 +50,18 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
34
50
  return 0;
35
51
  }
36
52
 
53
+ /** Outcome of one self-install. `manual` carries the commands to run by hand when no automatic attempt worked. */
54
+ export interface InstallResult {
55
+ success: boolean;
56
+ stdout?: string;
57
+ stderr?: string;
58
+ manual?: string[];
59
+ }
60
+
37
61
  export interface UpdateDeps {
38
62
  fetchJson: (url: string, options?: { signal?: AbortSignal }) => Promise<any>;
39
63
  localVersion: () => string;
40
- install: (version?: string) => Promise<{ success: boolean; stdout?: string; stderr?: string }>;
64
+ install: (version?: string) => Promise<InstallResult>;
41
65
  /** Display release notes after a successful self-update (best-effort, no-op in tests). */
42
66
  showWhatsNew?: () => void;
43
67
  /**
@@ -50,6 +74,11 @@ export interface UpdateDeps {
50
74
  /** Version of the `jeo` actually on PATH, read after install to catch a silent no-op
51
75
  * (installed but PATH still points at a different/older binary). Optional in tests. */
52
76
  activeVersion?: () => string | null;
77
+ /**
78
+ * Where the shared bun global lives, so an update can refuse before any installer could re-resolve packages
79
+ * jeo does not own and can report any that moved. Optional: absent = no inspection (every existing test).
80
+ */
81
+ sharedGlobal?: { locate: () => LocatedGlobal };
53
82
  }
54
83
 
55
84
  /**
@@ -58,16 +87,72 @@ export interface UpdateDeps {
58
87
  * `bun install -g` alone is not enough in practice: right after a publish bun can
59
88
  * still hold a stale registry manifest and fail with `No version matching "<v>"
60
89
  * found ... (but package exists)`. `--force` re-resolves against the registry and
61
- * is what actually recovers that state. npm remains the last resort for
62
- * npm-installed globals. Pure so the policy is unit-testable without spawning.
90
+ * is what actually recovers that state. npm is the last resort only for an active jeo that
91
+ * lives under npm's global root (`origin` 'npm-global'): from a bun-global or unknown location
92
+ * an npm install would create a stale second copy, so it is never attempted there. `origin`
93
+ * undefined keeps the legacy three attempts. Pure so the policy is unit-testable without spawning.
63
94
  */
64
- export function installCandidates(target: string): string[][] {
65
- return [
95
+ export function installCandidates(target: string, origin?: InstallOrigin): string[][] {
96
+ const candidates = [
66
97
  ["bun", "install", "-g", target],
67
98
  // Cache-buster: bun's resolver held a stale manifest for a just-published version.
68
99
  ["bun", "install", "-g", target, "--force"],
69
- ["npm", "install", "-g", target],
70
100
  ];
101
+ if (origin === undefined || origin === "npm-global") candidates.push(["npm", "install", "-g", target]);
102
+ return candidates;
103
+ }
104
+
105
+ /** What to tell the user to run when every automatic attempt failed and npm was deliberately not tried. */
106
+ export function manualInstallLines(target: string, origin: InstallOrigin): string[] {
107
+ if (origin === "npm-global") return [];
108
+ return [
109
+ `bun install -g ${target} --force`,
110
+ origin === "bun-global"
111
+ ? "(npm was not tried: the active jeo is under the bun global install)"
112
+ : `(npm was not tried: the active jeo is not under the npm global root; if it came from npm run: npm install -g ${target})`,
113
+ ];
114
+ }
115
+
116
+ /** Side effects `runInstall` needs, injected so the attempt policy is testable without spawning. */
117
+ export interface InstallIo {
118
+ spawn: (cmd: string[]) => { success: boolean; exitCode?: number | null };
119
+ activeVersion: () => string | null;
120
+ origin: () => InstallOrigin;
121
+ }
122
+
123
+ function errorText(err: unknown): string {
124
+ const message = err !== null && typeof err === "object" && "message" in err ? err.message : undefined;
125
+ return message ? String(message) : String(err);
126
+ }
127
+
128
+ /**
129
+ * Self-update the global install via the toolchain it was installed with. jeo ships as a
130
+ * `#!/usr/bin/env bun` script so bun is the common case; npm is tried last, and only when the
131
+ * active jeo lives under npm's global root (see installCandidates). The origin is resolved once,
132
+ * before the first spawn.
133
+ */
134
+ export function runInstall(version: string | undefined, io: InstallIo): InstallResult {
135
+ const target = `jeo-code@${version ?? "latest"}`;
136
+ const origin = io.origin();
137
+ let lastErr = "";
138
+ for (const cmd of installCandidates(target, origin)) {
139
+ try {
140
+ const proc = io.spawn(cmd);
141
+ if (proc.success) {
142
+ // A zero exit is NOT proof the user's `jeo` moved: npm's global prefix is
143
+ // often a different directory than the bun prefix PATH actually resolves,
144
+ // so an npm fallback can "succeed" while the active binary is untouched.
145
+ // Only stop once the binary on PATH really reports the requested version.
146
+ if (!version || io.activeVersion() === version) return { success: true };
147
+ lastErr = `${cmd[0]} reported success but the active 'jeo' is still ${io.activeVersion() ?? "unknown"}`;
148
+ continue;
149
+ }
150
+ lastErr = `${cmd[0]} exited with code ${proc.exitCode}`;
151
+ } catch (err) {
152
+ lastErr = `${cmd[0]} unavailable: ${errorText(err)}`;
153
+ }
154
+ }
155
+ return { success: false, stderr: lastErr, manual: manualInstallLines(target, origin) };
71
156
  }
72
157
 
73
158
  /** Version of the `jeo` binary PATH actually resolves, or null when unreadable. */
@@ -100,32 +185,13 @@ export const defaultDeps: UpdateDeps = {
100
185
  localVersion: () => {
101
186
  return pkg.version;
102
187
  },
103
- install: async (version?: string) => {
104
- // Self-update the global install via the SAME toolchain it was installed with. jeo
105
- // ships as a `#!/usr/bin/env bun` script so bun is the common case, but npm-installed
106
- // globals exist too — try bun first, fall back to npm (and tolerate either missing).
107
- const target = `jeo-code@${version ?? "latest"}`;
108
- let lastErr = "";
109
- for (const cmd of installCandidates(target)) {
110
- try {
111
- const proc = Bun.spawnSync(cmd, { stdout: "inherit", stderr: "inherit" });
112
- if (proc.success) {
113
- // A zero exit is NOT proof the user's `jeo` moved: npm's global prefix is
114
- // often a different directory than the bun prefix PATH actually resolves,
115
- // so an npm fallback can "succeed" while the active binary is untouched.
116
- // Only stop once the binary on PATH really reports the requested version.
117
- if (!version || readActiveVersion() === version) return { success: true };
118
- lastErr = `${cmd[0]} reported success but the active 'jeo' is still ${readActiveVersion() ?? "unknown"}`;
119
- continue;
120
- }
121
- lastErr = `${cmd[0]} exited with code ${proc.exitCode}`;
122
- } catch (err: any) {
123
- lastErr = `${cmd[0]} unavailable: ${err?.message ?? String(err)}`;
124
- }
125
- }
126
- return { success: false, stderr: lastErr };
127
- }
128
- ,
188
+ sharedGlobal: { locate: locateSharedGlobal },
189
+ install: async (version?: string) =>
190
+ runInstall(version, {
191
+ spawn: cmd => Bun.spawnSync(cmd, { stdout: "inherit", stderr: "inherit" }),
192
+ activeVersion: readActiveVersion,
193
+ origin: () => detectInstallOrigin().origin,
194
+ }),
129
195
  // Version of the `jeo` actually on PATH — may differ from this process's bundled
130
196
  // version after a self-update (e.g. npm installed into a prefix PATH does not
131
197
  // resolve). Detects the silent "installed but PATH unchanged" no-op.
@@ -159,14 +225,143 @@ export async function runUpdateCommand(args: string[] = []): Promise<void> {
159
225
  return runUpdateCommandWith(args, defaultDeps);
160
226
  }
161
227
 
228
+ /** What the guard found in the shared bun global before the installer ran (the `preflight` JSON key). */
229
+ interface SharedPreflight {
230
+ globalDir: string;
231
+ externalCount: number;
232
+ lockfile: LockStatus;
233
+ uncovered: string[];
234
+ allowedUnlocked: boolean;
235
+ }
236
+
237
+ interface SharedArmed {
238
+ kind: "armed";
239
+ dir: string;
240
+ /** Manifest dependency names other than jeo-code, as they were before the install. */
241
+ beforeNames: string[];
242
+ /** Installed versions captured BEFORE the install; settle never re-reads them from disk. */
243
+ before: InstalledSnapshot;
244
+ /** Null when the manifest has no packages besides jeo-code (nothing to guard, nothing to report). */
245
+ preflight: SharedPreflight | null;
246
+ }
247
+
248
+ type SharedArm = { kind: "off" } | SharedArmed;
249
+ type SharedBegin = SharedArm | { kind: "refused"; preflight: SharedPreflight; error: string; lines: string[] };
250
+
251
+ interface SharedSettled {
252
+ dir: string;
253
+ changes: ExternalChange[];
254
+ /** `shared global unchanged: ...`, only when a shared global with external packages was inspected and nothing moved. */
255
+ summary: string | null;
256
+ preflight: SharedPreflight | null;
257
+ driftError: string | null;
258
+ }
259
+
260
+ function skipShared(hasJson: boolean, reason: string): SharedBegin {
261
+ if (!hasJson) console.log(`Shared bun global not inspected (${reason}); the isolation check is skipped.`);
262
+ return { kind: "off" };
263
+ }
264
+
265
+ /**
266
+ * Runs at the start of the install branch, before any installer. Refuses when the shared bun global lists
267
+ * packages besides jeo-code and its bun.lock cannot pin them (bun would re-resolve and could move them);
268
+ * otherwise captures the installed versions that settleShared compares against after the install.
269
+ */
270
+ function beginShared(deps: UpdateDeps, opts: { hasJson: boolean; allowUnlocked: boolean }): SharedBegin {
271
+ if (!deps.sharedGlobal) return { kind: "off" };
272
+ const located = deps.sharedGlobal.locate();
273
+ if (!located.ok) return skipShared(opts.hasJson, located.reason);
274
+ const manifest = readManifestDeps(located.dir);
275
+ if (!manifest.ok) return skipShared(opts.hasJson, "manifest-unreadable");
276
+ const dir = located.dir;
277
+ const external = manifest.deps.external;
278
+ const before = snapshotInstalled(dir, external);
279
+ if (external.length === 0) return { kind: "armed", dir, beforeNames: external, before, preflight: null };
280
+ const lock = checkLockCoverage(dir, external);
281
+ const unlocked = lock.status !== "ok";
282
+ const preflight: SharedPreflight = {
283
+ globalDir: dir,
284
+ externalCount: external.length,
285
+ lockfile: lock.status,
286
+ uncovered: lock.uncovered,
287
+ allowedUnlocked: unlocked && opts.allowUnlocked,
288
+ };
289
+ if (!unlocked) return { kind: "armed", dir, beforeNames: external, before, preflight };
290
+ const names = lock.uncovered.join(", ");
291
+ if (!opts.allowUnlocked) {
292
+ const problem =
293
+ lock.status === "missing"
294
+ ? "is missing"
295
+ : lock.status === "incomplete"
296
+ ? `has no resolved entry for ${names}`
297
+ : `could not be read${lock.detail ? ` (${lock.detail})` : ""}`;
298
+ return {
299
+ kind: "refused",
300
+ preflight,
301
+ error: `shared bun global lockfile ${lock.status} (${names}); refusing to run any installer, re-run with --allow-unlocked-global to override`,
302
+ lines: [
303
+ `[REFUSED] jeo update did not run: the shared bun global at ${dir} has packages besides jeo-code but its bun.lock ${problem}.`,
304
+ ` bun would re-resolve every package in that directory and could move: ${names}`,
305
+ " Nothing was installed or changed.",
306
+ ` Restore ${lock.path} (for example from a backup), or accept the re-resolution with: jeo update --allow-unlocked-global`,
307
+ ...(lock.legacyBinaryLock ? [" (a binary bun.lockb is not accepted)"] : []),
308
+ ],
309
+ };
310
+ }
311
+ if (!opts.hasJson) console.warn(`Warning: bun.lock ${lock.status} for: ${names}; --allow-unlocked-global lets bun re-resolve them, drift will be reported.`);
312
+ return { kind: "armed", dir, beforeNames: external, before, preflight };
313
+ }
314
+
315
+ /**
316
+ * Runs exactly once, right after the installer returned or threw. The before map is the one captured by
317
+ * beginShared (re-reading it here would see post-install versions and could never show drift); only the
318
+ * after map is read from disk.
319
+ */
320
+ function settleShared(arm: SharedArm): SharedSettled {
321
+ if (arm.kind === "off") return { dir: "", changes: [], summary: null, preflight: null, driftError: null };
322
+ const manifest = readManifestDeps(arm.dir);
323
+ const afterNames = manifest.ok ? manifest.deps.external : arm.beforeNames;
324
+ const names = [...new Set([...arm.beforeNames, ...afterNames])];
325
+ const before: InstalledSnapshot = Object.fromEntries(names.map(name => [name, Object.hasOwn(arm.before, name) ? arm.before[name] : null]));
326
+ const after = snapshotInstalled(arm.dir, names, new Set(afterNames));
327
+ const changes = diffInstalled(before, after);
328
+ return {
329
+ dir: arm.dir,
330
+ changes,
331
+ summary: changes.length === 0 && arm.preflight !== null ? formatUnchangedSummary(after) : null,
332
+ preflight: arm.preflight,
333
+ driftError: changes.length > 0 ? `shared bun global changed: ${changes.map(formatChangeLine).join("; ")}` : null,
334
+ };
335
+ }
336
+
337
+ /** `externalChanges` and `preflight` JSON keys, present only when a shared global with external packages was inspected. */
338
+ function sharedJson(s: SharedSettled): { externalChanges?: ExternalChange[]; preflight?: SharedPreflight } {
339
+ if (s.preflight === null && s.changes.length === 0) return {};
340
+ return { externalChanges: s.changes, ...(s.preflight === null ? {} : { preflight: s.preflight }) };
341
+ }
342
+
343
+ /** The human drift report: what moved and the commands that would put it back. They are printed, never run. */
344
+ function printSharedDrift(s: SharedSettled, version: string | null, installed: boolean): void {
345
+ console.error(
346
+ installed
347
+ ? `[FAILED] jeo-code@${version} was installed, but the shared bun global at ${s.dir} changed other packages:`
348
+ : `[FAILED] the install of jeo-code@${version} did not finish cleanly, and the shared bun global at ${s.dir} changed other packages:`,
349
+ );
350
+ for (const change of s.changes) console.error(` ${formatChangeLine(change)}`);
351
+ console.error("jeo does not repair these. To put them back run:");
352
+ for (const command of formatRestoreCommands(s.changes)) console.error(` ${command}`);
353
+ if (installed) console.error("Messenger runtimes were not refreshed; run 'jeo messenger update' after restoring.");
354
+ }
355
+
162
356
  export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Promise<void> {
163
357
  const isHelp = args.includes("--help") || args.includes("-h");
164
358
  const hasInstall = args.includes("--install");
165
359
  const hasCheck = args.includes("--check");
166
360
  const hasJson = args.includes("--json");
167
361
  const hasStrict = args.includes("--strict");
362
+ const allowUnlocked = args.includes("--allow-unlocked-global");
168
363
 
169
- const KNOWN_FLAGS = new Set(["--check", "--install", "--json", "--strict", "--version", "-h", "--help"]);
364
+ const KNOWN_FLAGS = new Set(["--check", "--install", "--json", "--strict", "--allow-unlocked-global", "--version", "-h", "--help"]);
170
365
  // A semver-ish shape: MAJOR.MINOR.PATCH with optional -prerelease and/or +build metadata,
171
366
  // matching what the npm registry actually publishes (and what activeVersion() parses back out).
172
367
  const SEMVER_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
@@ -317,13 +512,35 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
317
512
  : `jeo-code is already up-to-date (${current}).`);
318
513
  }
319
514
  } else {
515
+ const shared = beginShared(deps, { hasJson, allowUnlocked });
516
+ if (shared.kind === "refused") {
517
+ if (hasJson) {
518
+ console.log(JSON.stringify({
519
+ current,
520
+ latest,
521
+ upToDate: false,
522
+ installed: false,
523
+ error: shared.error,
524
+ preflight: shared.preflight
525
+ }));
526
+ } else {
527
+ for (const line of shared.lines) console.error(line);
528
+ }
529
+ process.exitCode = 1;
530
+ return;
531
+ }
320
532
  if (!hasJson) {
321
533
  console.log(pinnedVersion
322
534
  ? `Installing pinned version: ${current} -> ${latest}...`
323
535
  : `Installing update: ${current} -> ${latest}...`);
324
536
  }
537
+ // Settled exactly once: right after the installer returns, or in the catch when it threw
538
+ // (a throwing installer may have half-run). Messenger refresh never happens before this.
539
+ let settled: SharedSettled | null = null;
325
540
  try {
326
541
  const result = await deps.install(latest ?? undefined);
542
+ settled = settleShared(shared);
543
+ const drift = settled.changes.length > 0;
327
544
  if (result.success) {
328
545
  // A package manager's zero exit does not prove the user's `jeo` moved: npm's
329
546
  // global prefix is frequently a different directory than the one PATH resolves,
@@ -333,25 +550,33 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
333
550
  const active = deps.activeVersion?.();
334
551
  const stale = !!(active && latest && compareVersions(active, latest) < 0);
335
552
  if (hasJson) {
336
- const messengersRefreshed = stale ? null : (deps.refreshMessengers?.(true) ?? null);
553
+ const messengersRefreshed = stale || drift ? null : (deps.refreshMessengers?.(true) ?? null);
337
554
  console.log(JSON.stringify({
338
555
  current,
339
556
  latest,
340
557
  upToDate: false,
341
558
  installed: !stale,
342
559
  ...(messengersRefreshed === null ? {} : { messengersRefreshed }),
343
- ...(stale ? { activeVersion: active, error: "active jeo still reports an older version" } : {}),
560
+ ...(stale
561
+ ? { activeVersion: active, error: "active jeo still reports an older version" }
562
+ : drift ? { error: settled.driftError } : {}),
563
+ ...sharedJson(settled),
344
564
  }));
345
- if (stale) process.exitCode = 1;
565
+ if (stale || drift) process.exitCode = 1;
346
566
  } else if (stale) {
347
567
  console.error(`[FAILED] Installed jeo-code@${latest}, but the active 'jeo' on PATH still reports ${active}.`);
348
568
  console.error(`The install landed in a prefix your PATH does not use. Fix it with one of:`);
349
569
  console.error(` bun install -g jeo-code@${latest} --force`);
350
570
  console.error(` npm install -g jeo-code@${latest}`);
351
571
  console.error(`Then confirm with: jeo --version`);
572
+ if (drift) printSharedDrift(settled, latest, true);
573
+ process.exitCode = 1;
574
+ } else if (drift) {
575
+ printSharedDrift(settled, latest, true);
352
576
  process.exitCode = 1;
353
577
  } else {
354
578
  console.log(`Successfully installed jeo-code@${latest}`);
579
+ if (settled.summary) console.log(settled.summary);
355
580
  deps.refreshMessengers?.(false);
356
581
  deps.showWhatsNew?.();
357
582
  }
@@ -363,17 +588,24 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
363
588
  const recovered = deps.activeVersion?.();
364
589
  if (recovered && latest && compareVersions(recovered, latest) >= 0) {
365
590
  if (hasJson) {
366
- const messengersRefreshed = deps.refreshMessengers?.(true) ?? null;
591
+ const messengersRefreshed = drift ? null : (deps.refreshMessengers?.(true) ?? null);
367
592
  console.log(JSON.stringify({
368
593
  current,
369
594
  latest,
370
595
  upToDate: false,
371
596
  installed: true,
372
597
  recovered: true,
373
- ...(messengersRefreshed === null ? {} : { messengersRefreshed })
598
+ ...(messengersRefreshed === null ? {} : { messengersRefreshed }),
599
+ ...(drift ? { error: settled.driftError } : {}),
600
+ ...sharedJson(settled),
374
601
  }));
602
+ if (drift) process.exitCode = 1;
603
+ } else if (drift) {
604
+ printSharedDrift(settled, latest, true);
605
+ process.exitCode = 1;
375
606
  } else {
376
607
  console.log(`Successfully installed jeo-code@${latest} (package manager reported an error, but the runtime verified clean).`);
608
+ if (settled.summary) console.log(settled.summary);
377
609
  deps.refreshMessengers?.(false);
378
610
  deps.showWhatsNew?.();
379
611
  }
@@ -384,26 +616,35 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
384
616
  latest,
385
617
  upToDate: false,
386
618
  installed: false,
387
- error: "Installation failed"
619
+ error: "Installation failed",
620
+ ...sharedJson(settled),
388
621
  }));
389
622
  } else {
390
623
  console.error("Failed to install update.");
624
+ if (result.manual && result.manual.length > 0) {
625
+ console.error("Try manually:");
626
+ for (const line of result.manual) console.error(` ${line}`);
627
+ }
628
+ if (drift) printSharedDrift(settled, latest, false);
391
629
  }
392
630
  process.exitCode = 1;
393
631
  }
394
632
  }
395
- } catch (installErr: any) {
396
- const installErrMsg = installErr.message || String(installErr);
633
+ } catch (installErr) {
634
+ settled ??= settleShared(shared);
635
+ const installErrMsg = errorText(installErr);
397
636
  if (hasJson) {
398
637
  console.log(JSON.stringify({
399
638
  current,
400
639
  latest,
401
640
  upToDate: false,
402
641
  installed: false,
403
- error: `Installation error: ${installErrMsg}`
642
+ error: `Installation error: ${installErrMsg}`,
643
+ ...sharedJson(settled),
404
644
  }));
405
645
  } else {
406
646
  console.error(`Failed to install update: ${installErrMsg}`);
647
+ if (settled.changes.length > 0) printSharedDrift(settled, latest, false);
407
648
  }
408
649
  process.exitCode = 1;
409
650
  }
@@ -454,5 +695,6 @@ function printUsage() {
454
695
  console.log(" (e.g. --version 1.2.3); combine with --install to apply it");
455
696
  console.log(" --json Output result in JSON format");
456
697
  console.log(" --strict Exit with code 1 on network/registry errors");
698
+ console.log(" --allow-unlocked-global Proceed even if the shared bun global bun.lock does not cover its other packages (drift is still reported)");
457
699
  console.log(" -h, --help Show this help message");
458
700
  }