@git.zone/cli 2.22.0 → 2.24.0

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 (28) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/mod_services/classes.dockercontainer.d.ts +92 -0
  3. package/dist_ts/mod_services/classes.dockercontainer.js +225 -6
  4. package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -0
  5. package/dist_ts/mod_services/classes.globalregistry.js +23 -1
  6. package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +39 -1
  7. package/dist_ts/mod_services/classes.serviceconfiguration.js +89 -22
  8. package/dist_ts/mod_services/classes.servicedatamarker.d.ts +93 -0
  9. package/dist_ts/mod_services/classes.servicedatamarker.js +166 -0
  10. package/dist_ts/mod_services/classes.servicemanager.d.ts +103 -3
  11. package/dist_ts/mod_services/classes.servicemanager.js +352 -100
  12. package/dist_ts/mod_services/classes.servicepruner.d.ts +102 -0
  13. package/dist_ts/mod_services/classes.servicepruner.js +410 -0
  14. package/dist_ts/mod_services/helpers.d.ts +15 -0
  15. package/dist_ts/mod_services/helpers.js +57 -1
  16. package/dist_ts/mod_services/index.js +307 -53
  17. package/package.json +4 -3
  18. package/readme.hints.md +88 -0
  19. package/readme.md +71 -5
  20. package/ts/00_commitinfo_data.ts +1 -1
  21. package/ts/mod_services/classes.dockercontainer.ts +269 -9
  22. package/ts/mod_services/classes.globalregistry.ts +27 -0
  23. package/ts/mod_services/classes.serviceconfiguration.ts +105 -24
  24. package/ts/mod_services/classes.servicedatamarker.ts +228 -0
  25. package/ts/mod_services/classes.servicemanager.ts +480 -117
  26. package/ts/mod_services/classes.servicepruner.ts +532 -0
  27. package/ts/mod_services/helpers.ts +60 -0
  28. package/ts/mod_services/index.ts +437 -58
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@git.zone/cli",
3
3
  "private": false,
4
- "version": "2.22.0",
4
+ "version": "2.24.0",
5
5
  "description": "A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.",
6
6
  "main": "dist_ts/index.js",
7
7
  "typings": "dist_ts/index.d.ts",
@@ -40,7 +40,7 @@
40
40
  "homepage": "https://code.foss.global/git.zone/cli#README",
41
41
  "devDependencies": {
42
42
  "@git.zone/tsbuild": "^4.4.2",
43
- "@git.zone/tsrun": "^2.0.4",
43
+ "@git.zone/tsrun": "^2.0.5",
44
44
  "@git.zone/tstest": "^3.6.6",
45
45
  "@types/node": "^25.9.3"
46
46
  },
@@ -90,7 +90,8 @@
90
90
  "last 1 chrome versions"
91
91
  ],
