jeo-code 0.12.21 → 0.12.22
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/CHANGELOG.md +13 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +2 -1
- package/src/agent/sandbox/hands-source.text +1 -1
- package/src/cli/runner.ts +1 -1
- package/src/commands/doctor.ts +106 -1
- package/src/commands/update.ts +284 -42
- package/src/util/shared-global.ts +380 -0
- package/src/util/shared-install-report.ts +315 -0
package/src/commands/doctor.ts
CHANGED
|
@@ -14,6 +14,9 @@ import { visibleWidth, truncateToWidth } from "../tui/components/width";
|
|
|
14
14
|
import chalk from "chalk";
|
|
15
15
|
import { extractChatgptAccountId, CODEX_RESPONSES_URL } from "../ai/providers/openai-responses";
|
|
16
16
|
import { resolveTierModel, tierModelPool, cheapestCredentialed } from "../agent/prompt-router";
|
|
17
|
+
import { DEFAULT_SIZE_CAP_MS, collectSharedInstallReport, type SharedInstallReport } from "../util/shared-install-report";
|
|
18
|
+
import { formatBytes } from "../tui/components/session-picker";
|
|
19
|
+
import { compareVersions } from "./update";
|
|
17
20
|
|
|
18
21
|
interface ProbeResult {
|
|
19
22
|
status: "ok" | "fail" | "skipped";
|
|
@@ -219,7 +222,93 @@ function providerTableLines(
|
|
|
219
222
|
return renderMarkdownTables(md, { unicode, maxColWidth: detailBudget }).split("\n");
|
|
220
223
|
}
|
|
221
224
|
|
|
222
|
-
export
|
|
225
|
+
export interface DoctorDeps {
|
|
226
|
+
/** Produces the shared bun global report; tests inject it, the default scans the real machine (memoized). */
|
|
227
|
+
sharedInstall?: () => SharedInstallReport | Promise<SharedInstallReport>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const SHARED_INSTALL_TTL_MS = 30_000;
|
|
231
|
+
let sharedInstallMemo: { key: string; at: number; report: SharedInstallReport } | undefined;
|
|
232
|
+
|
|
233
|
+
/** The real scan, memoized for 30 s per environment so repeated doctor runs in one process walk the global dir once. */
|
|
234
|
+
function defaultSharedInstall(): SharedInstallReport {
|
|
235
|
+
const key = [process.env.HOME, process.env.BUN_INSTALL, process.env.BUN_INSTALL_GLOBAL_DIR, process.env.XDG_CONFIG_HOME, process.env.PATH, process.platform].join("|");
|
|
236
|
+
if (sharedInstallMemo?.key === key && Date.now() - sharedInstallMemo.at < SHARED_INSTALL_TTL_MS) return sharedInstallMemo.report;
|
|
237
|
+
const report = collectSharedInstallReport();
|
|
238
|
+
sharedInstallMemo = { key, at: Date.now(), report };
|
|
239
|
+
return report;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ORPHAN_KIND_NOTE: Record<"directory" | "symlink" | "dangling-symlink", string> = {
|
|
243
|
+
directory: "possible orphan",
|
|
244
|
+
symlink: "possible orphan, symlink",
|
|
245
|
+
"dangling-symlink": "possible orphan, dangling symlink",
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** Human text for the shared install section. Warnings are yellow, an ok lockfile green; paths are never truncated. */
|
|
249
|
+
function sharedInstallLines(report: SharedInstallReport): string[] {
|
|
250
|
+
const lines: string[] = [];
|
|
251
|
+
const { manifest, lockfile, leftovers, sizes } = report;
|
|
252
|
+
if (report.skipped !== null) {
|
|
253
|
+
lines.push(`Shared install: no usable bun global (${report.skipped}); lockfile, leftover and size checks skipped.`);
|
|
254
|
+
} else {
|
|
255
|
+
lines.push(`Shared install (${report.globalDir ?? "unknown"}):`);
|
|
256
|
+
}
|
|
257
|
+
if (manifest !== null) lines.push(` packages: ${manifest.dependencies} in manifest (${manifest.external} besides jeo-code)`);
|
|
258
|
+
if (lockfile !== null) {
|
|
259
|
+
if (lockfile.status === "ok") {
|
|
260
|
+
const total = manifest?.external ?? lockfile.uncovered.length;
|
|
261
|
+
lines.push(chalk.green(` bun.lock: ok (${total - lockfile.uncovered.length} of ${total} covered)`));
|
|
262
|
+
} else if (lockfile.status === "not-required") {
|
|
263
|
+
lines.push(" bun.lock: not required (jeo-code is the only package)");
|
|
264
|
+
} else if (lockfile.status === "missing") {
|
|
265
|
+
lines.push(chalk.yellow(" bun.lock: MISSING - jeo update will refuse until it is restored (or pass --allow-unlocked-global)"));
|
|
266
|
+
} else if (lockfile.status === "incomplete") {
|
|
267
|
+
lines.push(chalk.yellow(` bun.lock: INCOMPLETE - no resolved entry for: ${lockfile.uncovered.join(", ")}`));
|
|
268
|
+
} else {
|
|
269
|
+
lines.push(chalk.yellow(` bun.lock: UNREADABLE${lockfile.detail ? ` (${lockfile.detail})` : ""}`));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (report.jeoOnPath.length === 0) {
|
|
273
|
+
lines.push(" jeo on PATH: none found");
|
|
274
|
+
} else {
|
|
275
|
+
const activeVersion = report.jeoOnPath.find(entry => entry.active)?.version ?? null;
|
|
276
|
+
lines.push(" jeo on PATH (first = active):");
|
|
277
|
+
for (const entry of report.jeoOnPath) {
|
|
278
|
+
const marker = entry.active ? "*" : " ";
|
|
279
|
+
const text = ` ${marker} ${entry.path} ${entry.version ?? "(version unknown)"}${entry.active ? " (active)" : ""}`;
|
|
280
|
+
if (entry.stale && entry.version !== null && activeVersion !== null) {
|
|
281
|
+
const direction = compareVersions(entry.version, activeVersion) < 0 ? "older" : "newer";
|
|
282
|
+
lines.push(chalk.yellow(`${text} [stale: ${direction} than active ${activeVersion}]`));
|
|
283
|
+
} else {
|
|
284
|
+
lines.push(text);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (leftovers !== null) {
|
|
289
|
+
if (leftovers.backupFiles.length === 0 && leftovers.possibleOrphans.length === 0 && leftovers.orphanScanComplete) {
|
|
290
|
+
lines.push(" leftovers: none");
|
|
291
|
+
} else {
|
|
292
|
+
lines.push(" leftovers (not touched):");
|
|
293
|
+
for (const file of leftovers.backupFiles) lines.push(chalk.yellow(` file ${file}`));
|
|
294
|
+
for (const orphan of leftovers.possibleOrphans) lines.push(chalk.yellow(` orphan ${orphan.name} (${ORPHAN_KIND_NOTE[orphan.kind]})`));
|
|
295
|
+
if (!leftovers.orphanScanComplete) lines.push(chalk.yellow(" orphan scan stopped at its cap; no orphans reported"));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (sizes !== null) {
|
|
299
|
+
const top = `top ${sizes.top.length}: ${sizes.top.map(p => `${p.name} ${formatBytes(p.bytes)}`).join(", ")}`;
|
|
300
|
+
if (sizes.capped === null) {
|
|
301
|
+
lines.push(` size: ${formatBytes(sizes.totalBytes)} total${sizes.top.length > 0 ? `; ${top}` : ""}`);
|
|
302
|
+
} else {
|
|
303
|
+
const cap = sizes.capped === "time" ? `time cap (${DEFAULT_SIZE_CAP_MS} ms)` : `entry cap (${sizes.scannedEntries} entries)`;
|
|
304
|
+
lines.push(chalk.yellow(` size scan stopped at the ${cap}; figures are lower bounds`));
|
|
305
|
+
lines.push(` at least ${formatBytes(sizes.totalBytes)} scanned${sizes.top.length > 0 ? `; ${top}` : ""}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return lines;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function runDoctorCommand(args: string[] = [], deps: DoctorDeps = {}): Promise<void> {
|
|
223
312
|
const strict = args.includes("--strict");
|
|
224
313
|
const json = args.includes("--json");
|
|
225
314
|
const config = await readGlobalConfig();
|
|
@@ -379,6 +468,14 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
379
468
|
? defaultProbe?.result.status === "skipped"
|
|
380
469
|
: defaultProbe?.result.status === "ok";
|
|
381
470
|
|
|
471
|
+
// Shared bun global diagnostics: read-only, never affects `ready` or the exit code.
|
|
472
|
+
let sharedInstall: SharedInstallReport | { error: string };
|
|
473
|
+
try {
|
|
474
|
+
sharedInstall = await (deps.sharedInstall ?? defaultSharedInstall)();
|
|
475
|
+
} catch (err) {
|
|
476
|
+
sharedInstall = { error: err instanceof Error ? err.message : String(err) };
|
|
477
|
+
}
|
|
478
|
+
|
|
382
479
|
// --- JSON output mode (CI / scripting) ---
|
|
383
480
|
if (json) {
|
|
384
481
|
const report = {
|
|
@@ -402,6 +499,7 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
402
499
|
oauth: oauthHealth,
|
|
403
500
|
ready,
|
|
404
501
|
...(routingEnabled ? { routing: { enabled: true, smolConfigured, preview: routingPreview, ...(routingNotes.length ? { notes: routingNotes } : {}) } } : {}),
|
|
502
|
+
sharedInstall,
|
|
405
503
|
|
|
406
504
|
};
|
|
407
505
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -490,6 +588,13 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
490
588
|
console.log("");
|
|
491
589
|
}
|
|
492
590
|
|
|
591
|
+
if ("error" in sharedInstall) {
|
|
592
|
+
console.log(chalk.yellow(`Shared install: check failed (${sharedInstall.error})`));
|
|
593
|
+
} else {
|
|
594
|
+
for (const line of sharedInstallLines(sharedInstall)) console.log(line);
|
|
595
|
+
}
|
|
596
|
+
console.log("");
|
|
597
|
+
|
|
493
598
|
// Final verdict
|
|
494
599
|
if (ready) {
|
|
495
600
|
console.log(`${chalk.green("[READY]")} Default model '${config.defaultModel}' is reachable.`);
|
package/src/commands/update.ts
CHANGED
|
@@ -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<
|
|
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
|
|
62
|
-
* npm
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
396
|
-
|
|
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
|
}
|