jeo-code 0.12.20 → 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 +22 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +2 -1
- package/src/agent/AGENTS.md +2 -1
- package/src/agent/engine.ts +55 -11
- package/src/agent/loop-guards.ts +101 -21
- package/src/agent/sandbox/hands-source.text +590 -590
- package/src/agent/task-tool.ts +4 -1
- package/src/agent/workspace-snapshot.ts +209 -0
- package/src/cli/runner.ts +1 -1
- package/src/commands/doctor.ts +106 -1
- package/src/commands/launch.ts +32 -13
- package/src/commands/update.ts +284 -42
- package/src/messenger/gateway.ts +9 -6
- package/src/messenger/session-pool.ts +16 -1
- package/src/messenger/types.ts +8 -0
- package/src/util/shared-global.ts +380 -0
- package/src/util/shared-install-report.ts +315 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only view of the shared bun global install (`~/.bun/install/global`) that `jeo update`
|
|
3
|
+
* writes into with `bun install -g jeo-code@V`. That directory also holds other tools (jeopi,
|
|
4
|
+
* gajae-code, ...), so `jeo update` must prove it leaves every other top-level package alone.
|
|
5
|
+
*
|
|
6
|
+
* This module only ever reads: it never writes, deletes, renames, spawns or executes anything.
|
|
7
|
+
* Restore commands are formatted as strings for a human to run.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { currentJeoCommand } from "../messenger/launcher";
|
|
12
|
+
|
|
13
|
+
export const SELF_PACKAGE = "jeo-code";
|
|
14
|
+
|
|
15
|
+
export type EnvLike = Readonly<Record<string, string | undefined>>;
|
|
16
|
+
export type GlobalSkipReason = "windows" | "no-home" | "no-bun-global" | "manifest-unreadable" | "bunfig-global-dir";
|
|
17
|
+
export type LocatedGlobal = { ok: true; dir: string } | { ok: false; reason: GlobalSkipReason; detail?: string };
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function errorMessage(err: unknown): string {
|
|
24
|
+
if (err !== null && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message;
|
|
25
|
+
return String(err);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function errorCode(err: unknown): string | undefined {
|
|
29
|
+
if (err !== null && typeof err === "object" && "code" in err && typeof err.code === "string") return err.code;
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ParsedFile = { ok: true; value: Record<string, unknown> } | { ok: false; missing: boolean; error: string };
|
|
34
|
+
|
|
35
|
+
/** Reads and JSONC-parses a file that must hold an object. `missing` is true only for ENOENT/ENOTDIR. */
|
|
36
|
+
function parseJsonObject(file: string): ParsedFile {
|
|
37
|
+
let text: string;
|
|
38
|
+
try {
|
|
39
|
+
text = fs.readFileSync(file, "utf8");
|
|
40
|
+
} catch (err) {
|
|
41
|
+
const code = errorCode(err);
|
|
42
|
+
return { ok: false, missing: code === "ENOENT" || code === "ENOTDIR", error: errorMessage(err) };
|
|
43
|
+
}
|
|
44
|
+
let doc: unknown;
|
|
45
|
+
try {
|
|
46
|
+
doc = Bun.JSONC.parse(text);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return { ok: false, missing: false, error: errorMessage(err) };
|
|
49
|
+
}
|
|
50
|
+
if (!isRecord(doc)) return { ok: false, missing: false, error: "not a JSON object" };
|
|
51
|
+
return { ok: true, value: doc };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Where bun keeps its global install: `$BUN_INSTALL_GLOBAL_DIR`, else `${BUN_INSTALL:-$HOME/.bun}/install/global`.
|
|
56
|
+
* Null when neither an override nor a home directory is available, or on Windows (never inspected there).
|
|
57
|
+
*/
|
|
58
|
+
export function candidateGlobalDir(env: EnvLike, home: string | undefined, platform: NodeJS.Platform): string | null {
|
|
59
|
+
if (platform === "win32") return null;
|
|
60
|
+
const override = env.BUN_INSTALL_GLOBAL_DIR;
|
|
61
|
+
if (override) return path.resolve(override);
|
|
62
|
+
const installRoot = env.BUN_INSTALL || (home ? path.join(home, ".bun") : undefined);
|
|
63
|
+
return installRoot ? path.join(installRoot, "install", "global") : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const BUNFIG_GLOBAL_DIR_KEY = /^\s*(?:install\s*\.\s*)?["']?globalDir["']?\s*=/;
|
|
67
|
+
|
|
68
|
+
/** The bunfig.toml that sets a `globalDir`, if any (a user-level one moves the global dir independently of the env). */
|
|
69
|
+
function bunfigWithGlobalDir(env: EnvLike, home: string | undefined): string | null {
|
|
70
|
+
const files: string[] = [];
|
|
71
|
+
if (home) files.push(path.join(home, ".bunfig.toml"));
|
|
72
|
+
if (env.XDG_CONFIG_HOME) files.push(path.join(env.XDG_CONFIG_HOME, ".bunfig.toml"));
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
let text: string;
|
|
75
|
+
try {
|
|
76
|
+
text = fs.readFileSync(file, "utf8");
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (text.split(/\r?\n/).some(line => BUNFIG_GLOBAL_DIR_KEY.test(line))) return file;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolves the shared bun global dir and proves it holds a parseable package.json. A compiled jeo is NOT a
|
|
87
|
+
* skip reason: `jeo update` runs `bun install -g` into this same directory from a compiled binary too.
|
|
88
|
+
* `entry` and `execPath` are accepted so one options object serves both this and detectInstallOrigin.
|
|
89
|
+
*/
|
|
90
|
+
export function locateSharedGlobal(o: { env?: EnvLike; home?: string; platform?: NodeJS.Platform; entry?: string; execPath?: string } = {}): LocatedGlobal {
|
|
91
|
+
const env = o.env ?? process.env;
|
|
92
|
+
const platform = o.platform ?? process.platform;
|
|
93
|
+
if (platform === "win32") return { ok: false, reason: "windows" };
|
|
94
|
+
const home = o.home ?? (env.HOME || undefined);
|
|
95
|
+
const bunfig = bunfigWithGlobalDir(env, home);
|
|
96
|
+
if (bunfig) return { ok: false, reason: "bunfig-global-dir", detail: bunfig };
|
|
97
|
+
const candidate = candidateGlobalDir(env, home, platform);
|
|
98
|
+
if (!candidate) return { ok: false, reason: "no-home" };
|
|
99
|
+
const manifest = parseJsonObject(path.join(candidate, "package.json"));
|
|
100
|
+
if (!manifest.ok) {
|
|
101
|
+
return manifest.missing
|
|
102
|
+
? { ok: false, reason: "no-bun-global", detail: candidate }
|
|
103
|
+
: { ok: false, reason: "manifest-unreadable", detail: manifest.error };
|
|
104
|
+
}
|
|
105
|
+
let dir = candidate;
|
|
106
|
+
try {
|
|
107
|
+
dir = fs.realpathSync(candidate);
|
|
108
|
+
} catch {
|
|
109
|
+
// The manifest was just read from the candidate path, so keep it when realpath fails.
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, dir };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ManifestDeps {
|
|
115
|
+
names: string[];
|
|
116
|
+
specs: Record<string, string>;
|
|
117
|
+
external: string[];
|
|
118
|
+
ignored: string[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Top-level dependencies of the global package.json. Empty names and non-string specs are dropped into
|
|
123
|
+
* `ignored` (an older jeopi wrote a `"": "."` dependency that no lockfile could ever cover). `external` is
|
|
124
|
+
* every name except jeo-code, in manifest order. An absent `dependencies` means zero dependencies.
|
|
125
|
+
*/
|
|
126
|
+
export function readManifestDeps(dir: string): { ok: true; deps: ManifestDeps } | { ok: false; error: string } {
|
|
127
|
+
const manifest = parseJsonObject(path.join(dir, "package.json"));
|
|
128
|
+
if (!manifest.ok) return { ok: false, error: manifest.error };
|
|
129
|
+
const raw = manifest.value.dependencies;
|
|
130
|
+
if (raw !== undefined && raw !== null && !isRecord(raw)) return { ok: false, error: "dependencies is not an object" };
|
|
131
|
+
const names: string[] = [];
|
|
132
|
+
const specs: [string, string][] = [];
|
|
133
|
+
const ignored: string[] = [];
|
|
134
|
+
for (const [name, spec] of Object.entries(raw ?? {})) {
|
|
135
|
+
if (name === "" || typeof spec !== "string") {
|
|
136
|
+
ignored.push(name);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
names.push(name);
|
|
140
|
+
specs.push([name, spec]);
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, deps: { names, specs: Object.fromEntries(specs), external: names.filter(n => n !== SELF_PACKAGE), ignored } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type LockStatus = "ok" | "missing" | "incomplete" | "unreadable" | "not-required";
|
|
146
|
+
export interface LockCoverage {
|
|
147
|
+
status: LockStatus;
|
|
148
|
+
uncovered: string[];
|
|
149
|
+
path: string;
|
|
150
|
+
detail?: string;
|
|
151
|
+
legacyBinaryLock?: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether `bun.lock` carries a resolved entry for every external dependency. Bun's text lockfile is JSONC;
|
|
156
|
+
* a direct dependency is an OWN key of `packages` equal to its name (nested copies are `parent/name`, and the
|
|
157
|
+
* root workspace under `workspaces[""]` merely mirrors the manifest), and its value is a tuple whose first
|
|
158
|
+
* element is the resolved `name@version` string. lockfileVersion and tuple length are deliberately not gated.
|
|
159
|
+
*/
|
|
160
|
+
export function checkLockCoverage(dir: string, external: readonly string[]): LockCoverage {
|
|
161
|
+
const lockPath = path.join(dir, "bun.lock");
|
|
162
|
+
if (external.length === 0) return { status: "not-required", uncovered: [], path: lockPath };
|
|
163
|
+
let text: string;
|
|
164
|
+
try {
|
|
165
|
+
text = fs.readFileSync(lockPath, "utf8");
|
|
166
|
+
} catch (err) {
|
|
167
|
+
const missing = errorCode(err) === "ENOENT";
|
|
168
|
+
return {
|
|
169
|
+
status: missing ? "missing" : "unreadable",
|
|
170
|
+
uncovered: [...external],
|
|
171
|
+
path: lockPath,
|
|
172
|
+
detail: missing ? undefined : errorMessage(err),
|
|
173
|
+
legacyBinaryLock: missing && fs.existsSync(path.join(dir, "bun.lockb")),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
let doc: unknown;
|
|
177
|
+
try {
|
|
178
|
+
doc = Bun.JSONC.parse(text);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return { status: "unreadable", uncovered: [...external], path: lockPath, detail: errorMessage(err) };
|
|
181
|
+
}
|
|
182
|
+
const packages = isRecord(doc) ? doc.packages : undefined;
|
|
183
|
+
if (!isRecord(packages)) return { status: "unreadable", uncovered: [...external], path: lockPath, detail: "no packages map" };
|
|
184
|
+
const uncovered = external.filter(name => {
|
|
185
|
+
if (!Object.hasOwn(packages, name)) return true;
|
|
186
|
+
const entry = packages[name];
|
|
187
|
+
return !(Array.isArray(entry) && typeof entry[0] === "string" && entry[0].length > 0);
|
|
188
|
+
});
|
|
189
|
+
return { status: uncovered.length > 0 ? "incomplete" : "ok", uncovered, path: lockPath };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type InstalledSnapshot = Record<string, string | null>;
|
|
193
|
+
|
|
194
|
+
function installedVersion(dir: string, name: string): string | null {
|
|
195
|
+
if (name === "" || name === SELF_PACKAGE || name.includes("..") || path.isAbsolute(name)) return null;
|
|
196
|
+
const manifest = parseJsonObject(path.join(dir, "node_modules", name, "package.json"));
|
|
197
|
+
if (!manifest.ok) return null;
|
|
198
|
+
return typeof manifest.value.version === "string" ? manifest.value.version : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Installed version of each named package (read from `dir/node_modules/<name>/package.json`), null when it is
|
|
203
|
+
* absent or unreadable, has no string version, is jeo-code itself, has an unsafe name, or `present` lacks it.
|
|
204
|
+
*/
|
|
205
|
+
export function snapshotInstalled(dir: string, names: readonly string[], present?: ReadonlySet<string>): InstalledSnapshot {
|
|
206
|
+
return Object.fromEntries(names.map(name => [name, present && !present.has(name) ? null : installedVersion(dir, name)]));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface ExternalChange {
|
|
210
|
+
name: string;
|
|
211
|
+
from: string | null;
|
|
212
|
+
to: string | null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Every package whose installed version differs between the two snapshots; jeo-code is never reported. */
|
|
216
|
+
export function diffInstalled(before: InstalledSnapshot, after: InstalledSnapshot): ExternalChange[] {
|
|
217
|
+
const changes: ExternalChange[] = [];
|
|
218
|
+
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
219
|
+
if (name === SELF_PACKAGE) continue;
|
|
220
|
+
const from = Object.hasOwn(before, name) ? before[name] ?? null : null;
|
|
221
|
+
const to = Object.hasOwn(after, name) ? after[name] ?? null : null;
|
|
222
|
+
if (from !== to) changes.push({ name, from, to });
|
|
223
|
+
}
|
|
224
|
+
return changes;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function formatChangeLine(c: ExternalChange): string {
|
|
228
|
+
return `${c.name} ${c.from ?? "(not installed)"} -> ${c.to ?? "(removed)"}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const SAFE_VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]*$/;
|
|
232
|
+
const SAFE_PACKAGE_NAME = new RegExp("^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$");
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* One shell line per change that puts the previous state back. They are printed for a human and never run.
|
|
236
|
+
* A name or version that is not shell-safe becomes a comment so a hostile manifest cannot inject a command.
|
|
237
|
+
*/
|
|
238
|
+
export function formatRestoreCommands(cs: readonly ExternalChange[]): string[] {
|
|
239
|
+
return cs.map(c => {
|
|
240
|
+
if (!SAFE_PACKAGE_NAME.test(c.name)) return `# restore ${c.name.replace(/[^ -~]/g, "?")} manually`;
|
|
241
|
+
if (c.from === null) return `bun remove -g ${c.name}`;
|
|
242
|
+
return SAFE_VERSION.test(c.from) ? `bun install -g ${c.name}@${c.from}` : `# restore ${c.name} manually`;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const PRIMARY_TOOL = "jeopi-cli";
|
|
247
|
+
|
|
248
|
+
/** `shared global unchanged: 3 packages (jeopi-cli X, a Y, b Z, ...)`; jeopi-cli leads, then ascending, at most 3 shown. */
|
|
249
|
+
export function formatUnchangedSummary(after: InstalledSnapshot): string {
|
|
250
|
+
const present = Object.entries(after)
|
|
251
|
+
.filter((e): e is [string, string] => e[0] !== SELF_PACKAGE && e[1] !== null)
|
|
252
|
+
.sort(([a], [b]) => (a === PRIMARY_TOOL ? -1 : b === PRIMARY_TOOL ? 1 : a < b ? -1 : a > b ? 1 : 0));
|
|
253
|
+
const count = `${present.length} ${present.length === 1 ? "package" : "packages"}`;
|
|
254
|
+
if (present.length === 0) return `shared global unchanged: ${count}`;
|
|
255
|
+
const shown = present.slice(0, 3).map(([name, version]) => `${name} ${version}`);
|
|
256
|
+
return `shared global unchanged: ${count} (${shown.join(", ")}${present.length > 3 ? ", ..." : ""})`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export type InstallOrigin = "bun-global" | "npm-global" | "unknown";
|
|
260
|
+
export interface JeoOnPath {
|
|
261
|
+
path: string;
|
|
262
|
+
realpath: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Every executable `jeo` on PATH in PATH order, deduplicated by realpath (first PATH position wins). Nothing is executed. */
|
|
266
|
+
export function listJeoOnPath(env: EnvLike = process.env, platform: NodeJS.Platform = process.platform): JeoOnPath[] {
|
|
267
|
+
if (platform === "win32") return [];
|
|
268
|
+
const found: JeoOnPath[] = [];
|
|
269
|
+
const seen = new Set<string>();
|
|
270
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
271
|
+
if (!dir || !path.isAbsolute(dir)) continue;
|
|
272
|
+
const candidate = path.join(dir, "jeo");
|
|
273
|
+
let real: string;
|
|
274
|
+
try {
|
|
275
|
+
const stat = fs.statSync(candidate);
|
|
276
|
+
if (!stat.isFile() || (stat.mode & 0o111) === 0) continue;
|
|
277
|
+
real = fs.realpathSync(candidate);
|
|
278
|
+
} catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (seen.has(real)) continue;
|
|
282
|
+
seen.add(real);
|
|
283
|
+
found.push({ path: candidate, realpath: real });
|
|
284
|
+
}
|
|
285
|
+
return found;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function readNpmrcPrefix(home: string | undefined): string | null | undefined {
|
|
289
|
+
if (!home) return undefined;
|
|
290
|
+
let text: string;
|
|
291
|
+
try {
|
|
292
|
+
text = fs.readFileSync(path.join(home, ".npmrc"), "utf8");
|
|
293
|
+
} catch {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
let value: string | undefined;
|
|
297
|
+
for (const line of text.split(/\r?\n/)) {
|
|
298
|
+
const match = /^\s*prefix\s*=\s*(.*)$/.exec(line);
|
|
299
|
+
if (match) value = match[1];
|
|
300
|
+
}
|
|
301
|
+
if (value === undefined) return undefined;
|
|
302
|
+
let prefix = value.trim();
|
|
303
|
+
const quote = prefix[0];
|
|
304
|
+
if ((quote === '"' || quote === "'") && prefix.length >= 2 && prefix.endsWith(quote)) prefix = prefix.slice(1, -1);
|
|
305
|
+
else prefix = prefix.replace(/\s*[;#].*$/, "");
|
|
306
|
+
prefix = prefix.replace(/^~(?=\/|$)/, home).replace(/^\$\{?HOME\}?(?=\/|$)/, home);
|
|
307
|
+
return path.isAbsolute(prefix) ? prefix : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* npm's global node_modules directory without running npm: `npm_config_prefix`/`NPM_CONFIG_PREFIX`, else the
|
|
312
|
+
* last `prefix =` line of `~/.npmrc`, else the first executable `npm` on PATH (only when its `lib/node_modules`
|
|
313
|
+
* really holds npm, which accepts nvm/Homebrew layouts and rejects Volta/fnm shims). Null when unknown.
|
|
314
|
+
*/
|
|
315
|
+
export function resolveNpmGlobalRoot(env: EnvLike = process.env, home: string | undefined = env.HOME || undefined, platform: NodeJS.Platform = process.platform): string | null {
|
|
316
|
+
if (platform === "win32") return null;
|
|
317
|
+
const fromEnv = env.npm_config_prefix || env.NPM_CONFIG_PREFIX;
|
|
318
|
+
if (fromEnv) return path.isAbsolute(fromEnv) ? path.join(fromEnv, "lib", "node_modules") : null;
|
|
319
|
+
const fromNpmrc = readNpmrcPrefix(home);
|
|
320
|
+
if (fromNpmrc !== undefined) return fromNpmrc === null ? null : path.join(fromNpmrc, "lib", "node_modules");
|
|
321
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
322
|
+
if (!dir || !path.isAbsolute(dir)) continue;
|
|
323
|
+
const npm = path.join(dir, "npm");
|
|
324
|
+
try {
|
|
325
|
+
const stat = fs.statSync(npm);
|
|
326
|
+
if (!stat.isFile() || (stat.mode & 0o111) === 0) continue;
|
|
327
|
+
} catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (path.basename(dir) !== "bin") return null;
|
|
331
|
+
const root = path.join(path.dirname(dir), "lib", "node_modules");
|
|
332
|
+
return fs.existsSync(path.join(root, "npm", "package.json")) ? root : null;
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** True when `child` is strictly inside `parent`. */
|
|
338
|
+
export function isUnder(child: string, parent: string): boolean {
|
|
339
|
+
const rel = path.relative(parent, child);
|
|
340
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function classifyActiveInstall(active: string | null, roots: { bunGlobalNodeModules: string | null; npmGlobalRoot: string | null }): InstallOrigin {
|
|
344
|
+
if (active === null) return "unknown";
|
|
345
|
+
if (roots.bunGlobalNodeModules !== null && isUnder(active, roots.bunGlobalNodeModules)) return "bun-global";
|
|
346
|
+
if (roots.npmGlobalRoot !== null && isUnder(active, roots.npmGlobalRoot)) return "npm-global";
|
|
347
|
+
return "unknown";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function realpathIfPresent(p: string | null): string | null {
|
|
351
|
+
if (p === null) return null;
|
|
352
|
+
try {
|
|
353
|
+
return fs.realpathSync(p);
|
|
354
|
+
} catch {
|
|
355
|
+
return p;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Which package manager owns the `jeo` that is first on PATH, decided from paths only. A compiled binary
|
|
361
|
+
* (`currentJeoCommand` is a single element) has no package-manager install, so its origin is 'unknown'
|
|
362
|
+
* and `jeo update` never falls back to npm for it.
|
|
363
|
+
*/
|
|
364
|
+
export function detectInstallOrigin(o: { env?: EnvLike; home?: string; platform?: NodeJS.Platform; entry?: string; execPath?: string } = {}): {
|
|
365
|
+
origin: InstallOrigin;
|
|
366
|
+
active: string | null;
|
|
367
|
+
bunGlobalNodeModules: string | null;
|
|
368
|
+
npmGlobalRoot: string | null;
|
|
369
|
+
} {
|
|
370
|
+
const env = o.env ?? process.env;
|
|
371
|
+
const platform = o.platform ?? process.platform;
|
|
372
|
+
const home = o.home ?? (env.HOME || undefined);
|
|
373
|
+
const active = listJeoOnPath(env, platform)[0]?.realpath ?? null;
|
|
374
|
+
const candidate = candidateGlobalDir(env, home, platform);
|
|
375
|
+
const bunGlobalNodeModules = candidate === null ? null : realpathIfPresent(path.join(candidate, "node_modules"));
|
|
376
|
+
const npmGlobalRoot = resolveNpmGlobalRoot(env, home, platform);
|
|
377
|
+
const compiled = currentJeoCommand(o.entry, o.execPath).length === 1;
|
|
378
|
+
const origin = compiled ? "unknown" : classifyActiveInstall(active, { bunGlobalNodeModules, npmGlobalRoot: realpathIfPresent(npmGlobalRoot) });
|
|
379
|
+
return { origin, active, bunGlobalNodeModules, npmGlobalRoot };
|
|
380
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor-side, read-only report on the shared bun global install that `jeo update` writes into:
|
|
3
|
+
* `bun.lock` coverage, every `jeo` on PATH with its version, leftovers a previous tool left behind
|
|
4
|
+
* (backup files, possible orphans) and where the disk space goes.
|
|
5
|
+
*
|
|
6
|
+
* Everything here only reads. Nothing is written, deleted, renamed, spawned or executed, and a
|
|
7
|
+
* symlink inside `node_modules` is never followed or read (one existence check tells a live link
|
|
8
|
+
* from a dangling one). Findings are reported for a human; nothing is repaired.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
SELF_PACKAGE,
|
|
14
|
+
checkLockCoverage,
|
|
15
|
+
listJeoOnPath,
|
|
16
|
+
locateSharedGlobal,
|
|
17
|
+
readManifestDeps,
|
|
18
|
+
type EnvLike,
|
|
19
|
+
type GlobalSkipReason,
|
|
20
|
+
type JeoOnPath,
|
|
21
|
+
type LockCoverage,
|
|
22
|
+
} from "./shared-global";
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_SIZE_CAP_MS = 3000;
|
|
25
|
+
export const DEFAULT_SIZE_CAP_ENTRIES = 400_000;
|
|
26
|
+
export const DEFAULT_ORPHAN_CAP_MANIFESTS = 5000;
|
|
27
|
+
|
|
28
|
+
/** Size scans read the clock every this many entries (and before the first one). */
|
|
29
|
+
const TIME_CHECK_EVERY = 256;
|
|
30
|
+
const TOP_PACKAGES = 5;
|
|
31
|
+
/** A `jeo` executable is the entry script inside its package: the package.json is in its directory or up to this many parents above. */
|
|
32
|
+
const VERSION_WALK_PARENTS = 4;
|
|
33
|
+
const BACKUP_FILE = /^package\.json\..*backup/;
|
|
34
|
+
const DECLARING_FIELDS = ["dependencies", "optionalDependencies", "peerDependencies"] as const;
|
|
35
|
+
|
|
36
|
+
export interface JeoOnPathEntry extends JeoOnPath {
|
|
37
|
+
version: string | null;
|
|
38
|
+
active: boolean;
|
|
39
|
+
stale: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PossibleOrphan {
|
|
43
|
+
name: string;
|
|
44
|
+
kind: "directory" | "symlink" | "dangling-symlink";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LeftoverReport {
|
|
48
|
+
backupFiles: string[];
|
|
49
|
+
possibleOrphans: PossibleOrphan[];
|
|
50
|
+
orphanScanComplete: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SizeReport {
|
|
54
|
+
top: { name: string; bytes: number }[];
|
|
55
|
+
totalBytes: number;
|
|
56
|
+
scannedEntries: number;
|
|
57
|
+
complete: boolean;
|
|
58
|
+
capped: "time" | "entries" | null;
|
|
59
|
+
elapsedMs: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SharedInstallReport {
|
|
63
|
+
globalDir: string | null;
|
|
64
|
+
skipped: GlobalSkipReason | null;
|
|
65
|
+
manifest: { dependencies: number; external: number } | null;
|
|
66
|
+
lockfile: LockCoverage | null;
|
|
67
|
+
jeoOnPath: JeoOnPathEntry[];
|
|
68
|
+
leftovers: LeftoverReport | null;
|
|
69
|
+
sizes: SizeReport | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
73
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function byName(a: string, b: string): number {
|
|
77
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readPackageJson(file: string): Record<string, unknown> | null {
|
|
81
|
+
try {
|
|
82
|
+
const doc: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
83
|
+
return isRecord(doc) ? doc : null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sortedDirents(dir: string): fs.Dirent[] {
|
|
90
|
+
try {
|
|
91
|
+
return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => byName(a.name, b.name));
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Version of the jeo-code package that owns this `jeo` entry script, read from the package.json next to it. */
|
|
98
|
+
function jeoPackageVersionNear(realpath: string): string | null {
|
|
99
|
+
let dir = path.dirname(realpath);
|
|
100
|
+
for (let level = 0; level <= VERSION_WALK_PARENTS; level++) {
|
|
101
|
+
const pkg = readPackageJson(path.join(dir, "package.json"));
|
|
102
|
+
if (pkg !== null && pkg.name === SELF_PACKAGE && typeof pkg.version === "string") return pkg.version;
|
|
103
|
+
const parent = path.dirname(dir);
|
|
104
|
+
if (parent === dir) break;
|
|
105
|
+
dir = parent;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every `jeo` on PATH (deduplicated by realpath, PATH order) with the version of the package it belongs to.
|
|
112
|
+
* The first is the active one; a later one is stale when both versions are known and differ. Nothing is run.
|
|
113
|
+
*/
|
|
114
|
+
export function listJeoOnPathWithVersions(env: EnvLike = process.env, platform: NodeJS.Platform = process.platform): JeoOnPathEntry[] {
|
|
115
|
+
const found = listJeoOnPath(env, platform);
|
|
116
|
+
const versions = found.map(entry => jeoPackageVersionNear(entry.realpath));
|
|
117
|
+
const activeVersion = versions[0] ?? null;
|
|
118
|
+
return found.map((entry, index) => {
|
|
119
|
+
const version = versions[index] ?? null;
|
|
120
|
+
return {
|
|
121
|
+
...entry,
|
|
122
|
+
version,
|
|
123
|
+
active: index === 0,
|
|
124
|
+
stale: index > 0 && version !== null && activeVersion !== null && version !== activeVersion,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface InstalledPackage {
|
|
130
|
+
name: string;
|
|
131
|
+
abs: string;
|
|
132
|
+
kind: PossibleOrphan["kind"];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function classify(entry: fs.Dirent, abs: string): PossibleOrphan["kind"] | null {
|
|
136
|
+
if (entry.isSymbolicLink()) return fs.existsSync(abs) ? "symlink" : "dangling-symlink";
|
|
137
|
+
return entry.isDirectory() ? "directory" : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Non-dot packages directly inside a node_modules dir, scoped packages flattened to `@scope/name`. */
|
|
141
|
+
function listPackages(nodeModules: string): InstalledPackage[] {
|
|
142
|
+
const out: InstalledPackage[] = [];
|
|
143
|
+
for (const entry of sortedDirents(nodeModules)) {
|
|
144
|
+
if (entry.name.startsWith(".")) continue;
|
|
145
|
+
const abs = path.join(nodeModules, entry.name);
|
|
146
|
+
const kind = classify(entry, abs);
|
|
147
|
+
if (kind === null) continue;
|
|
148
|
+
if (kind !== "directory" || !entry.name.startsWith("@")) {
|
|
149
|
+
out.push({ name: entry.name, abs, kind });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const child of sortedDirents(abs)) {
|
|
153
|
+
if (child.name.startsWith(".")) continue;
|
|
154
|
+
const childAbs = path.join(abs, child.name);
|
|
155
|
+
const childKind = classify(child, childAbs);
|
|
156
|
+
if (childKind !== null) out.push({ name: `${entry.name}/${child.name}`, abs: childAbs, kind: childKind });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Names that some installed package declares (dependencies, optionalDependencies, peerDependencies),
|
|
164
|
+
* collected from every package.json under node_modules, nested copies included. Symlinked packages
|
|
165
|
+
* are never descended into or read. Null when more than `cap` manifests would have to be read.
|
|
166
|
+
*/
|
|
167
|
+
function collectDeclared(packages: readonly InstalledPackage[], cap: number): Set<string> | null {
|
|
168
|
+
const declared = new Set<string>();
|
|
169
|
+
const pending = packages.filter(p => p.kind === "directory").map(p => p.abs);
|
|
170
|
+
let read = 0;
|
|
171
|
+
for (let abs = pending.pop(); abs !== undefined; abs = pending.pop()) {
|
|
172
|
+
if (read >= cap) return null;
|
|
173
|
+
read++;
|
|
174
|
+
const pkg = readPackageJson(path.join(abs, "package.json"));
|
|
175
|
+
if (pkg !== null) {
|
|
176
|
+
for (const field of DECLARING_FIELDS) {
|
|
177
|
+
const deps = pkg[field];
|
|
178
|
+
if (isRecord(deps)) for (const name of Object.keys(deps)) declared.add(name);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const nested of listPackages(path.join(abs, "node_modules"))) {
|
|
182
|
+
if (nested.kind === "directory") pending.push(nested.abs);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return declared;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Leftovers of other tools in the global dir: `package.json.*backup*` files, and installed top-level
|
|
190
|
+
* packages that neither the manifest nor any installed package declares ("possible orphans"; a dot-prefixed
|
|
191
|
+
* entry is never reported). When the declared-name scan would exceed `capManifests` no orphan is reported
|
|
192
|
+
* and `orphanScanComplete` is false.
|
|
193
|
+
*/
|
|
194
|
+
export function findStaleLeftovers(dir: string, manifestNames: readonly string[], capManifests: number = DEFAULT_ORPHAN_CAP_MANIFESTS): LeftoverReport {
|
|
195
|
+
const backupFiles = sortedDirents(dir)
|
|
196
|
+
.filter(entry => entry.isFile() && BACKUP_FILE.test(entry.name))
|
|
197
|
+
.map(entry => entry.name);
|
|
198
|
+
const nodeModules = path.join(dir, "node_modules");
|
|
199
|
+
const inManifest = new Set(manifestNames);
|
|
200
|
+
const packages = listPackages(nodeModules);
|
|
201
|
+
const candidates = packages.filter(p => !inManifest.has(p.name));
|
|
202
|
+
if (candidates.length === 0) return { backupFiles, possibleOrphans: [], orphanScanComplete: true };
|
|
203
|
+
const declared = collectDeclared(packages, capManifests);
|
|
204
|
+
if (declared === null) return { backupFiles, possibleOrphans: [], orphanScanComplete: false };
|
|
205
|
+
const possibleOrphans = candidates
|
|
206
|
+
.filter(p => !declared.has(p.name))
|
|
207
|
+
.map(({ name, kind }): PossibleOrphan => ({ name, kind }))
|
|
208
|
+
.sort((a, b) => byName(a.name, b.name));
|
|
209
|
+
return { backupFiles, possibleOrphans, orphanScanComplete: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface WalkFrame {
|
|
213
|
+
dir: string;
|
|
214
|
+
level: "root" | "scope" | "package";
|
|
215
|
+
scope: string;
|
|
216
|
+
/** Top-level package the directory belongs to; null for dot entries and stray files, which only count toward the total. */
|
|
217
|
+
pkg: string | null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Apparent bytes under `dir/node_modules`, attributed to the top-level package (scoped flattened) that holds them.
|
|
222
|
+
* Symlinks count their own size and are never followed; a directory counts as one entry and no bytes. The walk
|
|
223
|
+
* stops at the entry cap (checked on every entry) or the time cap (checked before the first entry and every 256
|
|
224
|
+
* entries); a stopped scan returns lower bounds with `complete: false`.
|
|
225
|
+
*/
|
|
226
|
+
export function sizeReport(dir: string, o: { now?: () => number; capMs?: number; capEntries?: number } = {}): SizeReport {
|
|
227
|
+
const now = o.now ?? Date.now;
|
|
228
|
+
const capMs = o.capMs ?? DEFAULT_SIZE_CAP_MS;
|
|
229
|
+
const capEntries = o.capEntries ?? DEFAULT_SIZE_CAP_ENTRIES;
|
|
230
|
+
const started = now();
|
|
231
|
+
const bytesByPackage = new Map<string, number>();
|
|
232
|
+
let totalBytes = 0;
|
|
233
|
+
let scanned = 0;
|
|
234
|
+
let capped: SizeReport["capped"] = null;
|
|
235
|
+
const stack: WalkFrame[] = [{ dir: path.join(dir, "node_modules"), level: "root", scope: "", pkg: null }];
|
|
236
|
+
walk: for (let frame = stack.pop(); frame !== undefined; frame = stack.pop()) {
|
|
237
|
+
for (const entry of sortedDirents(frame.dir)) {
|
|
238
|
+
if (scanned >= capEntries) {
|
|
239
|
+
capped = "entries";
|
|
240
|
+
break walk;
|
|
241
|
+
}
|
|
242
|
+
if (scanned % TIME_CHECK_EVERY === 0 && now() - started > capMs) {
|
|
243
|
+
capped = "time";
|
|
244
|
+
break walk;
|
|
245
|
+
}
|
|
246
|
+
scanned++;
|
|
247
|
+
const abs = path.join(frame.dir, entry.name);
|
|
248
|
+
const isDir = entry.isDirectory();
|
|
249
|
+
let pkg = frame.pkg;
|
|
250
|
+
if (frame.level === "root") pkg = entry.name.startsWith(".") || (!isDir && !entry.isSymbolicLink()) ? null : entry.name;
|
|
251
|
+
else if (frame.level === "scope") pkg = entry.name.startsWith(".") ? null : `${frame.scope}/${entry.name}`;
|
|
252
|
+
if (isDir) {
|
|
253
|
+
if (frame.level === "root" && entry.name.startsWith("@")) {
|
|
254
|
+
stack.push({ dir: abs, level: "scope", scope: entry.name, pkg: null });
|
|
255
|
+
} else {
|
|
256
|
+
stack.push({ dir: abs, level: "package", scope: "", pkg });
|
|
257
|
+
if (pkg !== null && !bytesByPackage.has(pkg)) bytesByPackage.set(pkg, 0);
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
let size = 0;
|
|
262
|
+
try {
|
|
263
|
+
size = fs.lstatSync(abs).size;
|
|
264
|
+
} catch {
|
|
265
|
+
// A file that vanished mid-scan counts as zero bytes.
|
|
266
|
+
}
|
|
267
|
+
totalBytes += size;
|
|
268
|
+
if (pkg !== null) bytesByPackage.set(pkg, (bytesByPackage.get(pkg) ?? 0) + size);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const top = [...bytesByPackage]
|
|
272
|
+
.map(([name, bytes]) => ({ name, bytes }))
|
|
273
|
+
.sort((a, b) => b.bytes - a.bytes || byName(a.name, b.name))
|
|
274
|
+
.slice(0, TOP_PACKAGES);
|
|
275
|
+
return { top, totalBytes, scannedEntries: scanned, complete: capped === null, capped, elapsedMs: now() - started };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* One read-only snapshot for `jeo doctor`. The `jeo` executables on PATH are always listed; when no usable
|
|
280
|
+
* bun global exists the report says why (`skipped`) and leaves the other sections null. File-system errors
|
|
281
|
+
* degrade to empty findings instead of throwing.
|
|
282
|
+
*/
|
|
283
|
+
export function collectSharedInstallReport(
|
|
284
|
+
o: {
|
|
285
|
+
env?: EnvLike;
|
|
286
|
+
home?: string;
|
|
287
|
+
platform?: NodeJS.Platform;
|
|
288
|
+
entry?: string;
|
|
289
|
+
execPath?: string;
|
|
290
|
+
now?: () => number;
|
|
291
|
+
sizeCapMs?: number;
|
|
292
|
+
sizeCapEntries?: number;
|
|
293
|
+
orphanCapManifests?: number;
|
|
294
|
+
} = {},
|
|
295
|
+
): SharedInstallReport {
|
|
296
|
+
const env = o.env ?? process.env;
|
|
297
|
+
const platform = o.platform ?? process.platform;
|
|
298
|
+
const jeoOnPath = listJeoOnPathWithVersions(env, platform);
|
|
299
|
+
const located = locateSharedGlobal({ env, home: o.home, platform, entry: o.entry, execPath: o.execPath });
|
|
300
|
+
const manifest = located.ok ? readManifestDeps(located.dir) : null;
|
|
301
|
+
if (!located.ok || manifest === null || !manifest.ok) {
|
|
302
|
+
const skipped: GlobalSkipReason = located.ok ? "manifest-unreadable" : located.reason;
|
|
303
|
+
return { globalDir: null, skipped, manifest: null, lockfile: null, jeoOnPath, leftovers: null, sizes: null };
|
|
304
|
+
}
|
|
305
|
+
const { names, external } = manifest.deps;
|
|
306
|
+
return {
|
|
307
|
+
globalDir: located.dir,
|
|
308
|
+
skipped: null,
|
|
309
|
+
manifest: { dependencies: names.length, external: external.length },
|
|
310
|
+
lockfile: checkLockCoverage(located.dir, external),
|
|
311
|
+
jeoOnPath,
|
|
312
|
+
leftovers: findStaleLeftovers(located.dir, names, o.orphanCapManifests),
|
|
313
|
+
sizes: sizeReport(located.dir, { now: o.now, capMs: o.sizeCapMs, capEntries: o.sizeCapEntries }),
|
|
314
|
+
};
|
|
315
|
+
}
|