92
92
  "scripts": {
93
- "test": "(pnpm run clean && pnpm run prepareTest && pnpm run testCli && pnpm run testFormat && pnpm run testCommit && pnpm run testDeprecate && pnpm run testVersion && pnpm run testReadme && pnpm run testUpdate && pnpm run testTemplateNpm && pnpm run testTemplateLit) && rm -rf .nogit/test",
93
+ "test": "(pnpm run testUnit && pnpm run clean && pnpm run prepareTest && pnpm run testCli && pnpm run testFormat && pnpm run testCommit && pnpm run testDeprecate && pnpm run testVersion && pnpm run testReadme && pnpm run testUpdate && pnpm run testTemplateNpm && pnpm run testTemplateLit) && rm -rf .nogit/test",
94
+ "testUnit": "tstest 'test.*.node.ts'",
94
95
  "build": "tsbuild tsfolders",
95
96
  "clean": "(rm -rf .nogit/test/)",
96
97
  "prepareTest": "(mkdir -p .nogit && git clone https://gitlab.com/sandboxzone/sandbox-npmts.git .nogit/test/)",
package/readme.hints.md CHANGED
@@ -127,6 +127,94 @@ The commit module now supports `-y/--yes` flag for non-interactive commits:
127
127
  - Fully backward compatible with interactive mode
128
128
  - CI/CD friendly for automated workflows
129
129
 
130
+ ## Services Module (`mod_services`) — Cleanup Design
131
+
132
+ ### Why data accumulated
133
+
134
+ `gitzone services clean` never actually worked for MongoDB or MinIO. Service
135
+ containers write as their own uid — mongod as 999 with `drwx------` on
136
+ subdirectories, MinIO as root under `.minio.sys` — so a recursive delete run as
137
+ the invoking user removed only the part of the tree it owned. For MongoDB the
138
+ result was a half-deleted WiredTiger dataset that crash-looped on the next start
139
+ (`WiredTigerHS.wt file is corrupted or missing`), kept alive indefinitely by
140
+ `restart: unless-stopped`. Users hit a failing `clean`, gave up, and the data
141
+ stayed. This was the root cause of multi-GB accumulation, not a missing feature.
142
+
143
+ `DockerContainer.removeDataDirectory` fixes it: try a native delete, and if the
144
+ directory survives, empty it from a short-lived `--user 0` container that mounts
145
+ only that directory. It verifies the directory is gone and throws otherwise — a
146
+ partial delete is worse than no delete.
147
+
148
+ ### Identifying tool-owned resources
149
+
150
+ Follows the `git.zone.*` label convention already established by
151
+ `@git.zone/tsdocker`. Containers created by `gitzone services` carry:
152
+
153
+ ```text
154
+ git.zone.tool=gitzone-services
155
+ git.zone.service=mongodb|minio|elasticsearch
156
+ git.zone.project-path=<abs path>
157
+ git.zone.data-path=<abs path>
158
+ git.zone.safe-to-prune=true
159
+ ```
160
+
161
+ Data directories are additionally claimed by a marker at
162
+ `<project>/.nogit/.gitzone-services.json`. The marker deliberately lives outside
163
+ the bind-mounted directory so it can never confuse mongod, MinIO or
164
+ Elasticsearch at runtime.
165
+
166
+ Nothing is ever matched by image or by bare name pattern. Containers predating
167
+ labels are only identified when exactly one registry entry claims their name.
168
+
169
+ ### Prune safety model (`classes.servicepruner.ts`)
170
+
171
+ Mirrors `TsDockerPruner`: build a plan, print it, and mutate only on `--apply`
172
+ with every item re-verified immediately before removal.
173
+
174
+ Projects classify as `live`, `stale`, `orphaned`, or `unknown`. Only `stale` and
175
+ `orphaned` are candidates; `unknown` never is. Ambiguity must *tighten* the
176
+ decision — a container name claimed by two projects forces both to `unknown`,
177
+ because an unattributable container also means "no container is running" cannot
178
+ be proven. Getting this backwards initially made 1.18 GB look reclaimable when
179
+ it was not.
180
+
181
+ Invariants, enforced at plan time and again at apply time:
182
+
183
+ - A data directory is never removed while any running container bind-mounts it
184
+ or an overlapping path (`helpers.pathsOverlap`).
185
+ - A path must match `<project>/.nogit/{mongodata,miniodata,esdata}` exactly
186
+ (`isSafeServiceDataPath`); this is an allowlist, checked after resolution.
187
+ - Ownership must be proven by marker or registry entry.
188
+ - Staleness is re-checked at apply time, so a project started between plan and
189
+ apply aborts the removal.
190
+ - An unreachable Docker daemon disables all container and data reclamation, so
191
+ ambiguity never reads as absence.
192
+
193
+ ### MongoDB auth modes
194
+
195
+ Default is authenticated, single-node replica set with a keyfile.
196
+ `gitzone services auth mongodb off` opts into a no-auth instance for runtimes
197
+ that cannot complete a SCRAM handshake over `node:crypto` (Deno). In that mode
198
+ the port is published on `127.0.0.1` only — publishing scope is the real
199
+ exposure control, since `--bind_ip_all` binds inside the container netns — and a
200
+ non-local `MONGODB_HOST` is refused. `MONGO_INITDB_ROOT_*` must be omitted in
201
+ no-auth mode because the official entrypoint turns them into `--auth`.
202
+
203
+ Re-enabling auth over data created without it finds no root user, because
204
+ `MONGO_INITDB_ROOT_*` only applies to an empty dbpath. `ensureMongoRootUser`
205
+ bootstraps it through MongoDB's localhost exception, which by design only works
206
+ while zero users exist and therefore cannot escalate anything.
207
+
208
+ ### Coupling: services config is a deployment input
209
+
210
+ `@git.zone/cli.services` is read by `@git.zone/tsdeploy`
211
+ (`deriveRequiredCapabilities` in `classes.cloudlydeployment.ts`) to derive a
212
+ workload's `requiredCapabilities`. It **throws** unless the value is a flat array
213
+ of unique, non-empty, lowercase canonical strings. Never add sub-keys under it
214
+ and never change the persisted names — turning it into an object would fail real
215
+ deployments. Any new services configuration belongs in `.nogit/env.json` or a
216
+ sibling key.
217
+
130
218
  ## Development Tips
131
219
 
132
220
  - Always check readme.plan.md for ongoing improvement plans
package/readme.md CHANGED
@@ -446,20 +446,86 @@ gitzone services set mongodb,minio
446
446
  # Check status
447
447
  gitzone services status
448
448
 
449
+ # Machine-readable status, including connection strings and data sizes
450
+ gitzone services status --json
451
+
449
452
  # Print MongoDB Compass connection string
450
453
  gitzone services compass
451
454
 
452
455
  # Show logs
453
456
  gitzone services logs mongo 50
457
+ ```
458
+
459
+ Service config is stored in `.nogit/env.json`. Data is stored below `.nogit/`, so it stays out of Git.
460
+
461
+ ### Consuming a service programmatically
454
462
 
455
- # Stop containers but keep data
456
- gitzone services stop
463
+ `gitzone services status --json` emits only JSON on stdout, so a test suite or
464
+ script can read a live connection string without parsing human output:
457
465
 
458
- # Remove containers and data
459
- gitzone services clean
466
+ ```bash
467
+ gitzone services status --json | jq -r '.services.mongodb.connectionString'
460
468
  ```
461
469
 
462
- Service config is stored in `.nogit/env.json`. Data is stored below `.nogit/`, so it stays out of Git.
470
+ ### Cleanup levels
471
+
472
+ Cleanup is tiered, from fully resumable to irreversible:
473
+
474
+ | Command | Containers | Data | Notes |
475
+ | --- | --- | --- | --- |
476
+ | `gitzone services stop` | kept (stopped) | kept | fully resumable |
477
+ | `gitzone services remove` | removed | kept | resumable; `--yes` to skip the prompt |
478
+ | `gitzone services clean` | removed | **removed** | irreversible; needs a typed `yes` or `--yes` |
479
+ | `gitzone services prune` | see below | see below | machine-wide; dry run unless `--apply` |
480
+
481
+ `clean` and `prune` remove data written by the container user (mongod runs as
482
+ uid 999, MinIO as root) by escalating to a short-lived privileged container
483
+ scoped to that one directory. They either remove a directory completely or fail
484
+ — a partial delete would leave a corrupt database behind.
485
+
486
+ ### Reclaiming space across projects
487
+
488
+ Service data is per project and survives container removal, so it accumulates.
489
+ `gitzone services prune` reports what every registered project holds and what
490
+ can be reclaimed. It is read-only unless `--apply` is passed:
491
+
492
+ ```bash
493
+ # Report only: what exists, what is reclaimable, and why
494
+ gitzone services prune
495
+
496
+ # Change the inactivity threshold (default 30 days)
497
+ gitzone services prune --stale-days 90
498
+
499
+ # Actually reclaim, non-interactively
500
+ gitzone services prune --apply --yes
501
+ ```
502
+
503
+ A project is only a candidate when there is positive evidence it is finished
504
+ with: its directory is gone, or it has been inactive past the threshold with no
505
+ container running. Anything ambiguous — an unlabeled container claimed by more
506
+ than one project, an unreachable Docker daemon, a directory still mounted by a
507
+ running container — is reported and skipped rather than reclaimed. Containers
508
+ are identified by the `git.zone.tool=gitzone-services` label or by an
509
+ unambiguous registry claim, never by image or name pattern, so pruning cannot
510
+ touch containers created by anything else.
511
+
512
+ ### MongoDB authentication
513
+
514
+ MongoDB runs as a single-node replica set with authentication enabled, so
515
+ multi-document transactions work. Authentication can be disabled per project for
516
+ runtimes whose `node:crypto` cannot complete a SCRAM handshake (notably Deno):
517
+
518
+ ```bash
519
+ gitzone services auth mongodb off
520
+ gitzone services start mongo
521
+ ```
522
+
523
+ This is opt-in and never implicit. With authentication disabled the database is
524
+ published on `127.0.0.1` only, and the combination of no authentication with a
525
+ non-local `MONGODB_HOST` is refused outright. Transactions continue to work, and
526
+ `gitzone services status` reports the mode. Re-enabling authentication over data
527
+ created without it bootstraps the configured root user through MongoDB's
528
+ localhost exception.
463
529
 
464
530
  ## Templates
465
531
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@git.zone/cli',
6
- version: '2.22.0',
6
+ version: '2.24.0',
7
7
  description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.'
8
8
  }
@@ -10,25 +10,57 @@ export interface IDockerRunOptions {
10
10
  ports?: { [key: string]: string };
11
11
  volumes?: { [key: string]: string };
12
12
  environment?: { [key: string]: string };
13
+ labels?: { [key: string]: string };
13
14
  restart?: string;
14
15
  command?: string;
15
16
  }
16
17
 
18
+ /** Container facts needed to decide, safely, whether a container may be removed. */
19
+ export interface IContainerInspectInfo {
20
+ id: string;
21
+ name: string;
22
+ state: string;
23
+ running: boolean;
24
+ labels: { [key: string]: string };
25
+ /** Resolved host-side sources of every bind mount. */
26
+ mountSources: string[];
27
+ }
28
+
29
+ /** Single-quote a value for safe interpolation into a bash command. */
30
+ export const shellQuote = (valueArg: string): string => `'${valueArg.replace(/'/g, `'"'"'`)}'`;
31
+
17
32
  export class DockerContainer {
18
33
  private smartshell: plugins.smartshell.Smartshell;
19
-
34
+ /**
35
+ * Wall-clock bound for docker queries and in-container commands.
36
+ *
37
+ * smartshell only arms a timeout when one is passed, so without this a wedged
38
+ * `docker exec` never settles and retry loops that look bounded ("30
39
+ * attempts") hang forever. Mirrors the `timeout Ns docker …` approach used by
40
+ * @git.zone/tsdocker.
41
+ */
42
+ private queryTimeoutSeconds = 60;
43
+ /** Longer bound for privileged cleanup, which may delete a large tree. */
44
+ private cleanupTimeoutSeconds = 600;
45
+
20
46
  constructor() {
21
47
  this.smartshell = new plugins.smartshell.Smartshell({
22
48
  executor: 'bash',
23
49
  });
24
50
  }
25
-
51
+
52
+ /** `docker` prefixed with a hard wall-clock bound. */
53
+ private bounded(seconds: number): string {
54
+ return `timeout ${seconds}s docker`;
55
+ }
56
+
26
57
  /**
27
58
  * Check if Docker is installed and available
28
59
  */
29
60
  public async checkDocker(): Promise<boolean> {
30
61
  try {
31
- const result = await this.smartshell.exec('docker --version');
62
+ // Silent: this must not print to stdout, which would corrupt --json output.
63
+ const result = await this.smartshell.execSilent('docker --version');
32
64
  return result.exitCode === 0;
33
65
  } catch (error) {
34
66
  return false;
@@ -120,17 +152,25 @@ export class DockerContainer {
120
152
  // Add volumes
121
153
  if (options.volumes) {
122
154
  for (const [hostPath, containerPath] of Object.entries(options.volumes)) {
123
- command += ` -v "${hostPath}:${containerPath}"`;
155
+ command += ` -v ${shellQuote(`${hostPath}:${containerPath}`)}`;
124
156
  }
125
157
  }
126
-
127
- // Add environment variables
158
+
159
+ // Add environment variables. Values come from .nogit/env.json, which is
160
+ // user-editable, so they must not be interpolated unquoted.
128
161
  if (options.environment) {
129
162
  for (const [key, value] of Object.entries(options.environment)) {
130
- command += ` -e ${key}="${value}"`;
163
+ command += ` -e ${shellQuote(`${key}=${value}`)}`;
131
164
  }
132
165
  }
133
-
166
+
167
+ // Add labels (used to identify tool-owned containers during prune)
168
+ if (options.labels) {
169
+ for (const [key, value] of Object.entries(options.labels)) {
170
+ command += ` --label ${shellQuote(`${key}=${value}`)}`;
171
+ }
172
+ }
173
+
134
174
  // Add restart policy
135
175
  if (options.restart) {
136
176
  command += ` --restart ${options.restart}`;
@@ -154,12 +194,124 @@ export class DockerContainer {
154
194
  }
155
195
  }
156
196
 
197
+ /**
198
+ * Run a one-off command in a temporary container (docker run --rm)
199
+ */
200
+ public async runOneOff(options: {
201
+ image: string;
202
+ command: string;
203
+ volumes?: { [key: string]: string };
204
+ user?: string;
205
+ /** Shell used inside the container. `sh` is available in every service image. */
206
+ shell?: string;
207
+ }): Promise<boolean> {
208
+ let command = `${this.bounded(this.cleanupTimeoutSeconds)} run --rm`;
209
+ if (options.user) {
210
+ command += ` --user ${shellQuote(options.user)}`;
211
+ }
212
+ if (options.volumes) {
213
+ for (const [hostPath, containerPath] of Object.entries(options.volumes)) {
214
+ // Quoted: this path reaches a root `rm -rf`, so a project directory
215
+ // containing `$(...)`, a backtick or a quote must not reach the shell
216
+ // unescaped. The path allowlist constrains shape, not metacharacters.
217
+ command += ` -v ${shellQuote(`${hostPath}:${containerPath}`)}`;
218
+ }
219
+ }
220
+ // The image entrypoint must be bypassed: service images start their daemon.
221
+ command += ` --entrypoint ${shellQuote(options.shell || 'bash')} ${shellQuote(options.image)}`;
222
+ command += ` -c ${shellQuote(options.command)}`;
223
+ try {
224
+ const result = await this.smartshell.exec(command);
225
+ return result.exitCode === 0;
226
+ } catch (error) {
227
+ const errorMessage = error instanceof Error ? error.message : String(error);
228
+ logger.log('error', `Failed to run one-off container: ${errorMessage}`);
229
+ return false;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * True when an image is already present locally.
235
+ */
236
+ public async imageExists(imageArg: string): Promise<boolean> {
237
+ try {
238
+ const result = await this.smartshell.execSilent(
239
+ `${this.bounded(this.queryTimeoutSeconds)} image inspect ${shellQuote(imageArg)}`,
240
+ );
241
+ return result.exitCode === 0;
242
+ } catch (error) {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Remove a service data directory, escalating to a root one-off container
249
+ * when the invoking user cannot delete the contents.
250
+ *
251
+ * Service containers write as their own uid (mongod as 999, MinIO as root)
252
+ * with restrictive modes, so a plain recursive delete only removes part of the
253
+ * tree. For MongoDB that left a half-deleted, corrupt WiredTiger dataset that
254
+ * crash-looped on the next start — so a partial delete is worse than none.
255
+ * This either fully removes the directory or throws.
256
+ *
257
+ * The one-off container mounts only the target directory, so `rm -rf` inside
258
+ * it cannot reach anything else on the host.
259
+ */
260
+ public async removeDataDirectory(hostPathArg: string, imageArg: string): Promise<void> {
261
+ const hostPath = plugins.path.resolve(hostPathArg);
262
+ if (!(await plugins.smartfs.directory(hostPath).exists())) {
263
+ return;
264
+ }
265
+
266
+ // Fast path: the current user may already own everything.
267
+ try {
268
+ await plugins.smartfs.directory(hostPath).recursive().delete();
269
+ } catch (error) {
270
+ // fall through to privileged removal
271
+ }
272
+ if (!(await plugins.smartfs.directory(hostPath).exists())) {
273
+ return;
274
+ }
275
+
276
+ if (!(await this.imageExists(imageArg))) {
277
+ throw new Error(
278
+ `Cannot remove ${hostPath}: its contents belong to the container user and image ` +
279
+ `${imageArg} is not available locally to perform a privileged cleanup. ` +
280
+ `Pull ${imageArg} and retry, or remove the directory manually as root.`,
281
+ );
282
+ }
283
+
284
+ // Empty the mounted directory from inside a root container, then remove the
285
+ // now-empty directory from the host.
286
+ const emptied = await this.runOneOff({
287
+ image: imageArg,
288
+ shell: 'sh',
289
+ command: 'rm -rf /gitzone-target/..?* /gitzone-target/.[!.]* /gitzone-target/*',
290
+ volumes: { [hostPath]: '/gitzone-target' },
291
+ user: '0',
292
+ });
293
+ if (!emptied) {
294
+ throw new Error(`Privileged cleanup of ${hostPath} failed`);
295
+ }
296
+
297
+ try {
298
+ await plugins.smartfs.directory(hostPath).recursive().delete();
299
+ } catch (error) {
300
+ const errorMessage = error instanceof Error ? error.message : String(error);
301
+ throw new Error(`Could not remove ${hostPath} after privileged cleanup: ${errorMessage}`);
302
+ }
303
+
304
+ if (await plugins.smartfs.directory(hostPath).exists()) {
305
+ throw new Error(`Refusing to report success: ${hostPath} still exists`);
306
+ }
307
+ }
308
+
157
309
  /**
158
310
  * Execute a command in a running container
159
311
  */
160
312
  public async exec(containerName: string, command: string): Promise<string> {
161
313
  try {
162
- const result = await this.smartshell.exec(`docker exec ${containerName} ${command}`);
314
+ const result = await this.smartshell.exec(`${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command}`);
163
315
  if (result.exitCode === 0) {
164
316
  return result.stdout;
165
317
  }
@@ -169,6 +321,28 @@ export class DockerContainer {
169
321
  }
170
322
  }
171
323
 
324
+ /**
325
+ * Execute a command in a running container, returning stdout and stderr
326
+ * together along with the exit code.
327
+ *
328
+ * `exec` discards output on a non-zero exit and drops stderr entirely, which
329
+ * hides diagnostics that a caller may need to branch on.
330
+ */
331
+ public async execCombined(
332
+ containerName: string,
333
+ command: string,
334
+ ): Promise<{ exitCode: number; output: string }> {
335
+ try {
336
+ const result = await this.smartshell.execSilent(
337
+ `${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command} 2>&1`,
338
+ );
339
+ return { exitCode: result.exitCode, output: result.stdout || '' };
340
+ } catch (error) {
341
+ const errorMessage = error instanceof Error ? error.message : String(error);
342
+ return { exitCode: -1, output: errorMessage };
343
+ }
344
+ }
345
+
172
346
  /**
173
347
  * Get container logs
174
348
  */
@@ -260,4 +434,90 @@ export class DockerContainer {
260
434
  return null;
261
435
  }
262
436
  }
437
+
438
+ /**
439
+ * Run a `docker ps` query and return matching container ids.
440
+ *
441
+ * Throws when Docker itself fails, so callers can fail closed instead of
442
+ * mistaking an unreachable daemon for "no containers exist".
443
+ */
444
+ public async listIds(filterArgs: string[], includeStopped: boolean = true): Promise<string[]> {
445
+ const filters = filterArgs.map((filter) => `--filter ${shellQuote(filter)}`).join(' ');
446
+ const allFlag = includeStopped ? '-a' : '';
447
+ const result = await this.smartshell.execSilent(
448
+ `${this.bounded(this.queryTimeoutSeconds)} ps ${allFlag} ${filters} --format '{{.ID}}'`,
449
+ );
450
+ if (result.exitCode !== 0) {
451
+ throw new Error(result.stderr || result.stdout || 'docker ps failed');
452
+ }
453
+ if (!result.stdout.trim()) {
454
+ return [];
455
+ }
456
+ return result.stdout
457
+ .trim()
458
+ .split(/\r?\n/)
459
+ .map((line) => line.trim())
460
+ .filter(Boolean);
461
+ }
462
+
463
+ /**
464
+ * Inspect many containers at once and normalise the parts prune relies on.
465
+ * Throws on Docker failure so ambiguity never reads as absence.
466
+ */
467
+ public async inspectMany(idsArg: string[]): Promise<IContainerInspectInfo[]> {
468
+ if (idsArg.length === 0) {
469
+ return [];
470
+ }
471
+ const result = await this.smartshell.execSilent(
472
+ `${this.bounded(this.queryTimeoutSeconds)} inspect ${idsArg.map((id) => shellQuote(id)).join(' ')}`,
473
+ );
474
+ if (result.exitCode !== 0) {
475
+ throw new Error(result.stderr || result.stdout || 'docker inspect failed');
476
+ }
477
+ let parsed: any;
478
+ try {
479
+ parsed = JSON.parse(result.stdout);
480
+ } catch (error) {
481
+ throw new Error('docker inspect returned unparseable output');
482
+ }
483
+ if (!Array.isArray(parsed)) {
484
+ throw new Error('docker inspect returned unexpected output');
485
+ }
486
+ return parsed.map((container: any) => {
487
+ const labels = (container?.Config?.Labels || {}) as { [key: string]: string };
488
+ const mountSources: string[] = [];
489
+ for (const mount of container?.Mounts || []) {
490
+ if (mount?.Source) {
491
+ mountSources.push(plugins.path.resolve(mount.Source));
492
+ }
493
+ }
494
+ return {
495
+ id: String(container?.Id || ''),
496
+ name: String(container?.Name || '').replace(/^\//, ''),
497
+ state: String(container?.State?.Status || 'unknown'),
498
+ running: container?.State?.Running === true,
499
+ labels,
500
+ mountSources,
501
+ };
502
+ });
503
+ }
504
+
505
+ /**
506
+ * Resolved host paths bind-mounted by every *currently running* container on
507
+ * the daemon, regardless of which tool created it.
508
+ *
509
+ * This is the primitive that makes data reclamation safe: a directory that is
510
+ * mounted by anything running is never a deletion candidate.
511
+ */
512
+ public async listRunningMountSources(): Promise<string[]> {
513
+ const ids = await this.listIds([], false);
514
+ const containers = await this.inspectMany(ids);
515
+ const sources = new Set<string>();
516
+ for (const container of containers) {
517
+ for (const source of container.mountSources) {
518
+ sources.add(source);
519
+ }
520
+ }
521
+ return [...sources];
522
+ }
263
523
  }
@@ -170,6 +170,33 @@ export class GlobalRegistry {
170
170
  return { stopped, failed };
171
171
  }
172
172
 
173
+ /**
174
+ * Cheap staleness summary for a non-blocking hint after ordinary commands.
175
+ *
176
+ * Deliberately filesystem-only: no Docker calls and no directory sizing, so
177
+ * it stays fast enough to run after every `start` or `status`.
178
+ */
179
+ public async getStaleSummary(
180
+ staleDaysArg: number,
181
+ ): Promise<{ orphaned: number; stale: number }> {
182
+ const projects = await this.getAllProjects();
183
+ const thresholdMs = staleDaysArg * 24 * 60 * 60 * 1000;
184
+ let orphaned = 0;
185
+ let stale = 0;
186
+
187
+ for (const [projectPath, project] of Object.entries(projects)) {
188
+ if (!(await plugins.smartfs.directory(projectPath).exists())) {
189
+ orphaned++;
190
+ continue;
191
+ }
192
+ if (Date.now() - project.lastActive > thresholdMs) {
193
+ stale++;
194
+ }
195
+ }
196
+
197
+ return { orphaned, stale };
198
+ }
199
+
173
200
  /**
174
201
  * Remove stale registry entries (projects that no longer exist on disk)
175
202
  */