@git.zone/cli 2.19.0 → 2.19.2

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.
Files changed (43) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/gitzone.cli.d.ts +1 -1
  3. package/dist_ts/gitzone.cli.js +100 -126
  4. package/dist_ts/helpers.climode.d.ts +2 -0
  5. package/dist_ts/helpers.climode.js +31 -2
  6. package/dist_ts/helpers.smartconfigmigrations.js +34 -2
  7. package/dist_ts/mod_config/index.js +51 -10
  8. package/dist_ts/mod_format/classes.baseformatter.d.ts +3 -1
  9. package/dist_ts/mod_format/classes.baseformatter.js +7 -1
  10. package/dist_ts/mod_format/classes.formatplanner.d.ts +2 -0
  11. package/dist_ts/mod_format/classes.formatplanner.js +48 -4
  12. package/dist_ts/mod_format/formatters/license.formatter.d.ts +4 -1
  13. package/dist_ts/mod_format/formatters/license.formatter.js +32 -10
  14. package/dist_ts/mod_format/formatters/prettier.formatter.d.ts +0 -3
  15. package/dist_ts/mod_format/formatters/prettier.formatter.js +53 -62
  16. package/dist_ts/mod_format/index.d.ts +1 -1
  17. package/dist_ts/mod_format/index.js +237 -8
  18. package/dist_ts/mod_format/interfaces.format.d.ts +7 -11
  19. package/dist_ts/mod_format/interfaces.format.js +1 -1
  20. package/dist_ts/mod_standard/index.js +2 -1
  21. package/dist_ts/mod_tools/classes.packagemanager.d.ts +36 -1
  22. package/dist_ts/mod_tools/classes.packagemanager.js +541 -34
  23. package/dist_ts/mod_tools/index.js +128 -26
  24. package/dist_ts/plugins.d.ts +2 -1
  25. package/dist_ts/plugins.js +3 -2
  26. package/package.json +1 -1
  27. package/readme.hints.md +10 -0
  28. package/readme.md +11 -0
  29. package/ts/00_commitinfo_data.ts +1 -1
  30. package/ts/gitzone.cli.ts +104 -144
  31. package/ts/helpers.climode.ts +36 -1
  32. package/ts/helpers.smartconfigmigrations.ts +37 -1
  33. package/ts/mod_config/index.ts +60 -9
  34. package/ts/mod_format/classes.baseformatter.ts +13 -1
  35. package/ts/mod_format/classes.formatplanner.ts +66 -4
  36. package/ts/mod_format/formatters/license.formatter.ts +43 -15
  37. package/ts/mod_format/formatters/prettier.formatter.ts +54 -66
  38. package/ts/mod_format/index.ts +289 -8
  39. package/ts/mod_format/interfaces.format.ts +8 -11
  40. package/ts/mod_standard/index.ts +1 -0
  41. package/ts/mod_tools/classes.packagemanager.ts +724 -34
  42. package/ts/mod_tools/index.ts +225 -45
  43. package/ts/plugins.ts +2 -0
@@ -3,6 +3,9 @@ import * as plugins from "./mod.plugins.js";
3
3
  export interface IInstalledPackage {
4
4
  name: string;
5
5
  version: string;
6
+ globalDir?: string;
7
+ packagePath?: string;
8
+ legacy?: boolean;
6
9
  }
7
10
 
