@git.zone/cli 2.25.2 → 3.0.1

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 (34) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/mod_config/index.js +10 -3
  3. package/dist_ts/mod_docker/classes.dockerpruner.d.ts +87 -0
  4. package/dist_ts/mod_docker/classes.dockerpruner.js +262 -0
  5. package/dist_ts/mod_docker/index.d.ts +2 -0
  6. package/dist_ts/mod_docker/index.js +167 -6
  7. package/dist_ts/mod_format/index.js +8 -3
  8. package/dist_ts/mod_meta/meta.classes.meta.d.ts +11 -0
  9. package/dist_ts/mod_meta/meta.classes.meta.js +61 -8
  10. package/dist_ts/mod_services/classes.dockercontainer.d.ts +9 -0
  11. package/dist_ts/mod_services/classes.dockercontainer.js +48 -1
  12. package/dist_ts/mod_services/classes.globalregistry.d.ts +7 -1
  13. package/dist_ts/mod_services/classes.globalregistry.js +29 -7
  14. package/dist_ts/mod_services/classes.servicemanager.d.ts +6 -4
  15. package/dist_ts/mod_services/classes.servicemanager.js +42 -16
  16. package/dist_ts/mod_services/index.js +6 -3
  17. package/dist_ts/mod_standard/index.js +10 -2
  18. package/dist_ts/mod_tools/classes.packagemanager.js +22 -1
  19. package/package.json +1 -1
  20. package/readme.hints.md +30 -0
  21. package/readme.md +29 -4
  22. package/readme.plan.md +31 -0
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/mod_config/index.ts +15 -2
  25. package/ts/mod_docker/classes.dockerpruner.ts +334 -0
  26. package/ts/mod_docker/index.ts +231 -6
  27. package/ts/mod_format/index.ts +17 -2
  28. package/ts/mod_meta/meta.classes.meta.ts +82 -7
  29. package/ts/mod_services/classes.dockercontainer.ts +72 -0
  30. package/ts/mod_services/classes.globalregistry.ts +32 -7
  31. package/ts/mod_services/classes.servicemanager.ts +57 -16
  32. package/ts/mod_services/index.ts +9 -2
  33. package/ts/mod_standard/index.ts +9 -1
  34. package/ts/mod_tools/classes.packagemanager.ts +25 -0
@@ -100,8 +100,7 @@ export class Meta {
100
100
  `cd ${this.cwd} && git pull origin master`,
101
101
  );
102
102
  if (gitCleanArg) {
103
- logger.log('info', `cleaning the repository from old directories`);
104
- await this.smartshellInstance.exec(`cd ${this.cwd} && git clean -fd`);
103
+ await this.cleanUntrackedWithConsent();
105
104
  }
106
105
  logger.log('info', `syncing to remote origin master`);
107
106
  await this.smartshellInstance.exec(
@@ -109,6 +108,54 @@ export class Meta {
109
108
  );
110
109
  }
111
110
 
