@git.zone/cli 2.23.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 +81 -0
  3. package/dist_ts/mod_services/classes.dockercontainer.js +205 -10
  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 +86 -20
  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 +82 -5
  11. package/dist_ts/mod_services/classes.servicemanager.js +258 -64
  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 +3 -2
  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 +244 -13
  22. package/ts/mod_services/classes.globalregistry.ts +27 -0
  23. package/ts/mod_services/classes.serviceconfiguration.ts +101 -22
  24. package/ts/mod_services/classes.servicedatamarker.ts +228 -0
  25. package/ts/mod_services/classes.servicemanager.ts +365 -72
  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
@@ -3,7 +3,43 @@ import * as helpers from './helpers.js';
3
3
  import { ServiceConfiguration } from './classes.serviceconfiguration.js';
4
4
  import { DockerContainer } from './classes.dockercontainer.js';
5
5
  import { GlobalRegistry } from './classes.globalregistry.js';
6
+ import {
7
+ getServiceContainerLabels,
8
+ recordServiceDataDirectory,
9
+ serviceImages,
10
+ type TServiceName,
11
+ } from './classes.servicedatamarker.js';
6
12
  import { logger } from '../gitzone.logging.js';
13
+ import type { ContainerStatus } from './classes.dockercontainer.js';
14
+
15
+ export interface IServiceStatus {
16
+ service: TServiceName;
17
+ enabled: boolean;
18
+ container: string;
19
+ status: ContainerStatus;
20
+ port: string;
21
+ connectionString: string;
22
+ dataDirectory: string;
23
+ dataSizeBytes: number;
24
+ /** Only meaningful when the container does not exist yet. */
25
+ portAvailable: boolean | null;
26
+ /** MongoDB only: whether the instance enforces authentication. */
27
+ authEnabled?: boolean;
28
+ }
29
+
30
+ export interface IServicesStatus {
31
+ project: {
32
+ name: string;
33
+ path: string;
34
+ enabledServices: string[];
35
+ };
36
+ services: {
37
+ mongodb: IServiceStatus;
38
+ minio: IServiceStatus;
39
+ elasticsearch: IServiceStatus;
40
+ };
41
+ totalDataBytes: number;
42
+ }
7
43
 