8
11
  export interface IPackageUpdateInfo {
@@ -10,6 +13,8 @@ export interface IPackageUpdateInfo {
10
13
  currentVersion: string;
11
14
  latestVersion: string;
12
15
  needsUpdate: boolean;
16
+ needsMigration?: boolean;
17
+ globalDir?: string;
13
18
  }
14
19
 
15
20
  export interface IPackageManagerInfo {
@@ -19,18 +24,38 @@ export interface IPackageManagerInfo {
19
24
  needsUpdate: boolean;
20
25
  }
21
26
 
27
+ export interface ILegacyGlobalRootInfo {
28
+ globalDir: string;
29
+ packages: IInstalledPackage[];
30
+ unmanagedPackageNames: string[];
31
+ safeToDelete: boolean;
32
+ }
33
+
34
+ export interface ILegacyCleanupResult {
35
+ globalDir: string;
36
+ deleted: boolean;
37
+ reason?: string;
38
+ }
39
+
40
+ export interface IShimSyncResult {
41
+ name: string;
42
+ action: "updated" | "removed" | "skipped";
43
+ reason?: string;
44
+ }
45
+
46
+ interface IPnpmListProject {
47
+ path?: string;
48
+ dependencies?: Record<string, any>;
49
+ }
50
+
22
51
  export class PackageManagerUtil {
23
52
  private shell = new plugins.smartshell.Smartshell({
24
53
  executor: "bash",
25
54
  });
55
+ private pnpmCommand: string | null | undefined;
26
56
 
27
57
  public async detectPnpm(): Promise<boolean> {
28
- try {
29
- const result = await this.shell.execSilent("pnpm --version 2>/dev/null");
30
- return result.exitCode === 0 && Boolean(result.stdout.trim());
31
- } catch {
32
- return false;
33
- }
58
+ return Boolean(await this.getPnpmCommand());
34
59
  }
35
60
 
36
61
  public async getPnpmVersionInfo(): Promise<IPackageManagerInfo> {
@@ -45,53 +70,300 @@ export class PackageManagerUtil {
45
70
  }
46
71
 
47
72
  const currentVersion = await this.getCurrentPnpmVersion();
48
- const latestVersion = await this.getLatestVersion("pnpm", ["https://registry.npmjs.org"]);
73
+ const latestVersion = await this.getLatestVersion("pnpm", [
74
+ "https://registry.npmjs.org",
75
+ ]);
49
76
 
50
77
  return {
51
78
  available: true,
52
79
  currentVersion,
53
80
  latestVersion,
54
- needsUpdate: latestVersion ? this.isNewerVersion(currentVersion, latestVersion) : false,
81
+ needsUpdate: latestVersion
82
+ ? this.isNewerVersion(currentVersion, latestVersion)
83
+ : false,
55
84
  };
56
85
  }
57
86
 
58
87
  public async getInstalledPackages(): Promise<IInstalledPackage[]> {
59
- const packages: IInstalledPackage[] = [];
88
+ const packageMap = new Map<string, IInstalledPackage>();
89
+ const currentPackages = await this.getCurrentInstalledPackages();
60
90
 
61
- try {
62
- const result = await this.shell.execSilent("pnpm list -g --depth=0 --json 2>/dev/null || true");
63
- const output = result.stdout.trim();
64
- if (!output) {
65
- return packages;
91
+ for (const packageInfo of currentPackages) {
92
+ packageMap.set(packageInfo.name, packageInfo);
93
+ }
94
+
95
+ const legacyRoots = await this.getLegacyGlobalRoots();
96
+ for (const legacyRoot of legacyRoots) {
97
+ for (const packageInfo of legacyRoot.packages) {
98
+ if (!packageMap.has(packageInfo.name)) {
99
+ packageMap.set(packageInfo.name, packageInfo);
100
+ }
66
101
  }
102
+ }
67
103
 
68
- const data = JSON.parse(output);
69
- const dataArray = Array.isArray(data) ? data : [data];
70
- for (const item of dataArray) {
71
- const dependencies = item.dependencies || {};
72
- for (const [name, info] of Object.entries(dependencies)) {
73
- if (!name.startsWith("@git.zone/")) {
104
+ return Array.from(packageMap.values()).sort((packageA, packageB) =>
105
+ packageA.name.localeCompare(packageB.name),
106
+ );
107
+ }
108
+
109
+ public async getLegacyGlobalRoots(): Promise<ILegacyGlobalRootInfo[]> {
110
+ const currentGlobalDir = await this.getCurrentGlobalDir();
111
+ const baseDirs = new Set<string>();
112
+ const pnpmHome = process.env.PNPM_HOME;
113
+
114
+ if (pnpmHome) {
115
+ baseDirs.add(plugins.path.join(pnpmHome, "global"));
116
+ }
117
+
118
+ if (currentGlobalDir) {
119
+ baseDirs.add(plugins.path.dirname(currentGlobalDir));
120
+ }
121
+
122
+ const roots: ILegacyGlobalRootInfo[] = [];
123
+ for (const baseDir of baseDirs) {
124
+ try {
125
+ const entries = await plugins.fs.readdir(baseDir, {
126
+ withFileTypes: true,
127
+ });
128
+
129
+ for (const entry of entries) {
130
+ if (!entry.isDirectory()) {
74
131
  continue;
75
132
  }
76
- packages.push({
77
- name,
78
- version: (info as any).version || "unknown",
79
- });
133
+
134
+ const globalDir = normalizePath(
135
+ plugins.path.join(baseDir, entry.name),
136
+ );
137
+ if (currentGlobalDir && pathsAreEqual(globalDir, currentGlobalDir)) {
138
+ continue;
139
+ }
140
+
141
+ const rootInfo = await this.inspectGlobalRoot(globalDir, false);
142
+ if (rootInfo.packages.length > 0) {
143
+ roots.push(rootInfo);
144
+ }
80
145
  }
146
+ } catch {
147
+ continue;
148
+ }
149
+ }
150
+
151
+ return roots;
152
+ }
153
+
154
+ public async cleanupLegacyGlobalRoots(): Promise<ILegacyCleanupResult[]> {
155
+ const legacyRoots = await this.getLegacyGlobalRoots();
156
+ if (legacyRoots.length === 0) {
157
+ return [];
158
+ }
159
+
160
+ const currentPackageNames = new Set(
161
+ (await this.getCurrentInstalledPackages()).map(
162
+ (packageInfo) => packageInfo.name,
163
+ ),
164
+ );
165
+ const cleanupResults: ILegacyCleanupResult[] = [];
166
+
167
+ for (const legacyRoot of legacyRoots) {
168
+ const missingPackageNames = legacyRoot.packages
169
+ .map((packageInfo) => packageInfo.name)
170
+ .filter((packageName) => !currentPackageNames.has(packageName));
171
+
172
+ if (missingPackageNames.length > 0) {
173
+ cleanupResults.push({
174
+ globalDir: legacyRoot.globalDir,
175
+ deleted: false,
176
+ reason: `kept because ${missingPackageNames.join(", ")} are not installed in the current pnpm global root`,
177
+ });
178
+ continue;
179
+ }
180
+
181
+ if (!legacyRoot.safeToDelete) {
182
+ cleanupResults.push({
183
+ globalDir: legacyRoot.globalDir,
184
+ deleted: false,
185
+ reason:
186
+ legacyRoot.unmanagedPackageNames.length > 0
187
+ ? `kept because it also contains ${legacyRoot.unmanagedPackageNames.join(", ")}`
188
+ : "kept because it is not a managed @git.zone-only global root",
189
+ });
190
+ continue;
191
+ }
192
+
193
+ const blockingShims = await this.getShimReferences(legacyRoot.globalDir);
194
+ if (blockingShims === null) {
195
+ cleanupResults.push({
196
+ globalDir: legacyRoot.globalDir,
197
+ deleted: false,
198
+ reason:
199
+ "kept because PNPM_HOME is not set, so command shims could not be verified",
200
+ });
201
+ continue;
202
+ }
203
+
204
+ if (blockingShims.length > 0) {
205
+ cleanupResults.push({
206
+ globalDir: legacyRoot.globalDir,
207
+ deleted: false,
208
+ reason: `kept because command shims still reference it: ${blockingShims.join(", ")}`,
209
+ });
210
+ continue;
211
+ }
212
+
213
+ try {
214
+ await plugins.fs.rm(legacyRoot.globalDir, {
215
+ recursive: true,
216
+ force: true,
217
+ });
218
+ cleanupResults.push({
219
+ globalDir: legacyRoot.globalDir,
220
+ deleted: true,
221
+ });
222
+ } catch (error) {
223
+ cleanupResults.push({
224
+ globalDir: legacyRoot.globalDir,
225
+ deleted: false,
226
+ reason: `delete failed: ${(error as Error).message}`,
227
+ });
81
228
  }
82
- } catch {
83
- return packages;
84
229
  }
85
230
 
86
- return packages;
231
+ return cleanupResults;
232
+ }
233
+
234
+ public async syncCurrentGlobalShims(): Promise<IShimSyncResult[]> {
235
+ const pnpmShimDirs = await this.getPnpmShimDirs();
236
+ if (!pnpmShimDirs) {
237
+ return [
238
+ {
239
+ name: "PNPM_HOME",
240
+ action: "skipped",
241
+ reason: "PNPM_HOME is not set",
242
+ },
243
+ ];
244
+ }
245
+
246
+ const results: IShimSyncResult[] = [];
247
+ const currentBinNames = new Set<string>();
248
+ const currentPackages = await this.getCurrentInstalledPackages();
249
+
250
+ for (const packageInfo of currentPackages) {
251
+ if (!packageInfo.packagePath) {
252
+ continue;
253
+ }
254
+
255
+ const packageJson = await readJson(
256
+ plugins.path.join(packageInfo.packagePath, "package.json"),
257
+ );
258
+ const binNames = getPackageBinNames(packageInfo.name, packageJson);
259
+ const nodeModulesDir = getNodeModulesDir(
260
+ packageInfo.packagePath,
261
+ packageInfo.name,
262
+ );
263
+
264
+ for (const binName of binNames) {
265
+ currentBinNames.add(binName);
266
+ const sourceShim = plugins.path.join(nodeModulesDir, ".bin", binName);
267
+
268
+ for (const pnpmShimDir of pnpmShimDirs) {
269
+ const destinationShim = plugins.path.join(pnpmShimDir, binName);
270
+
271
+ try {
272
+ const sourceContent = await plugins.fs.readFile(sourceShim, "utf8");
273
+ const sourceStat = await plugins.fs.stat(sourceShim);
274
+ await plugins.fs.writeFile(
275
+ destinationShim,
276
+ rewriteShimForPnpmHome(sourceContent),
277
+ "utf8",
278
+ );
279
+ await plugins.fs.chmod(destinationShim, sourceStat.mode);
280
+ results.push({
281
+ name: formatShimName(pnpmShimDir, binName),
282
+ action: "updated",
283
+ });
284
+ } catch (error) {
285
+ results.push({
286
+ name: formatShimName(pnpmShimDir, binName),
287
+ action: "skipped",
288
+ reason: (error as Error).message,
289
+ });
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ const legacyRoots = await this.getLegacyGlobalRoots();
296
+ const legacyGlobalDirs = legacyRoots.map(
297
+ (legacyRoot) => legacyRoot.globalDir,
298
+ );
299
+ if (legacyGlobalDirs.length === 0) {
300
+ return results;
301
+ }
302
+
303
+ for (const pnpmShimDir of pnpmShimDirs) {
304
+ try {
305
+ const entries = await plugins.fs.readdir(pnpmShimDir, {
306
+ withFileTypes: true,
307
+ });
308
+ for (const entry of entries) {
309
+ if (!entry.isFile() || currentBinNames.has(entry.name)) {
310
+ continue;
311
+ }
312
+
313
+ const filePath = plugins.path.join(pnpmShimDir, entry.name);
314
+ let content = "";
315
+ try {
316
+ content = await plugins.fs.readFile(filePath, "utf8");
317
+ } catch {
318
+ continue;
319
+ }
320
+
321
+ if (
322
+ !legacyGlobalDirs.some((legacyGlobalDir) =>
323
+ content.includes(legacyGlobalDir),
324
+ )
325
+ ) {
326
+ continue;
327
+ }
328
+
329
+ try {
330
+ await plugins.fs.rm(filePath, { force: true });
331
+ results.push({
332
+ name: formatShimName(pnpmShimDir, entry.name),
333
+ action: "removed",
334
+ });
335
+ } catch (error) {
336
+ results.push({
337
+ name: formatShimName(pnpmShimDir, entry.name),
338
+ action: "skipped",
339
+ reason: (error as Error).message,
340
+ });
341
+ }
342
+ }
343
+ } catch (error) {
344
+ results.push({
345
+ name: pnpmShimDir,
346
+ action: "skipped",
347
+ reason: (error as Error).message,
348
+ });
349
+ }
350
+ }
351
+
352
+ return results;
87
353
  }
88
354
 
89
355
  public async getLatestVersion(
90
356
  packageName: string,
91
- registries = ["https://verdaccio.lossless.digital", "https://registry.npmjs.org"],
357
+ registries = [
358
+ "https://verdaccio.lossless.digital",
359
+ "https://registry.npmjs.org",
360
+ ],
92
361
  ): Promise<string | null> {
93
362
  for (const registry of registries) {
94
- const latest = await this.getLatestVersionFromRegistry(registry, packageName);
363
+ const latest = await this.getLatestVersionFromRegistry(
364
+ registry,
365
+ packageName,
366
+ );
95
367
  if (latest) {
96
368
  return latest;
97
369
  }
@@ -99,23 +371,60 @@ export class PackageManagerUtil {
99
371
  return null;
100
372
  }
101
373
 
102
- public async installLatest(packageName: string): Promise<boolean> {
103
- const packageSpecifier = `${packageName}@latest`;
374
+ public async installLatest(
375
+ packageName: string,
376
+ version = "latest",
377
+ ): Promise<boolean> {
378
+ const pnpmCommand = await this.getPnpmCommand();
379
+ if (!pnpmCommand) {
380
+ return false;
381
+ }
382
+
383
+ const packageSpecifier = `${packageName}@${version}`;
104
384
  console.log(` Installing ${packageSpecifier} via pnpm...`);
105
385
 
106
386
  try {
107
- const result = await this.shell.exec(`pnpm add -g ${shellQuote(packageSpecifier)}`);
387
+ const result = await this.shell.exec(
388
+ `${pnpmCommand} add -g ${shellQuote(packageSpecifier)}`,
389
+ );
108
390
  return result.exitCode === 0;
109
391
  } catch {
110
392
  return false;
111
393
  }
112
394
  }
113
395
 
396
+ public async updatePnpm(targetVersion: string): Promise<boolean> {
397
+ const pnpmCommand = await this.getPnpmCommand();
398
+ if (!pnpmCommand) {
399
+ return false;
400
+ }
401
+
402
+ const neutralDir = process.env.PNPM_HOME || "/tmp";
403
+
404
+ try {
405
+ const result = await this.shell.exec(
406
+ `${pnpmCommand} --dir ${shellQuote(neutralDir)} self-update ${shellQuote(targetVersion)}`,
407
+ );
408
+ this.pnpmCommand = undefined;
409
+ const currentVersion = await this.getCurrentPnpmVersion();
410
+ return (
411
+ result.exitCode === 0 &&
412
+ !this.isNewerVersion(currentVersion, targetVersion)
413
+ );
414
+ } catch {
415
+ return false;
416
+ }
417
+ }
418
+
114
419
  public isNewerVersion(current: string, latest: string): boolean {
115
420
  const currentParts = normalizeSemver(current);
116
421
  const latestParts = normalizeSemver(latest);
117
422
 
118
- for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
423
+ for (
424
+ let i = 0;
425
+ i < Math.max(currentParts.length, latestParts.length);
426
+ i++
427
+ ) {
119
428
  const currentPart = currentParts[i] || 0;
120
429
  const latestPart = latestParts[i] || 0;
121
430
  if (latestPart > currentPart) return true;
@@ -127,7 +436,10 @@ export class PackageManagerUtil {
127
436
 
128
437
  private async getCurrentPnpmVersion(): Promise<string> {
129
438
  try {
130
- const result = await this.shell.execSilent("pnpm --version 2>/dev/null");
439
+ const result = await this.execPnpmSilent("--version 2>/dev/null");
440
+ if (!result) {
441
+ return "unknown";
442
+ }
131
443
  const versionMatch = result.stdout.trim().match(/(\d+\.\d+\.\d+)/);
132
444
  return versionMatch?.[1] || "unknown";
133
445
  } catch {
@@ -135,6 +447,265 @@ export class PackageManagerUtil {
135
447
  }
136
448
  }
137
449
 
450
+ private async getPnpmCommand(): Promise<string | null> {
451
+ if (this.pnpmCommand !== undefined) {
452
+ return this.pnpmCommand;
453
+ }
454
+
455
+ const candidates = [
456
+ "pnpm --pm-on-fail=ignore",
457
+ "pnpm --config.manage-package-manager-versions=false",
458
+ "pnpm",
459
+ ];
460
+
461
+ for (const candidate of candidates) {
462
+ try {
463
+ const result = await this.shell.execSilent(
464
+ `${candidate} --version 2>/dev/null`,
465
+ );
466
+ if (result.exitCode === 0 && Boolean(result.stdout.trim())) {
467
+ this.pnpmCommand = candidate;
468
+ return this.pnpmCommand;
469
+ }
470
+ } catch {
471
+ // Try the next supported pnpm invocation form.
472
+ }
473
+ }
474
+
475
+ this.pnpmCommand = null;
476
+ return this.pnpmCommand;
477
+ }
478
+
479
+ private async execPnpmSilent(commandArgs: string): Promise<any | null> {
480
+ const pnpmCommand = await this.getPnpmCommand();
481
+ if (!pnpmCommand) {
482
+ return null;
483
+ }
484
+ return this.shell.execSilent(`${pnpmCommand} ${commandArgs}`);
485
+ }
486
+
487
+ private async getPnpmListProjects(): Promise<IPnpmListProject[]> {
488
+ try {
489
+ const result = await this.execPnpmSilent(
490
+ "list -g --depth=0 --json 2>/dev/null || true",
491
+ );
492
+ const output = result?.stdout.trim();
493
+ if (!output) {
494
+ return [];
495
+ }
496
+
497
+ const data = JSON.parse(output);
498
+ return Array.isArray(data) ? data : [data];
499
+ } catch {
500
+ return [];
501
+ }
502
+ }
503
+
504
+ private async getCurrentGlobalDir(): Promise<string | null> {
505
+ const listProjects = await this.getPnpmListProjects();
506
+ for (const listProject of listProjects) {
507
+ if (typeof listProject.path === "string" && listProject.path.length > 0) {
508
+ return normalizeGlobalDir(listProject.path);
509
+ }
510
+ }
511
+
512
+ try {
513
+ const result = await this.execPnpmSilent("root -g 2>/dev/null");
514
+ const output = result?.stdout.trim();
515
+ return output ? normalizeGlobalDir(output) : null;
516
+ } catch {
517
+ return null;
518
+ }
519
+ }
520
+
521
+ private async getCurrentInstalledPackages(): Promise<IInstalledPackage[]> {
522
+ const listProjects = await this.getPnpmListProjects();
523
+ const currentGlobalDir = await this.getCurrentGlobalDir();
524
+ const packageMap = new Map<string, IInstalledPackage>();
525
+
526
+ for (const listProject of listProjects) {
527
+ const globalDir = listProject.path
528
+ ? normalizeGlobalDir(listProject.path)
529
+ : currentGlobalDir || undefined;
530
+ const dependencies = listProject.dependencies || {};
531
+ for (const [name, info] of Object.entries(dependencies)) {
532
+ if (!name.startsWith("@git.zone/")) {
533
+ continue;
534
+ }
535
+ packageMap.set(name, {
536
+ name,
537
+ version: getDependencyVersion(info),
538
+ globalDir,
539
+ packagePath: getDependencyPackagePath(info),
540
+ legacy: false,
541
+ });
542
+ }
543
+ }
544
+
545
+ if (packageMap.size === 0 && currentGlobalDir) {
546
+ const currentRootInfo = await this.inspectGlobalRoot(
547
+ currentGlobalDir,
548
+ true,
549
+ );
550
+ for (const packageInfo of currentRootInfo.packages) {
551
+ packageMap.set(packageInfo.name, packageInfo);
552
+ }
553
+ }
554
+
555
+ return Array.from(packageMap.values());
556
+ }
557
+
558
+ private async inspectGlobalRoot(
559
+ globalDir: string,
560
+ current: boolean,
561
+ ): Promise<ILegacyGlobalRootInfo> {
562
+ const normalizedGlobalDir = normalizeGlobalDir(globalDir);
563
+ const rootPackageJson = await readJson(
564
+ plugins.path.join(normalizedGlobalDir, "package.json"),
565
+ );
566
+ const dependencies = getDependencyMap(rootPackageJson?.dependencies);
567
+ const packageMap = new Map<string, IInstalledPackage>();
568
+
569
+ for (const [name, spec] of Object.entries(dependencies)) {
570
+ if (!name.startsWith("@git.zone/")) {
571
+ continue;
572
+ }
573
+ packageMap.set(name, {
574
+ name,
575
+ version:
576
+ (await this.getInstalledPackageVersion(normalizedGlobalDir, name)) ||
577
+ normalizeDependencySpec(spec),
578
+ globalDir: normalizedGlobalDir,
579
+ packagePath: getPackagePath(normalizedGlobalDir, name),
580
+ legacy: !current,
581
+ });
582
+ }
583
+
584
+ const gitZoneScopeDir = plugins.path.join(
585
+ normalizedGlobalDir,
586
+ "node_modules",
587
+ "@git.zone",
588
+ );
589
+ try {
590
+ const entries = await plugins.fs.readdir(gitZoneScopeDir, {
591
+ withFileTypes: true,
592
+ });
593
+ for (const entry of entries) {
594
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) {
595
+ continue;
596
+ }
597
+ const name = `@git.zone/${entry.name}`;
598
+ packageMap.set(name, {
599
+ name,
600
+ version:
601
+ (await this.getInstalledPackageVersion(
602
+ normalizedGlobalDir,
603
+ name,
604
+ )) || "unknown",
605
+ globalDir: normalizedGlobalDir,
606
+ packagePath: getPackagePath(normalizedGlobalDir, name),
607
+ legacy: !current,
608
+ });
609
+ }
610
+ } catch {
611
+ // A pnpm global root may be empty or may not have node_modules materialized yet.
612
+ }
613
+
614
+ const dependencyNames = Object.keys(dependencies);
615
+ const unmanagedPackageNames = dependencyNames.filter(
616
+ (packageName) => !packageName.startsWith("@git.zone/"),
617
+ );
618
+
619
+ return {
620
+ globalDir: normalizedGlobalDir,
621
+ packages: Array.from(packageMap.values()).sort((packageA, packageB) =>
622
+ packageA.name.localeCompare(packageB.name),
623
+ ),
624
+ unmanagedPackageNames,
625
+ safeToDelete:
626
+ !current &&
627
+ dependencyNames.length > 0 &&
628
+ unmanagedPackageNames.length === 0,
629
+ };
630
+ }
631
+
632
+ private async getInstalledPackageVersion(
633
+ globalDir: string,
634
+ packageName: string,
635
+ ): Promise<string | null> {
636
+ const packageJson = await readJson(
637
+ plugins.path.join(
638
+ globalDir,
639
+ "node_modules",
640
+ ...packageName.split("/"),
641
+ "package.json",
642
+ ),
643
+ );
644
+ return typeof packageJson?.version === "string"
645
+ ? packageJson.version
646
+ : null;
647
+ }
648
+
649
+ private async getShimReferences(
650
+ legacyGlobalDir: string,
651
+ ): Promise<string[] | null> {
652
+ const pnpmShimDirs = await this.getPnpmShimDirs();
653
+ if (!pnpmShimDirs) {
654
+ return null;
655
+ }
656
+
657
+ const references: string[] = [];
658
+ for (const pnpmShimDir of pnpmShimDirs) {
659
+ try {
660
+ const entries = await plugins.fs.readdir(pnpmShimDir, {
661
+ withFileTypes: true,
662
+ });
663
+
664
+ for (const entry of entries) {
665
+ if (!entry.isFile()) {
666
+ continue;
667
+ }
668
+
669
+ const filePath = plugins.path.join(pnpmShimDir, entry.name);
670
+ try {
671
+ const content = await plugins.fs.readFile(filePath, "utf8");
672
+ if (content.includes(legacyGlobalDir)) {
673
+ references.push(formatShimName(pnpmShimDir, entry.name));
674
+ }
675
+ } catch {
676
+ // Ignore unreadable or non-text files in PNPM_HOME.
677
+ }
678
+ }
679
+ } catch {
680
+ return null;
681
+ }
682
+ }
683
+
684
+ return references;
685
+ }
686
+
687
+ private async getPnpmShimDirs(): Promise<string[] | null> {
688
+ const pnpmHome = process.env.PNPM_HOME;
689
+ if (!pnpmHome) {
690
+ return null;
691
+ }
692
+
693
+ const candidateDirs = [plugins.path.join(pnpmHome, "bin"), pnpmHome];
694
+ const shimDirs: string[] = [];
695
+ for (const candidateDir of candidateDirs) {
696
+ try {
697
+ const stat = await plugins.fs.stat(candidateDir);
698
+ if (stat.isDirectory()) {
699
+ shimDirs.push(normalizePath(candidateDir));
700
+ }
701
+ } catch {
702
+ // Ignore missing pnpm shim directories.
703
+ }
704
+ }
705
+
706
+ return shimDirs.length > 0 ? Array.from(new Set(shimDirs)) : null;
707
+ }
708
+
138
709
  private async getLatestVersionFromRegistry(
139
710
  registry: string,
140
711
  packageName: string,
@@ -164,6 +735,125 @@ export class PackageManagerUtil {
164
735
  }
165
736
  }
166
737
 
738
+ async function readJson(filePath: string): Promise<any | null> {
739
+ try {
740
+ const content = await plugins.fs.readFile(filePath, "utf8");
741
+ return JSON.parse(content);
742
+ } catch {
743
+ return null;
744
+ }
745
+ }
746
+
747
+ function getDependencyMap(value: unknown): Record<string, string> {
748
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
749
+ return {};
750
+ }
751
+ return value as Record<string, string>;
752
+ }
753
+
754
+ function getDependencyVersion(info: any): string {
755
+ if (info && typeof info === "object" && typeof info.version === "string") {
756
+ return info.version;
757
+ }
758
+ if (typeof info === "string") {
759
+ return normalizeDependencySpec(info);
760
+ }
761
+ return "unknown";
762
+ }
763
+
764
+ function getDependencyPackagePath(info: any): string | undefined {
765
+ return info && typeof info === "object" && typeof info.path === "string"
766
+ ? normalizePath(info.path)
767
+ : undefined;
768
+ }
769
+
770
+ function getPackagePath(globalDir: string, packageName: string): string {
771
+ return plugins.path.join(
772
+ globalDir,
773
+ "node_modules",
774
+ ...packageName.split("/"),
775
+ );
776
+ }
777
+
778
+ function getPackageBinNames(packageName: string, packageJson: any): string[] {
779
+ const bin = packageJson?.bin;
780
+ if (typeof bin === "string") {
781
+ return [getDefaultBinName(packageName)];
782
+ }
783
+ if (!bin || typeof bin !== "object" || Array.isArray(bin)) {
784
+ return [];
785
+ }
786
+ return Object.keys(bin)
787
+ .filter((binName) => binName.length > 0)
788
+ .sort();
789
+ }
790
+
791
+ function getDefaultBinName(packageName: string): string {
792
+ const packageNameParts = packageName.split("/");
793
+ return packageNameParts[packageNameParts.length - 1] || packageName;
794
+ }
795
+
796
+ function getNodeModulesDir(packagePath: string, packageName: string): string {
797
+ return packageName.startsWith("@")
798
+ ? plugins.path.dirname(plugins.path.dirname(packagePath))
799
+ : plugins.path.dirname(packagePath);
800
+ }
801
+
802
+ function rewriteShimForPnpmHome(content: string): string {
803
+ const targetMatch = content.match(/^# cmd-shim-target=(.+)$/m);
804
+ if (!targetMatch?.[1]) {
805
+ return content;
806
+ }
807
+
808
+ const absoluteTarget = `"${escapeDoubleQuotedShell(targetMatch[1])}"`;
809
+ return content.replace(
810
+ /"\$basedir\/(?:\.\.\/)+store\/[^"\n]+"/g,
811
+ absoluteTarget,
812
+ );
813
+ }
814
+
815
+ function escapeDoubleQuotedShell(value: string): string {
816
+ return value.replace(/["\\$`]/g, "\\$&");
817
+ }
818
+
819
+ function formatShimName(pnpmShimDir: string, binName: string): string {
820
+ const pnpmHome = process.env.PNPM_HOME;
821
+ if (!pnpmHome) {
822
+ return binName;
823
+ }
824
+
825
+ const relativeName = plugins.path.relative(
826
+ pnpmHome,
827
+ plugins.path.join(pnpmShimDir, binName),
828
+ );
829
+ return relativeName && !relativeName.startsWith("..")
830
+ ? relativeName
831
+ : binName;
832
+ }
833
+
834
+ function normalizeDependencySpec(spec: unknown): string {
835
+ if (typeof spec !== "string" || spec.length === 0) {
836
+ return "unknown";
837
+ }
838
+ const versionMatch = spec.match(/\d+\.\d+\.\d+(?:[-+][\w.-]+)?/);
839
+ return versionMatch?.[0] || spec;
840
+ }
841
+
842
+ function normalizeGlobalDir(globalDir: string): string {
843
+ const normalizedPath = normalizePath(globalDir);
844
+ return plugins.path.basename(normalizedPath) === "node_modules"
845
+ ? plugins.path.dirname(normalizedPath)
846
+ : normalizedPath;
847
+ }
848
+
849
+ function normalizePath(filePath: string): string {
850
+ return plugins.path.resolve(filePath).replace(/\/+$/, "");
851
+ }
852
+
853
+ function pathsAreEqual(pathA: string, pathB: string): boolean {
854
+ return normalizePath(pathA) === normalizePath(pathB);
855
+ }
856
+
167
857
  function normalizeSemver(version: string): number[] {
168
858
  return version
169
859
  .replace(/^[^\d]*/, "")