111
+ /**
112
+ * Remove untracked files from the meta repository, but only after showing
113
+ * exactly what would go and getting a yes.
114
+ *
115
+ * This used to be an unconditional `git clean -fd` on a code path that every
116
+ * meta subcommand reaches, including `meta update`. It deletes untracked,
117
+ * non-ignored files — which is a user's uncommitted scratch work, not the
118
+ * "old directories" the log line claimed, since cloned project directories
119
+ * are normally gitignored and `-fd` does not touch ignored paths.
120
+ */
121
+ private async cleanUntrackedWithConsent(): Promise<void> {
122
+ const dryRun = await this.smartshellInstance.execSilent(
123
+ `cd ${this.cwd} && git clean -nd`,
124
+ );
125
+ if (dryRun.exitCode !== 0) {
126
+ logger.log('note', 'could not determine untracked files; skipping cleanup');
127
+ return;
128
+ }
129
+
130
+ const candidates = (dryRun.stdout || '')
131
+ .trim()
132
+ .split(/\r?\n/)
133
+ .map((line) => line.replace(/^Would remove\s+/, '').trim())
134
+ .filter(Boolean);
135
+
136
+ if (candidates.length === 0) {
137
+ return;
138
+ }
139
+
140
+ logger.log('note', `${candidates.length} untracked path(s) in the meta repository:`);
141
+ for (const candidate of candidates) {
142
+ logger.log('info', ` ${candidate}`);
143
+ }
144
+
145
+ const shouldClean =
146
+ await plugins.smartinteract.SmartInteract.getCliConfirmation(
147
+ 'Delete these untracked paths?',
148
+ false,
149
+ );
150
+ if (!shouldClean) {
151
+ logger.log('note', 'keeping untracked paths');
152
+ return;
153
+ }
154
+
155
+ logger.log('info', 'cleaning the repository from untracked paths');
156
+ await this.smartshellInstance.exec(`cd ${this.cwd} && git clean -fd`);
157
+ }
158
+
112
159
  /**
113
160
  * update the locally cloned repositories
114
161
  */
@@ -299,17 +346,45 @@ export class Meta {
299
346
  return;
300
347
  }
301
348
 
349
+ // The name comes from .meta.json, which is just a file on disk: a key of
350
+ // `../something` would otherwise resolve outside the meta repo and be
351
+ // deleted. Require the target to be a direct child of cwd.
352
+ const targetDirectory = plugins.path.resolve(paths.cwd, projectNameArg);
353
+ const expectedParent = plugins.path.resolve(paths.cwd);
354
+ if (
355
+ plugins.path.dirname(targetDirectory) !== expectedParent ||
356
+ targetDirectory === expectedParent
357
+ ) {
358
+ throw new Error(
359
+ `Refusing to remove "${projectNameArg}": it does not resolve to a direct child of ${expectedParent}`,
360
+ );
361
+ }
362
+
363
+ // Deleting a clone can destroy uncommitted or unpushed work, so it is
364
+ // confirmed rather than assumed.
365
+ const targetExists = await plugins.smartfs.directory(targetDirectory).exists();
366
+ if (targetExists) {
367
+ const shouldContinue =
368
+ await plugins.smartinteract.SmartInteract.getCliConfirmation(
369
+ `Delete directory ${targetDirectory} and everything in it?`,
370
+ false,
371
+ );
372
+ if (!shouldContinue) {
373
+ logger.log('note', 'Cancelled — nothing was removed');
374
+ return;
375
+ }
376
+ }
377
+
302
378
  delete this.metaRepoData.projects[projectNameArg];
303
379
 
304
380
  logger.log('info', 'removing project from .meta.json');
305
381
  await this.sortMetaRepoData();
306
382
  await this.writeToDisk();
307
383
 
308
- logger.log('info', 'removing directory from cwd');
309
- await plugins.smartfs
310
- .directory(plugins.path.join(paths.cwd, projectNameArg))
311
- .recursive()
312
- .delete();
384
+ if (targetExists) {
385
+ logger.log('info', 'removing directory from cwd');
386
+ await plugins.smartfs.directory(targetDirectory).recursive().delete();
387
+ }
313
388
  await this.updateLocalRepos();
314
389
  }
315
390
  }
@@ -4,6 +4,15 @@ import { logger } from '../gitzone.logging.js';
4
4
 
5
5
  export type ContainerStatus = 'running' | 'stopped' | 'not_exists';
6
6
 