8
44
  export class ServiceManager {
9
45
  private config: ServiceConfiguration;
@@ -31,11 +67,32 @@ export class ServiceManager {
31
67
  await this.config.loadOrCreate();
32
68
  logger.log('info', `📋 Project: ${this.config.getConfig().PROJECT_NAME}`);
33
69
 
70
+ // The unsafe-exposure guard is deliberately NOT enforced here. init() runs
71
+ // for every project command, so refusing at this point would also block
72
+ // stop, remove, clean, status and logs — the commands needed to recover from
73
+ // the misconfiguration. It is enforced where it matters, on the path that
74
+ // actually starts an unauthenticated database.
75
+ if (!this.config.isMongoAuthEnabled()) {
76
+ logger.log('note', '⚠️ MongoDB auth is disabled for this project (loopback only)');
77
+ }
78
+
34
79
  // Load service selection from .smartconfig.json
35
80
  await this.loadServiceConfiguration();
36
81
 
37
82
  // Validate and update ports if needed
38
83
  await this.config.validateAndUpdatePorts();
84
+
85
+ // Any services command run in a project counts as activity. Without this
86
+ // lastActive only advanced on start, so an actively used project could look
87
+ // stale to the reaper.
88
+ await this.globalRegistry.touchProject(process.cwd());
89
+ }
90
+
91
+ /**
92
+ * Expose the resolved service configuration to the command layer.
93
+ */
94
+ public getConfiguration(): ServiceConfiguration {
95
+ return this.config;
39
96
  }
40
97
 
41
98
  /**
@@ -113,9 +170,32 @@ export class ServiceManager {
113
170
  }
114
171
 
115
172
  /**
116
- * Register this project with the global registry
173
+ * Prepare a service's data directory: create it and record tool ownership.
174
+ *
175
+ * The marker written here is what later allows `gitzone services prune` to
176
+ * prove the directory is reclaimable instead of guessing from its path.
117
177
  */
118
- private async registerWithGlobalRegistry(): Promise<void> {
178
+ private async prepareDataDirectory(
179
+ serviceArg: TServiceName,
180
+ dataPathArg: string,
181
+ ): Promise<{ [key: string]: string }> {
182
+ await plugins.smartfs.directory(dataPathArg).recursive().create();
183
+ await recordServiceDataDirectory(process.cwd(), serviceArg);
184
+ return getServiceContainerLabels({
185
+ projectPath: process.cwd(),
186
+ service: serviceArg,
187
+ dataPath: dataPathArg,
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Register this project with the global registry.
193
+ *
194
+ * Public and called from the command layer after any start path: previously
195
+ * only `startAll` registered, so `gitzone services start mongo` created a
196
+ * container the registry never knew about and prune could never account for.
197
+ */
198
+ public async registerWithGlobalRegistry(): Promise<void> {
119
199
  const config = this.config.getConfig();
120
200
  const containers = this.config.getContainerNames();
121
201
 
@@ -157,31 +237,22 @@ export class ServiceManager {
157
237
  await this.startElasticsearch();
158
238
  first = false;
159
239
  }
160
-
161
- // Register with global registry
162
- await this.registerWithGlobalRegistry();
163
240
  }
164
241
 
165
242
  /**
166
- * Stop all enabled services
243
+ * Stop every service container belonging to this project.
244
+ *
245
+ * Deliberately not filtered by the enabled-service list: disabling a service
246
+ * previously left its container running with no command able to stop it,
247
+ * which is how projects accumulated forgotten containers. Stopping a service
248
+ * that is not running is a no-op, so covering all three is always safe.
167
249
  */
168
250
  public async stopAll(): Promise<void> {
169
- let first = true;
170
- if (this.isServiceEnabled('mongodb')) {
171
- if (!first) console.log();
172
- await this.stopMongoDB();
173
- first = false;
174
- }
175
- if (this.isServiceEnabled('minio')) {
176
- if (!first) console.log();
177
- await this.stopMinIO();
178
- first = false;
179
- }
180
- if (this.isServiceEnabled('elasticsearch')) {
181
- if (!first) console.log();
182
- await this.stopElasticsearch();
183
- first = false;
184
- }
251
+ await this.stopMongoDB();
252
+ console.log();
253
+ await this.stopMinIO();
254
+ console.log();
255
+ await this.stopElasticsearch();
185
256
  }
186
257
 
187
258
  /**
@@ -199,20 +270,29 @@ export class ServiceManager {
199
270
  const containers = this.config.getContainerNames();
200
271
  const directories = this.config.getDataDirectories();
201
272
 
202
- // Ensure data directory exists
203
- await plugins.smartfs.directory(directories.mongo).recursive().create();
273
+ // Ensure data directory exists and is marked as tool-owned
274
+ await this.prepareDataDirectory('mongodb', directories.mongo);
204
275
 
205
276
  const status = await this.docker.getStatus(containers.mongo);
206
277
 
207
- // Containers created before replica-set support (or with a changed port)
208
- // must be recreated; the data volume is preserved.
278
+ // Containers created before replica-set support, with a changed port, or
279
+ // running the other auth mode must be recreated; the data is preserved.
209
280
  let needsRecreate = false;
210
281
  if (status !== 'not_exists') {
211
- const hasReplSet = await this.mongoContainerHasReplSet(containers.mongo);
282
+ const containerCmd = await this.getMongoContainerCmd(containers.mongo);
283
+ const hasReplSet = containerCmd.includes('--replSet');
284
+ const hasKeyFile = containerCmd.includes('--keyFile');
212
285
  const portMappings = await this.docker.getPortMappings(containers.mongo);
213
286
  const portMatches =
214
287
  !!portMappings && portMappings[config.MONGODB_PORT] === config.MONGODB_PORT;
215
- needsRecreate = !hasReplSet || !portMatches;
288
+ const authMatches = hasKeyFile === this.config.isMongoAuthEnabled();
289
+ needsRecreate = !hasReplSet || !portMatches || !authMatches;
290
+ if (!authMatches) {
291
+ logger.log(
292
+ 'note',
293
+ ` Auth mode changed to ${this.config.isMongoAuthEnabled() ? 'enabled' : 'disabled'}, recreating container...`,
294
+ );
295
+ }
216
296
  }
217
297
 
218
298
  switch (status) {
@@ -246,16 +326,23 @@ export class ServiceManager {
246
326
  break;
247
327
  }
248
328
 
329
+ await this.ensureMongoRootUser();
249
330
  await this.ensureMongoReplicaSetInitiated();
250
331
 
251
332
  logger.log('info', ` Container: ${containers.mongo}`);
252
333
  logger.log('info', ` Port: ${config.MONGODB_PORT}`);
334
+ if (!this.config.isMongoAuthEnabled()) {
335
+ logger.log('note', ' ⚠️ Auth: DISABLED — published on loopback only (127.0.0.1)');
336
+ }
253
337
  logger.log('info', ` Connection: ${this.config.getMongoConnectionString()}`);
254
338
 
255
- // Show Compass connection string
256
- const networkIp = await helpers.getLocalNetworkIp();
257
- const compassString = `mongodb://${config.MONGODB_USER}:${config.MONGODB_PASS}@${networkIp}:${config.MONGODB_PORT}/${config.MONGODB_NAME}?authSource=admin&directConnection=true`;
258
- logger.log('ok', ` Compass: ${compassString}`);
339
+ // Show Compass connection string. Only meaningful while the database is
340
+ // reachable off-host, which is exactly the auth-enabled case.
341
+ if (this.config.isMongoAuthEnabled()) {
342
+ const networkIp = await helpers.getLocalNetworkIp();
343
+ const compassString = `mongodb://${config.MONGODB_USER}:${config.MONGODB_PASS}@${networkIp}:${config.MONGODB_PORT}/${config.MONGODB_NAME}?authSource=admin&directConnection=true`;
344
+ logger.log('ok', ` Compass: ${compassString}`);
345
+ }
259
346
  }
260
347
 
261
348
  /**
@@ -265,25 +352,51 @@ export class ServiceManager {
265
352
  const config = this.config.getConfig();
266
353
  const containers = this.config.getContainerNames();
267
354
  const directories = this.config.getDataDirectories();
355
+ const authEnabled = this.config.isMongoAuthEnabled();
268
356
 
269
- await this.ensureMongoKeyfile(directories.mongo);
357
+ // Never create an unauthenticated instance that is reachable off-host.
358
+ this.config.assertMongoExposureIsSafe();
359
+
360
+ const labels = getServiceContainerLabels({
361
+ projectPath: process.cwd(),
362
+ service: 'mongodb',
363
+ dataPath: directories.mongo,
364
+ });
365
+
366
+ // Auth + replSet requires a keyfile. Without auth no keyfile is needed, and
367
+ // the root-user env vars must be omitted because the official entrypoint
368
+ // turns them into `--auth`.
369
+ const command = authEnabled
370
+ ? `--replSet rs0 --keyFile /data/db/mongo-keyfile --port ${config.MONGODB_PORT} --bind_ip_all`
371
+ : `--replSet rs0 --port ${config.MONGODB_PORT} --bind_ip_all`;
372
+ const environment: { [key: string]: string } = {
373
+ MONGO_INITDB_DATABASE: config.MONGODB_NAME,
374
+ };
375
+ if (authEnabled) {
376
+ environment.MONGO_INITDB_ROOT_USERNAME = config.MONGODB_USER;
377
+ environment.MONGO_INITDB_ROOT_PASSWORD = config.MONGODB_PASS;
378
+ }
379
+ // Publishing scope is the actual exposure control: mongod's --bind_ip_all
380
+ // only binds inside the container's netns.
381
+ const publishHost = authEnabled ? '0.0.0.0' : '127.0.0.1';
382
+
383
+ if (authEnabled) {
384
+ await this.ensureMongoKeyfile(directories.mongo);
385
+ }
270
386
 
271
387
  const success = await this.docker.run({
272
388
  name: containers.mongo,
273
389
  image: 'mongo:7.0',
274
390
  ports: {
275
- [`0.0.0.0:${config.MONGODB_PORT}`]: config.MONGODB_PORT
391
+ [`${publishHost}:${config.MONGODB_PORT}`]: config.MONGODB_PORT
276
392
  },
277
393
  volumes: {
278
394
  [directories.mongo]: '/data/db'
279
395
  },
280
- environment: {
281
- MONGO_INITDB_ROOT_USERNAME: config.MONGODB_USER,
282
- MONGO_INITDB_ROOT_PASSWORD: config.MONGODB_PASS,
283
- MONGO_INITDB_DATABASE: config.MONGODB_NAME
284
- },
396
+ environment,
397
+ labels,
285
398
  restart: 'unless-stopped',
286
- command: `--replSet rs0 --keyFile /data/db/mongo-keyfile --port ${config.MONGODB_PORT} --bind_ip_all`
399
+ command
287
400
  });
288
401
 
289
402
  if (success) {
@@ -314,12 +427,76 @@ export class ServiceManager {
314
427
  }
315
428
 
316
429
  /**
317
- * Check whether the container was created with --replSet
430
+ * The mongod argv the container was created with
318
431
  */
319
- private async mongoContainerHasReplSet(containerName: string): Promise<boolean> {
432
+ private async getMongoContainerCmd(containerName: string): Promise<string[]> {
320
433
  const info = await this.docker.inspect(containerName);
321
- const cmd: string[] = info?.[0]?.Config?.Cmd ?? [];
322
- return cmd.includes('--replSet');
434
+ return info?.[0]?.Config?.Cmd ?? [];
435
+ }
436
+
437
+ /**
438
+ * Make sure the configured root user exists when auth is enabled.
439
+ *
440
+ * `MONGO_INITDB_ROOT_USERNAME` only takes effect on an empty dbpath, so data
441
+ * that was first created with auth disabled has no user at all. Enabling auth
442
+ * over it would leave an unusable database. MongoDB's localhost exception is
443
+ * the designed bootstrap path for exactly this: while zero users exist, a
444
+ * connection from localhost — which, inside the container, is what this is —
445
+ * may create the first one. If users already exist the exception does not
446
+ * apply and creation fails, so this can never overwrite or escalate anything.
447
+ */
448
+ private async ensureMongoRootUser(): Promise<void> {
449
+ if (!this.config.isMongoAuthEnabled()) {
450
+ return;
451
+ }
452
+ const config = this.config.getConfig();
453
+ const containers = this.config.getContainerNames();
454
+
455
+ for (let attempt = 0; attempt < 30; attempt++) {
456
+ const probe = await this.docker.execCombined(
457
+ containers.mongo,
458
+ `mongosh --quiet --port ${config.MONGODB_PORT} ` +
459
+ `-u "${config.MONGODB_USER}" -p "${config.MONGODB_PASS}" --authenticationDatabase admin ` +
460
+ `--eval 'print("AUTH_OK")'`,
461
+ );
462
+ if (probe.output.includes('AUTH_OK')) {
463
+ return;
464
+ }
465
+ if (probe.output.includes('Authentication failed')) {
466
+ break;
467
+ }
468
+ // A missing or dead container will never become reachable; retrying 30
469
+ // times only delays a failure that is already terminal.
470
+ if (probe.output.includes('No such container') || probe.output.includes('is restarting')) {
471
+ throw new Error(
472
+ `MongoDB container "${containers.mongo}" is not running (${probe.output.trim()}). ` +
473
+ `Inspect it with \`gitzone services logs mongo 100\`.`,
474
+ );
475
+ }
476
+ // mongod not accepting connections yet
477
+ await plugins.smartdelay.delayFor(1000);
478
+ }
479
+
480
+ logger.log('note', ' No usable root user in existing data, bootstrapping...');
481
+ const createScript =
482
+ `db.getSiblingDB("admin").createUser({user: "${config.MONGODB_USER}", ` +
483
+ `pwd: "${config.MONGODB_PASS}", roles: [{role: "root", db: "admin"}]}); ` +
484
+ 'print("USER_CREATED");';
485
+ const create = await this.docker.execCombined(
486
+ containers.mongo,
487
+ `mongosh --quiet --port ${config.MONGODB_PORT} --eval '${createScript}'`,
488
+ );
489
+ if (create.output.includes('USER_CREATED')) {
490
+ logger.log('ok', ' Root user created ✓');
491
+ return;
492
+ }
493
+
494
+ throw new Error(
495
+ `MongoDB rejected the configured credentials for "${containers.mongo}" and the root user could not be ` +
496
+ 'bootstrapped (the localhost exception only applies while no users exist). Either correct ' +
497
+ 'MONGODB_USER/MONGODB_PASS in .nogit/env.json to match the existing data, or discard the data with ' +
498
+ '`gitzone services clean`.',
499
+ );
323
500
  }
324
501
 
325
502
  /**
@@ -333,9 +510,12 @@ export class ServiceManager {
333
510
  'if (e.codeName === "NotYetInitialized") { ' +
334
511
  `rs.initiate({_id: "rs0", members: [{_id: 0, host: "localhost:${config.MONGODB_PORT}"}]}); ` +
335
512
  'print("RS_INITIATED"); } else { print("RS_ERROR: " + e.message); } }';
513
+ const authArgs = this.config.isMongoAuthEnabled()
514
+ ? `-u "${config.MONGODB_USER}" -p "${config.MONGODB_PASS}" --authenticationDatabase admin `
515
+ : '';
336
516
  const command =
337
517
  `mongosh --quiet --port ${config.MONGODB_PORT} ` +
338
- `-u "${config.MONGODB_USER}" -p "${config.MONGODB_PASS}" --authenticationDatabase admin ` +
518
+ authArgs +
339
519
  `--eval '${evalScript}'`;
340
520
 
341
521
  for (let attempt = 0; attempt < 30; attempt++) {
@@ -363,9 +543,9 @@ export class ServiceManager {
363
543
  const containers = this.config.getContainerNames();
364
544
  const directories = this.config.getDataDirectories();
365
545
 
366
- // Ensure data directory exists
367
- await plugins.smartfs.directory(directories.minio).recursive().create();
368
-
546
+ // Ensure data directory exists and is marked as tool-owned
547
+ const minioLabels = await this.prepareDataDirectory('minio', directories.minio);
548
+
369
549
  const status = await this.docker.getStatus(containers.minio);
370
550
 
371
551
  switch (status) {
@@ -396,10 +576,11 @@ export class ServiceManager {
396
576
  MINIO_ROOT_USER: config.S3_ACCESSKEY,
397
577
  MINIO_ROOT_PASSWORD: config.S3_SECRETKEY
398
578
  },
579
+ labels: minioLabels,
399
580
  restart: 'unless-stopped',
400
581
  command: 'server /data --console-address ":9001"'
401
582
  });
402
-
583
+
403
584
  if (success) {
404
585
  logger.log('ok', ' Recreated with new ports ✓');
405
586
 
@@ -448,10 +629,11 @@ export class ServiceManager {
448
629
  MINIO_ROOT_USER: config.S3_ACCESSKEY,
449
630
  MINIO_ROOT_PASSWORD: config.S3_SECRETKEY
450
631
  },
632
+ labels: minioLabels,
451
633
  restart: 'unless-stopped',
452
634
  command: 'server /data --console-address ":9001"'
453
635
  });
454
-
636
+
455
637
  if (success) {
456
638
  logger.log('ok', ' Created and started ✓');
457
639
 
@@ -493,8 +675,8 @@ export class ServiceManager {
493
675
  const containers = this.config.getContainerNames();
494
676
  const directories = this.config.getDataDirectories();
495
677
 
496
- // Ensure data directory exists
497
- await plugins.smartfs.directory(directories.elasticsearch).recursive().create();
678
+ // Ensure data directory exists and is marked as tool-owned
679
+ const esLabels = await this.prepareDataDirectory('elasticsearch', directories.elasticsearch);
498
680
 
499
681
  const status = await this.docker.getStatus(containers.elasticsearch);
500
682
 
@@ -525,6 +707,7 @@ export class ServiceManager {
525
707
  'ELASTIC_PASSWORD': config.ELASTICSEARCH_PASS,
526
708
  'ES_JAVA_OPTS': '-Xms512m -Xmx512m'
527
709
  },
710
+ labels: esLabels,
528
711
  restart: 'unless-stopped'
529
712
  });
530
713
 
@@ -561,6 +744,7 @@ export class ServiceManager {
561
744
  'ELASTIC_PASSWORD': config.ELASTICSEARCH_PASS,
562
745
  'ES_JAVA_OPTS': '-Xms512m -Xmx512m'
563
746
  },
747
+ labels: esLabels,
564
748
  restart: 'unless-stopped'
565
749
  });
566
750
 
@@ -639,18 +823,88 @@ export class ServiceManager {
639
823
  }
640
824
  }
641
825
 
826
+ /**
827
+ * Collect service status as structured data.
828
+ *
829
+ * This is the machine-readable surface behind `gitzone services status
830
+ * --json`; consumers such as test suites can read a connection string from it
831
+ * without scraping human output or re-deriving it from `.nogit/env.json`.
832
+ */
833
+ public async collectStatus(): Promise<IServicesStatus> {
834
+ const config = this.config.getConfig();
835
+ const containers = this.config.getContainerNames();
836
+ const directories = this.config.getDataDirectories();
837
+
838
+ const describe = async (
839
+ service: TServiceName,
840
+ containerName: string,
841
+ dataPath: string,
842
+ port: string,
843
+ connectionString: string,
844
+ ): Promise<IServiceStatus> => {
845
+ const status = await this.docker.getStatus(containerName);
846
+ const dataExists = await plugins.smartfs.directory(dataPath).exists();
847
+ return {
848
+ service,
849
+ enabled: this.isServiceEnabled(service),
850
+ container: containerName,
851
+ status,
852
+ port,
853
+ connectionString,
854
+ dataDirectory: dataPath,
855
+ dataSizeBytes: dataExists ? await helpers.getDirectorySize(dataPath) : 0,
856
+ portAvailable:
857
+ status === 'not_exists' ? await helpers.isPortAvailable(parseInt(port)) : null,
858
+ };
859
+ };
860
+
861
+ const mongo = await describe(
862
+ 'mongodb',
863
+ containers.mongo,
864
+ directories.mongo,
865
+ config.MONGODB_PORT,
866
+ this.config.getMongoConnectionString(),
867
+ );
868
+ mongo.authEnabled = this.config.isMongoAuthEnabled();
869
+
870
+ const minio = await describe(
871
+ 'minio',
872
+ containers.minio,
873
+ directories.minio,
874
+ config.S3_PORT,
875
+ `http://${config.S3_HOST}:${config.S3_PORT}`,
876
+ );
877
+ const elasticsearch = await describe(
878
+ 'elasticsearch',
879
+ containers.elasticsearch,
880
+ directories.elasticsearch,
881
+ config.ELASTICSEARCH_PORT,
882
+ config.ELASTICSEARCH_URL,
883
+ );
884
+
885
+ return {
886
+ project: {
887
+ name: config.PROJECT_NAME,
888
+ path: process.cwd(),
889
+ enabledServices: this.enabledServices || [],
890
+ },
891
+ services: { mongodb: mongo, minio, elasticsearch },
892
+ totalDataBytes: mongo.dataSizeBytes + minio.dataSizeBytes + elasticsearch.dataSizeBytes,
893
+ };
894
+ }
895
+
642
896
  /**
643
897
  * Show service status
644
898
  */
645
899
  public async showStatus(): Promise<void> {
646
900
  helpers.printHeader('Service Status');
647
-
901
+
648
902
  const config = this.config.getConfig();
649
903
  const containers = this.config.getContainerNames();
650
-
904
+
651
905
  logger.log('info', `Project: ${config.PROJECT_NAME}`);
652
906
  console.log();
653
-
907
+
654
908
  // MongoDB status
655
909
  const mongoStatus = await this.docker.getStatus(containers.mongo);
656
910
  switch (mongoStatus) {
@@ -658,8 +912,11 @@ export class ServiceManager {
658
912
  logger.log('ok', '📦 MongoDB: 🟢 Running');
659
913
  logger.log('info', ` ├─ Container: ${containers.mongo}`);
660
914
  logger.log('info', ` ├─ Port: ${config.MONGODB_PORT}`);
915
+ if (!this.config.isMongoAuthEnabled()) {
916
+ logger.log('note', ' ├─ ⚠️ Auth: DISABLED (loopback only)');
917
+ }
661
918
  logger.log('info', ` ├─ Connection: ${this.config.getMongoConnectionString()}`);
662
-
919
+
663
920
  // Show Compass connection string
664
921
  const networkIp = await helpers.getLocalNetworkIp();
665
922
  const compassString = `mongodb://${config.MONGODB_USER}:${config.MONGODB_PASS}@${networkIp}:${config.MONGODB_PORT}/${config.MONGODB_NAME}?authSource=admin`;
@@ -752,6 +1009,44 @@ export class ServiceManager {
752
1009
  }
753
1010
  break;
754
1011
  }
1012
+
1013
+ await this.showDiskUsage();
1014
+ }
1015
+
1016
+ /**
1017
+ * Report on-disk size of this project's service data.
1018
+ *
1019
+ * Reporting comes before any deletion: knowing what a project holds is what
1020
+ * makes reclaiming it a decision rather than a guess.
1021
+ */
1022
+ public async showDiskUsage(): Promise<void> {
1023
+ const directories = this.config.getDataDirectories();
1024
+ const entries: Array<{ label: string; path: string }> = [
1025
+ { label: 'MongoDB', path: directories.mongo },
1026
+ { label: 'S3/MinIO', path: directories.minio },
1027
+ { label: 'Elasticsearch', path: directories.elasticsearch },
1028
+ ];
1029
+
1030
+ console.log();
1031
+ logger.log('note', 'Data on disk:');
1032
+ let total = 0;
1033
+ for (const entry of entries) {
1034
+ if (!(await plugins.smartfs.directory(entry.path).exists())) {
1035
+ continue;
1036
+ }
1037
+ const size = await helpers.getDirectorySize(entry.path);
1038
+ total += size;
1039
+ logger.log('info', ` ${entry.label}: ${helpers.formatBytes(size)} (${entry.path})`);
1040
+ }
1041
+ if (total === 0) {
1042
+ logger.log('info', ' No service data directories present');
1043
+ return;
1044
+ }
1045
+ logger.log('info', ` Total: ${helpers.formatBytes(total)}`);
1046
+ logger.log(
1047
+ 'note',
1048
+ ' Remove containers but keep data: `gitzone services remove`; remove data too: `gitzone services clean`',
1049
+ );
755
1050
  }
756
1051
 
757
1052
  /**
@@ -927,23 +1222,21 @@ export class ServiceManager {
927
1222
  */
928
1223
  public async cleanData(): Promise<void> {
929
1224
  const directories = this.config.getDataDirectories();
930
- let cleaned = false;
931
-
932
- if (await plugins.smartfs.directory(directories.mongo).exists()) {
933
- await plugins.smartfs.directory(directories.mongo).recursive().delete();
934
- logger.log('ok', ' MongoDB data removed ✓');
935
- cleaned = true;
936
- }
1225
+ const targets: Array<{ label: string; service: TServiceName; path: string }> = [
1226
+ { label: 'MongoDB', service: 'mongodb', path: directories.mongo },
1227
+ { label: 'S3/MinIO', service: 'minio', path: directories.minio },
1228
+ { label: 'Elasticsearch', service: 'elasticsearch', path: directories.elasticsearch },
1229
+ ];
937
1230
 
938
- if (await plugins.smartfs.directory(directories.minio).exists()) {
939
- await plugins.smartfs.directory(directories.minio).recursive().delete();
940
- logger.log('ok', ' S3/MinIO data removed ✓');
941
- cleaned = true;
942
- }
943
-
944
- if (await plugins.smartfs.directory(directories.elasticsearch).exists()) {
945
- await plugins.smartfs.directory(directories.elasticsearch).recursive().delete();
946
- logger.log('ok', ' Elasticsearch data removed ✓');
1231
+ let cleaned = false;
1232
+ for (const target of targets) {
1233
+ if (!(await plugins.smartfs.directory(target.path).exists())) {
1234
+ continue;
1235
+ }
1236
+ // Escalates to a privileged one-off container when needed; throws rather
1237
+ // than leaving a partially deleted (and for MongoDB, corrupt) directory.
1238
+ await this.docker.removeDataDirectory(target.path, serviceImages[target.service]);
1239
+ logger.log('ok', ` ${target.label} data removed ✓`);
947
1240
  cleaned = true;
948
1241
  }
949
1242