7
+ export interface IDockerUlimit {
8
+ soft: number;
9
+ hard: number;
10
+ }
11
+
12
+ export type TDockerUlimits = Record<string, IDockerUlimit>;
13
+
14
+ export type TDockerUlimitMatch = 'match' | 'mismatch' | 'unavailable';
15
+
7
16
  export interface IDockerRunOptions {
8
17
  name: string;
9
18
  image: string;
@@ -11,6 +20,7 @@ export interface IDockerRunOptions {
11
20
  volumes?: { [key: string]: string };
12
21
  environment?: { [key: string]: string };
13
22
  labels?: { [key: string]: string };
23
+ ulimits?: TDockerUlimits;
14
24
  restart?: string;
15
25
  command?: string;
16
26
  }
@@ -29,6 +39,66 @@ export interface IContainerInspectInfo {
29
39
  /** Single-quote a value for safe interpolation into a bash command. */
30
40
  export const shellQuote = (valueArg: string): string => `'${valueArg.replace(/'/g, `'"'"'`)}'`;
31
41
 
42
+ const assertDockerUlimit = (nameArg: string, limitArg: IDockerUlimit): void => {
43
+ if (!/^[a-z][a-z0-9_-]*$/u.test(nameArg)) {
44
+ throw new Error(`Invalid Docker ulimit name: ${nameArg}`);
45
+ }
46
+ if (
47
+ !Number.isSafeInteger(limitArg.soft) ||
48
+ !Number.isSafeInteger(limitArg.hard) ||
49
+ limitArg.soft <= 0 ||
50
+ limitArg.hard <= 0 ||
51
+ limitArg.hard < limitArg.soft
52
+ ) {
53
+ throw new Error(`Invalid Docker ulimit values for ${nameArg}`);
54
+ }
55
+ };
56
+
57
+ export const renderDockerUlimits = (ulimitsArg: TDockerUlimits = {}): string => {
58
+ let rendered = '';
59
+ for (const [name, limit] of Object.entries(ulimitsArg)) {
60
+ assertDockerUlimit(name, limit);
61
+ rendered += ` --ulimit ${shellQuote(`${name}=${limit.soft}:${limit.hard}`)}`;
62
+ }
63
+ return rendered;
64
+ };
65
+
66
+ export const matchDockerUlimits = (
67
+ inspectDataArg: unknown,
68
+ expectedArg: TDockerUlimits,
69
+ ): TDockerUlimitMatch => {
70
+ if (!Array.isArray(inspectDataArg) || inspectDataArg.length !== 1) {
71
+ return 'unavailable';
72
+ }
73
+ const container = inspectDataArg[0] as {
74
+ HostConfig?: {
75
+ Ulimits?: Array<{ Name?: unknown; Soft?: unknown; Hard?: unknown }> | null;
76
+ };
77
+ };
78
+ if (!container?.HostConfig) {
79
+ return 'unavailable';
80
+ }
81
+ if (!Object.hasOwn(container.HostConfig, 'Ulimits')) {
82
+ return 'unavailable';
83
+ }
84
+ const actual = container.HostConfig.Ulimits ?? [];
85
+ if (!Array.isArray(actual)) {
86
+ return 'unavailable';
87
+ }
88
+ for (const [name, expected] of Object.entries(expectedArg)) {
89
+ assertDockerUlimit(name, expected);
90
+ const matchingName = actual.filter((entry) => entry?.Name === name);
91
+ if (
92
+ matchingName.length !== 1 ||
93
+ matchingName[0].Soft !== expected.soft ||
94
+ matchingName[0].Hard !== expected.hard
95
+ ) {
96
+ return 'mismatch';
97
+ }
98
+ }
99
+ return 'match';
100
+ };
101
+
32
102
  export class DockerContainer {
33
103
  private smartshell: plugins.smartshell.Smartshell;
34
104
  /**
@@ -171,6 +241,8 @@ export class DockerContainer {
171
241
  }
172
242
  }
173
243
 
244
+ command += renderDockerUlimits(options.ulimits);
245
+
174
246
  // Add restart policy
175
247
  if (options.restart) {
176
248
  command += ` --restart ${options.restart}`;
@@ -200,18 +200,43 @@ export class GlobalRegistry {
200
200
  /**
201
201
  * Remove stale registry entries (projects that no longer exist on disk)
202
202
  */
203
- public async cleanup(): Promise<string[]> {
203
+ public async cleanup(): Promise<{ removed: string[]; kept: Array<{ projectPath: string; reason: string }> }> {
204
204
  const projects = await this.getAllProjects();
205
205
  const removed: string[] = [];
206
+ const kept: Array<{ projectPath: string; reason: string }> = [];
206
207
 
207
- for (const projectPath of Object.keys(projects)) {
208
- const exists = await plugins.smartfs.directory(projectPath).exists();
209
- if (!exists) {
210
- await this.unregisterProject(projectPath);
211
- removed.push(projectPath);
208
+ for (const [projectPath, project] of Object.entries(projects)) {
209
+ if (await plugins.smartfs.directory(projectPath).exists()) {
210
+ continue;
212
211
  }
212
+
213
+ // Same rule ServicePruner enforces: for containers created before labels
214
+ // existed, this registry entry is the only thing that can still identify
215
+ // them. Dropping it while one exists would hide the container from prune,
216
+ // `stop -g` and `cleanup -g` alike, leaving it running forever under
217
+ // `restart: unless-stopped`.
218
+ const survivingContainers: string[] = [];
219
+ for (const containerName of Object.values(project.containers)) {
220
+ if (!containerName) {
221
+ continue;
222
+ }
223
+ if ((await this.docker.getStatus(containerName)) !== 'not_exists') {
224
+ survivingContainers.push(containerName);
225
+ }
226
+ }
227
+
228
+ if (survivingContainers.length > 0) {
229
+ kept.push({
230
+ projectPath,
231
+ reason: `container(s) still exist: ${survivingContainers.join(', ')}`,
232
+ });
233
+ continue;
234
+ }
235
+
236
+ await this.unregisterProject(projectPath);
237
+ removed.push(projectPath);
213
238
  }
214
239
 
215
- return removed;
240
+ return { removed, kept };
216
241
  }
217
242
  }
@@ -1,7 +1,11 @@
1
1
  import * as plugins from './mod.plugins.js';
2
2
  import * as helpers from './helpers.js';
3
3
  import { ServiceConfiguration } from './classes.serviceconfiguration.js';
4
- import { DockerContainer } from './classes.dockercontainer.js';
4
+ import {
5
+ DockerContainer,
6
+ matchDockerUlimits,
7
+ type TDockerUlimits,
8
+ } from './classes.dockercontainer.js';
5
9
  import { GlobalRegistry } from './classes.globalregistry.js';
6
10
  import {
7
11
  getServiceContainerLabels,
@@ -44,6 +48,13 @@ export interface IServicesStatus {
44
48
  totalDataBytes: number;
45
49
  }
46
50
 
51
+ export const mongoServiceUlimits = {
52
+ nofile: {
53
+ soft: 65_536,
54
+ hard: 65_536,
55
+ },
56
+ } as const satisfies TDockerUlimits;
57
+
47
58
  export class ServiceManager {
48
59
  private config: ServiceConfiguration;
49
60
  private docker: DockerContainer;
@@ -297,24 +308,60 @@ export class ServiceManager {
297
308
 
298
309
  const status = await this.docker.getStatus(containers.mongo);
299
310
 
300
- // Containers created before replica-set support, with a changed port, or
301
- // running the other auth mode must be recreated; the data is preserved.
311
+ // Containers created before replica-set or managed-limit support, with a
312
+ // changed port, or running the other auth mode must be recreated; the data
313
+ // is preserved.
302
314
  let needsRecreate = false;
303
315
  if (status !== 'not_exists') {
304
- const containerCmd = await this.getMongoContainerCmd(containers.mongo);
316
+ const inspectInfo = await this.docker.inspect(containers.mongo);
317
+ if (!Array.isArray(inspectInfo) || inspectInfo.length !== 1) {
318
+ throw new Error(
319
+ `Unable to inspect MongoDB container ${containers.mongo}; refusing to recreate it.`,
320
+ );
321
+ }
322
+ const inspectedContainer = inspectInfo[0];
323
+ if (
324
+ !inspectedContainer?.Config ||
325
+ !Array.isArray(inspectedContainer.Config.Cmd) ||
326
+ !inspectedContainer.HostConfig ||
327
+ !Object.hasOwn(inspectedContainer.HostConfig, 'PortBindings')
328
+ ) {
329
+ throw new Error(
330
+ `Incomplete inspection for MongoDB container ${containers.mongo}; refusing to recreate it.`,
331
+ );
332
+ }
333
+ const containerCmd = inspectedContainer.Config.Cmd;
305
334
  const hasReplSet = containerCmd.includes('--replSet');
306
335
  const hasKeyFile = containerCmd.includes('--keyFile');
307
- const portMappings = await this.docker.getPortMappings(containers.mongo);
336
+ const mongoPortBindings =
337
+ inspectedContainer.HostConfig.PortBindings?.[`${config.MONGODB_PORT}/tcp`];
308
338
  const portMatches =
309
- !!portMappings && portMappings[config.MONGODB_PORT] === config.MONGODB_PORT;
339
+ Array.isArray(mongoPortBindings) &&
340
+ mongoPortBindings.length > 0 &&
341
+ mongoPortBindings.some(
342
+ (bindingArg: { HostPort?: string }) => bindingArg?.HostPort === config.MONGODB_PORT,
343
+ );
310
344
  const authMatches = hasKeyFile === this.config.isMongoAuthEnabled();
311
- needsRecreate = !hasReplSet || !portMatches || !authMatches;
345
+ const ulimitMatch = matchDockerUlimits(inspectInfo, mongoServiceUlimits);
346
+ if (ulimitMatch === 'unavailable') {
347
+ throw new Error(
348
+ `Unable to inspect MongoDB container limits for ${containers.mongo}; refusing to recreate it.`,
349
+ );
350
+ }
351
+ needsRecreate =
352
+ !hasReplSet ||
353
+ !portMatches ||
354
+ !authMatches ||
355
+ ulimitMatch === 'mismatch';
312
356
  if (!authMatches) {
313
357
  logger.log(
314
358
  'note',
315
359
  ` Auth mode changed to ${this.config.isMongoAuthEnabled() ? 'enabled' : 'disabled'}, recreating container...`,
316
360
  );
317
361
  }
362
+ if (ulimitMatch === 'mismatch') {
363
+ logger.log('note', ' MongoDB file-descriptor limit changed, recreating container...');
364
+ }
318
365
  }
319
366
 
320
367
  switch (status) {
@@ -323,7 +370,7 @@ export class ServiceManager {
323
370
  logger.log('ok', ' Already running ✓');
324
371
  break;
325
372
  }
326
- logger.log('note', ' Upgrading to single-node replica set, recreating container...');
373
+ logger.log('note', ' MongoDB service configuration changed, recreating container...');
327
374
  await this.docker.remove(containers.mongo, true);
328
375
  await this.createMongoContainer();
329
376
  break;
@@ -420,6 +467,7 @@ export class ServiceManager {
420
467
  },
421
468
  environment,
422
469
  labels,
470
+ ulimits: mongoServiceUlimits,
423
471
  restart: 'unless-stopped',
424
472
  command
425
473
  });
@@ -428,6 +476,7 @@ export class ServiceManager {
428
476
  logger.log('ok', ' Created and started ✓');
429
477
  } else {
430
478
  logger.log('error', ' Failed to create container');
479
+ throw new Error(`Failed to create MongoDB container ${containers.mongo}.`);
431
480
  }
432
481
  }
433
482
 
@@ -451,14 +500,6 @@ export class ServiceManager {
451
500
  });
452
501
  }
453
502
 
454
- /**
455
- * The mongod argv the container was created with
456
- */
457
- private async getMongoContainerCmd(containerName: string): Promise<string[]> {
458
- const info = await this.docker.inspect(containerName);
459
- return info?.[0]?.Config?.Cmd ?? [];
460
- }
461
-
462
503
  /**
463
504
  * Make sure the configured root user exists when auth is enabled.
464
505
  *
@@ -1162,10 +1162,17 @@ async function handleGlobalCleanup(globalRegistry: GlobalRegistry) {
1162
1162
  helpers.printHeader("Cleanup Registry (Global)");
1163
1163
 
1164
1164
  logger.log("note", "Checking for stale registry entries...");
1165
- const removed = await globalRegistry.cleanup();
1165
+ const { removed, kept } = await globalRegistry.cleanup();
1166
+
1167
+ for (const keptEntry of kept) {
1168
+ logger.log(
1169
+ "note",
1170
+ ` kept ${keptEntry.projectPath}: ${keptEntry.reason} — removing the entry would make them unidentifiable`,
1171
+ );
1172
+ }
1166
1173
 
1167
1174
  if (removed.length === 0) {
1168
- logger.log("ok", "No stale entries found");
1175
+ logger.log("ok", "No stale entries removed");
1169
1176
  return;
1170
1177
  }
1171
1178
 
@@ -26,7 +26,10 @@ const commandSummaries: ICommandHelpSummary[] = [
26
26
  { name: "tools", description: "Manage the global @git.zone toolchain" },
27
27
  { name: "template", description: "Create a project from a template" },
28
28
  { name: "open", description: "Open project assets and CI pages" },
29
- { name: "docker", description: "Run Docker-related maintenance tasks" },
29
+ {
30
+ name: "docker",
31
+ description: "Report and reclaim Docker resources created by git.zone tooling",
32
+ },
30
33
  {
31
34
  name: "deprecate",
32
35
  description: "Deprecate npm packages across registries",
@@ -245,6 +248,11 @@ async function showCommandHelp(
245
248
  modTools.showHelp(mode);
246
249
  return true;
247
250
  }
251
+ case "docker": {
252
+ const modDocker = await import("../mod_docker/index.js");
253
+ modDocker.showHelp(mode);
254
+ return true;
255
+ }
248
256
  default:
249
257
  return false;
250
258
  }
@@ -362,6 +362,26 @@ export class PackageManagerUtil {
362
362
  return results;
363
363
  }
364
364
 
365
+ // Only shims belonging to packages this tool manages may be removed.
366
+ // Pointing at a legacy root is not sufficient evidence of ownership: a
367
+ // legacy root retained precisely because it holds unmanaged packages also
368
+ // holds their shims, and deleting those silently breaks unrelated global
369
+ // CLIs that have nothing to do with @git.zone.
370
+ const managedLegacyBinNames = new Set<string>();
371
+ for (const legacyRoot of legacyRoots) {
372
+ for (const packageInfo of legacyRoot.packages) {
373
+ if (!packageInfo.packagePath) {
374
+ continue;
375
+ }
376
+ const packageJson = await readJson(
377
+ plugins.path.join(packageInfo.packagePath, "package.json"),
378
+ );
379
+ for (const binName of getPackageBinNames(packageInfo.name, packageJson)) {
380
+ managedLegacyBinNames.add(binName);
381
+ }
382
+ }
383
+ }
384
+
365
385
  for (const pnpmShimDir of pnpmShimDirs) {
366
386
  try {
367
387
  const entries = await plugins.fs.readdir(pnpmShimDir, {
@@ -372,6 +392,11 @@ export class PackageManagerUtil {
372
392
  continue;
373
393
  }
374
394
 
395
+ if (!managedLegacyBinNames.has(entry.name)) {
396
+ // Not a binary of a package this tool manages — leave it alone.
397
+ continue;
398
+ }
399
+
375
400
  const filePath = plugins.path.join(pnpmShimDir, entry.name);
376
401
  let content = "";
377
402
  try {