@git.zone/cli 2.23.0 → 2.25.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 (38) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/gitzone.cli.js +11 -2
  3. package/dist_ts/mod_services/classes.dockercontainer.d.ts +81 -0
  4. package/dist_ts/mod_services/classes.dockercontainer.js +205 -10
  5. package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -0
  6. package/dist_ts/mod_services/classes.globalregistry.js +23 -1
  7. package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +52 -1
  8. package/dist_ts/mod_services/classes.serviceconfiguration.js +116 -20
  9. package/dist_ts/mod_services/classes.servicedatamarker.d.ts +93 -0
  10. package/dist_ts/mod_services/classes.servicedatamarker.js +166 -0
  11. package/dist_ts/mod_services/classes.servicemanager.d.ts +91 -5
  12. package/dist_ts/mod_services/classes.servicemanager.js +274 -64
  13. package/dist_ts/mod_services/classes.serviceoptions.d.ts +58 -0
  14. package/dist_ts/mod_services/classes.serviceoptions.js +93 -0
  15. package/dist_ts/mod_services/classes.servicepruner.d.ts +102 -0
  16. package/dist_ts/mod_services/classes.servicepruner.js +410 -0
  17. package/dist_ts/mod_services/helpers.d.ts +15 -0
  18. package/dist_ts/mod_services/helpers.js +57 -1
  19. package/dist_ts/mod_services/index.js +307 -53
  20. package/dist_ts/mod_tools/classes.packagemanager.d.ts +13 -0
  21. package/dist_ts/mod_tools/classes.packagemanager.js +42 -1
  22. package/dist_ts/mod_tools/index.js +4 -1
  23. package/package.json +3 -2
  24. package/readme.hints.md +105 -0
  25. package/readme.md +123 -5
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/gitzone.cli.ts +10 -0
  28. package/ts/mod_services/classes.dockercontainer.ts +244 -13
  29. package/ts/mod_services/classes.globalregistry.ts +27 -0
  30. package/ts/mod_services/classes.serviceconfiguration.ts +148 -27
  31. package/ts/mod_services/classes.servicedatamarker.ts +228 -0
  32. package/ts/mod_services/classes.servicemanager.ts +394 -72
  33. package/ts/mod_services/classes.serviceoptions.ts +135 -0
  34. package/ts/mod_services/classes.servicepruner.ts +532 -0
  35. package/ts/mod_services/helpers.ts +60 -0
  36. package/ts/mod_services/index.ts +437 -58
  37. package/ts/mod_tools/classes.packagemanager.ts +54 -0
  38. package/ts/mod_tools/index.ts +5 -0
@@ -2,6 +2,20 @@ import * as plugins from './mod.plugins.js';
2
2
  import * as helpers from './helpers.js';
3
3
  import { logger } from '../gitzone.logging.js';
4
4
  import { DockerContainer } from './classes.dockercontainer.js';
5
+ import { getServiceDataDirectory } from './classes.servicedatamarker.js';
6
+ import {
7
+ readServiceOptions,
8
+ resolveMongoAuth,
9
+ writeMongodbAuthOption,
10
+ type TServiceOptionSource,
11
+ } from './classes.serviceoptions.js';
12
+
13
+ /** Hosts that are considered local for the purposes of the no-auth guard. */
14
+ const localMongoHosts = ['localhost', '127.0.0.1', '::1'];
15
+
16
+ export const isLocalMongoHost = (hostArg: string): boolean => {
17
+ return localMongoHosts.includes(hostArg.trim().toLowerCase());
18
+ };
5
19
 
6
20
  export interface IServiceConfig {
7
21
  PROJECT_NAME: string;
@@ -11,6 +25,12 @@ export interface IServiceConfig {
11
25
  MONGODB_USER: string;
12
26
  MONGODB_PASS: string;
13
27
  MONGODB_URL: string;
28
+ /**
29
+ * When false, mongod runs without authentication and is published on
30
+ * loopback only. Opt-in, for runtimes whose `node:crypto` cannot complete a
31
+ * SCRAM handshake (notably Deno). Defaults to true.
32
+ */
33
+ MONGODB_AUTH_ENABLED: boolean;
14
34
  S3_HOST: string;
15
35
  S3_PORT: string;
16
36
  S3_CONSOLE_PORT: string;
@@ -30,30 +50,60 @@ export class ServiceConfiguration {
30
50
  private configPath: string;
31
51
  private config!: IServiceConfig;
32
52
  private docker: DockerContainer;
33
-
53
+ /** Where the effective MongoDB auth mode came from. */
54
+ private mongoAuthSource: TServiceOptionSource = 'default';
55
+
34
56
  constructor() {
35
57
  this.configPath = plugins.path.join(process.cwd(), '.nogit', 'env.json');
36
58
  this.docker = new DockerContainer();
37
59
  }
38
-
60
+
39
61
  /**
40
62
  * Load or create the configuration
41
63
  */
42
64
  public async loadOrCreate(): Promise<IServiceConfig> {
43
65
  await this.ensureNogitDirectory();
44
-
66
+
45
67
  if (await this.configExists()) {
46
68
  await this.loadConfig();
47
69
  await this.updateMissingFields();
48
70
  } else {
49
71
  await this.createDefaultConfig();
50
72
  }
51
-
73
+
74
+ // A committed declaration in .smartconfig.json overrides the local runtime
75
+ // value, so a fresh checkout reproduces the declared setup.
76
+ await this.applyDeclaredServiceOptions();
77
+
52
78
  // Sync ports from existing Docker containers if they exist
53
79
  await this.syncPortsFromDocker();
54
-
80
+
55
81
  return this.config;
56
82
  }
83
+
84
+ /**
85
+ * Fold `@git.zone/cli.serviceOptions` into the runtime configuration.
86
+ */
87
+ private async applyDeclaredServiceOptions(): Promise<void> {
88
+ const declaredOptions = await readServiceOptions(process.cwd());
89
+ const resolved = resolveMongoAuth(declaredOptions, this.config.MONGODB_AUTH_ENABLED);
90
+ this.mongoAuthSource = resolved.source;
91
+
92
+ if (this.config.MONGODB_AUTH_ENABLED !== resolved.authEnabled) {
93
+ this.config.MONGODB_AUTH_ENABLED = resolved.authEnabled;
94
+ this.updateDerivedFields();
95
+ await this.saveConfig();
96
+ logger.log(
97
+ 'note',
98
+ `📍 MongoDB auth ${resolved.authEnabled ? 'enabled' : 'disabled'} by .smartconfig.json declaration`,
99
+ );
100
+ }
101
+ }
102
+
103
+ /** Where the effective MongoDB auth mode came from. */
104
+ public getMongoAuthSource(): TServiceOptionSource {
105
+ return this.mongoAuthSource;
106
+ }
57
107
 
58
108
  /**
59
109
  * Get the current configuration
@@ -61,6 +111,73 @@ export class ServiceConfiguration {
61
111
  public getConfig(): IServiceConfig {
62
112
  return this.config;
63
113
  }
114
+
115
+ /**
116
+ * Whether mongod should enforce authentication. Defaults to true; only an
117
+ * explicit `false` in `.nogit/env.json` disables it.
118
+ */
119
+ public isMongoAuthEnabled(): boolean {
120
+ return this.config.MONGODB_AUTH_ENABLED !== false;
121
+ }
122
+
123
+ /**
124
+ * Build MONGODB_URL from the current fields.
125
+ *
126
+ * Credentials are omitted entirely when auth is disabled, so the stored URL
127
+ * never advertises a username that mongod would not accept.
128
+ */
129
+ private buildMongoUrl(): string {
130
+ const credentials = this.isMongoAuthEnabled()
131
+ ? `${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@`
132
+ : '';
133
+ const authQuery = this.isMongoAuthEnabled() ? '?authSource=admin' : '';
134
+ return `mongodb://${credentials}${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}${authQuery}`;
135
+ }
136
+
137
+ /**
138
+ * Recompute every derived field from the primitive ones.
139
+ */
140
+ private updateDerivedFields(): void {
141
+ this.config.MONGODB_URL = this.buildMongoUrl();
142
+ this.config.S3_ENDPOINT = this.config.S3_HOST;
143
+ this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
144
+ }
145
+
146
+ /**
147
+ * Refuse an unauthenticated mongod that would be reachable off-host.
148
+ *
149
+ * Disabling auth is only defensible while the database is bound to loopback.
150
+ * Rather than trusting the operator to keep MONGODB_HOST local, this fails
151
+ * closed on the combination.
152
+ */
153
+ public assertMongoExposureIsSafe(): void {
154
+ if (this.isMongoAuthEnabled()) {
155
+ return;
156
+ }
157
+ if (!isLocalMongoHost(this.config.MONGODB_HOST)) {
158
+ throw new Error(
159
+ `Refusing to run MongoDB without authentication on non-local host "${this.config.MONGODB_HOST}". ` +
160
+ 'Set MONGODB_HOST to localhost in .nogit/env.json, or re-enable auth with ' +
161
+ '`gitzone services auth mongodb on`.',
162
+ );
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Persist the MongoDB auth mode.
168
+ *
169
+ * Written to both `.smartconfig.json` (the committed declaration, so a fresh
170
+ * checkout or CI reproduces it) and `.nogit/env.json` (the resolved runtime
171
+ * value consumed by tooling). Writing both keeps them from drifting.
172
+ */
173
+ public async setMongoAuthEnabled(enabledArg: boolean): Promise<void> {
174
+ this.config.MONGODB_AUTH_ENABLED = enabledArg;
175
+ this.updateDerivedFields();
176
+ this.assertMongoExposureIsSafe();
177
+ await this.saveConfig();
178
+ await writeMongodbAuthOption(enabledArg, process.cwd());
179
+ this.mongoAuthSource = enabledArg ? 'default' : 'declared';
180
+ }
64
181
 
65
182
  /**
66
183
  * Save the configuration to file
@@ -132,6 +249,7 @@ export class ServiceConfiguration {
132
249
  MONGODB_USER: mongoUser,
133
250
  MONGODB_PASS: mongoPass,
134
251
  MONGODB_URL: `mongodb://${mongoUser}:${mongoPass}@${mongoHost}:${mongoPortStr}/${mongoName}?authSource=admin`,
252
+ MONGODB_AUTH_ENABLED: true,
135
253
  S3_HOST: s3Host,
136
254
  S3_PORT: s3PortStr,
137
255
  S3_CONSOLE_PORT: s3ConsolePort.toString(),
@@ -202,9 +320,15 @@ export class ServiceConfiguration {
202
320
  updated = true;
203
321
  }
204
322
 
323
+ if (this.config.MONGODB_AUTH_ENABLED === undefined) {
324
+ this.config.MONGODB_AUTH_ENABLED = true;
325
+ fieldsAdded.push('MONGODB_AUTH_ENABLED');
326
+ updated = true;
327
+ }
328
+
205
329
  // Always update MONGODB_URL based on current settings
206
330
  const oldUrl = this.config.MONGODB_URL;
207
- this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`;
331
+ this.config.MONGODB_URL = this.buildMongoUrl();
208
332
  if (oldUrl !== this.config.MONGODB_URL) {
209
333
  fieldsAdded.push('MONGODB_URL');
210
334
  updated = true;
@@ -254,7 +378,9 @@ export class ServiceConfiguration {
254
378
  updated = true;
255
379
  }
256
380
 
257
- if (!this.config.S3_USESSL) {
381
+ // `undefined`, not falsy: a stored `false` is a valid value, and treating it
382
+ // as missing made every run report S3_USESSL as newly added.
383
+ if (this.config.S3_USESSL === undefined) {
258
384
  this.config.S3_USESSL = false;
259
385
  fieldsAdded.push('S3_USESSL');
260
386
  updated = true;
@@ -313,7 +439,11 @@ export class ServiceConfiguration {
313
439
  */
314
440
  public getMongoConnectionString(useNetworkIp: boolean = false): string {
315
441
  const host = useNetworkIp ? '${networkIp}' : this.config.MONGODB_HOST;
316
- return `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${host}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin&directConnection=true`;
442
+ const credentials = this.isMongoAuthEnabled()
443
+ ? `${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@`
444
+ : '';
445
+ const authQuery = this.isMongoAuthEnabled() ? 'authSource=admin&' : '';
446
+ return `mongodb://${credentials}${host}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?${authQuery}directConnection=true`;
317
447
  }
318
448
 
319
449
  /**
@@ -328,13 +458,16 @@ export class ServiceConfiguration {
328
458
  }
329
459
 
330
460
  /**
331
- * Get data directories
461
+ * Get data directories.
462
+ *
463
+ * Derived through `getServiceDataDirectory` so that creation here and
464
+ * reclamation in `ServicePruner` can never disagree about the shape.
332
465
  */
333
466
  public getDataDirectories() {
334
467
  return {
335
- mongo: plugins.path.join(process.cwd(), '.nogit', 'mongodata'),
336
- minio: plugins.path.join(process.cwd(), '.nogit', 'miniodata'),
337
- elasticsearch: plugins.path.join(process.cwd(), '.nogit', 'esdata')
468
+ mongo: getServiceDataDirectory(process.cwd(), 'mongodb'),
469
+ minio: getServiceDataDirectory(process.cwd(), 'minio'),
470
+ elasticsearch: getServiceDataDirectory(process.cwd(), 'elasticsearch')
338
471
  };
339
472
  }
340
473
 
@@ -400,11 +533,7 @@ export class ServiceConfiguration {
400
533
  }
401
534
 
402
535
  if (updated) {
403
- // Update derived fields
404
- this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`;
405
- this.config.S3_ENDPOINT = this.config.S3_HOST;
406
- this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
407
-
536
+ this.updateDerivedFields();
408
537
  await this.saveConfig();
409
538
  logger.log('ok', '✅ Configuration synced with Docker containers');
410
539
  }
@@ -470,11 +599,7 @@ export class ServiceConfiguration {
470
599
  }
471
600
 
472
601
  if (updated) {
473
- // Update derived fields
474
- this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`;
475
- this.config.S3_ENDPOINT = this.config.S3_HOST;
476
- this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
477
-
602
+ this.updateDerivedFields();
478
603
  await this.saveConfig();
479
604
  }
480
605
 
@@ -504,11 +629,7 @@ export class ServiceConfiguration {
504
629
  this.config.S3_CONSOLE_PORT = s3ConsolePort.toString();
505
630
  this.config.ELASTICSEARCH_PORT = esPort;
506
631
 
507
- // Update derived fields
508
- this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`;
509
- this.config.S3_ENDPOINT = this.config.S3_HOST;
510
- this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
511
-
632
+ this.updateDerivedFields();
512
633
  await this.saveConfig();
513
634
 
514
635
  logger.log('ok', '✅ New port configuration:');
@@ -0,0 +1,228 @@
1
+ import * as plugins from './mod.plugins.js';
2
+
3
+ /**
4
+ * Canonical service identifiers used across mod_services.
5
+ *
6
+ * These intentionally mirror the values stored in `@git.zone/cli.services`.
7
+ * That configuration array is a *deployment* input consumed by
8
+ * `@git.zone/tsdeploy` (`deriveRequiredCapabilities`), so the strings and the
9
+ * array shape must never change here.
10
+ */
11
+ export type TServiceName = 'mongodb' | 'minio' | 'elasticsearch';
12
+
13
+ export const serviceNames: TServiceName[] = ['mongodb', 'minio', 'elasticsearch'];
14
+
15
+ /** Owner recorded in markers and container labels. */
16
+ export const serviceDataOwner = '@git.zone/cli';
17
+
18
+ /** Value of the `git.zone.tool` label on containers created by `gitzone services`. */
19
+ export const serviceToolLabel = 'gitzone-services';
20
+
21
+ /** Marker file name, stored in `<projectPath>/.nogit/`. */
22
+ export const serviceDataMarkerFile = '.gitzone-services.json';
23
+
24
+ /**
25
+ * Directory basename used below `.nogit/` for each service's bind-mounted data.
26
+ * This mapping is the only shape `gitzone services` ever creates, and prune
27
+ * refuses to touch anything that does not match it exactly.
28
+ */
29
+ export const serviceDataDirectoryNames: { [key in TServiceName]: string } = {
30
+ mongodb: 'mongodata',
31
+ minio: 'miniodata',
32
+ elasticsearch: 'esdata',
33
+ };
34
+
35
+ /**
36
+ * Image used to run each service, and therefore the image available to perform
37
+ * a privileged cleanup of that service's data directory: if the data exists,
38
+ * the service ran, so its image is present locally.
39
+ */
40
+ export const serviceImages: { [key in TServiceName]: string } = {
41
+ mongodb: 'mongo:7.0',
42
+ minio: 'minio/minio',
43
+ elasticsearch: 'elasticsearch:8.11.0',
44
+ };
45
+
46
+ export interface IServiceDataMarkerEntry {
47
+ service: TServiceName;
48
+ path: string;
49
+ }
50
+
51
+ export interface IServiceDataMarker {
52
+ owner: string;
53
+ kind: 'service-data';
54
+ schemaVersion: 1;
55
+ projectPath: string;
56
+ safeToPrune: true;
57
+ createdAt: string;
58
+ dataDirectories: IServiceDataMarkerEntry[];
59
+ }
60
+
61
+ const isPlainObject = (value: unknown): value is Record<string, any> => {
62
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
63
+ };
64
+
65
+ /** Absolute path of the marker file for a project. */
66
+ export const getServiceDataMarkerPath = (projectPathArg: string): string => {
67
+ return plugins.path.join(plugins.path.resolve(projectPathArg), '.nogit', serviceDataMarkerFile);
68
+ };
69
+
70
+ /**
71
+ * Expected absolute data directory path for a service in a project.
72
+ * `mod_services` must always derive data paths through this helper so that
73
+ * creation and reclamation agree on a single shape.
74
+ */
75
+ export const getServiceDataDirectory = (
76
+ projectPathArg: string,
77
+ serviceArg: TServiceName,
78
+ ): string => {
79
+ return plugins.path.join(
80
+ plugins.path.resolve(projectPathArg),
81
+ '.nogit',
82
+ serviceDataDirectoryNames[serviceArg],
83
+ );
84
+ };
85
+
86
+ /**
87
+ * True only when `candidatePathArg` is exactly one of the three data
88
+ * directories `gitzone services` creates for `projectPathArg`.
89
+ *
90
+ * This is an allowlist, not a denylist: any path that is not byte-for-byte one
91
+ * of the expected resolved paths is rejected. It is the last line of defence
92
+ * before a recursive delete.
93
+ */
94
+ export const isSafeServiceDataPath = (
95
+ projectPathArg: string,
96
+ candidatePathArg: string,
97
+ ): boolean => {
98
+ const candidate = plugins.path.resolve(candidatePathArg);
99
+ for (const service of serviceNames) {
100
+ if (getServiceDataDirectory(projectPathArg, service) === candidate) {
101
+ return true;
102
+ }
103
+ }
104
+ return false;
105
+ };
106
+
107
+ /** Validate an unknown value as a usable marker. */
108
+ export const isValidServiceDataMarker = (markerArg: unknown): markerArg is IServiceDataMarker => {
109
+ if (!isPlainObject(markerArg)) {
110
+ return false;
111
+ }
112
+ if (markerArg.owner !== serviceDataOwner) {
113
+ return false;
114
+ }
115
+ if (markerArg.kind !== 'service-data') {
116
+ return false;
117
+ }
118
+ if (markerArg.schemaVersion !== 1) {
119
+ return false;
120
+ }
121
+ if (markerArg.safeToPrune !== true) {
122
+ return false;
123
+ }
124
+ if (typeof markerArg.projectPath !== 'string' || !markerArg.projectPath) {
125
+ return false;
126
+ }
127
+ if (!Array.isArray(markerArg.dataDirectories)) {
128
+ return false;
129
+ }
130
+ for (const entry of markerArg.dataDirectories) {
131
+ if (!isPlainObject(entry)) {
132
+ return false;
133
+ }
134
+ if (!serviceNames.includes(entry.service)) {
135
+ return false;
136
+ }
137
+ if (typeof entry.path !== 'string' || !entry.path) {
138
+ return false;
139
+ }
140
+ }
141
+ return true;
142
+ };
143
+
144
+ /** Read and validate the marker for a project, or undefined when absent/invalid. */
145
+ export const readServiceDataMarker = async (
146
+ projectPathArg: string,
147
+ ): Promise<IServiceDataMarker | undefined> => {
148
+ const markerPath = getServiceDataMarkerPath(projectPathArg);
149
+ try {
150
+ if (!(await plugins.smartfs.file(markerPath).exists())) {
151
+ return undefined;
152
+ }
153
+ const content = (await plugins.smartfs.file(markerPath).encoding('utf8').read()) as string;
154
+ const parsed = JSON.parse(content);
155
+ return isValidServiceDataMarker(parsed) ? parsed : undefined;
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ };
160
+
161
+ /**
162
+ * Record that `gitzone services` owns a data directory for this project.
163
+ *
164
+ * The marker lives in `.nogit/` rather than inside the bind-mounted data
165
+ * directory itself, so it can never confuse mongod, MinIO or Elasticsearch at
166
+ * runtime. Entries are merged so enabling a service later does not drop the
167
+ * claim on previously created directories.
168
+ */
169
+ export const recordServiceDataDirectory = async (
170
+ projectPathArg: string,
171
+ serviceArg: TServiceName,
172
+ ): Promise<void> => {
173
+ const projectPath = plugins.path.resolve(projectPathArg);
174
+ const dataPath = getServiceDataDirectory(projectPath, serviceArg);
175
+ const existingMarker = await readServiceDataMarker(projectPath);
176
+
177
+ const entries: IServiceDataMarkerEntry[] = existingMarker
178
+ ? existingMarker.dataDirectories.filter((entry) => entry.service !== serviceArg)
179
+ : [];
180
+ entries.push({ service: serviceArg, path: dataPath });
181
+ entries.sort((entryA, entryB) => entryA.service.localeCompare(entryB.service));
182
+
183
+ const marker: IServiceDataMarker = {
184
+ owner: serviceDataOwner,
185
+ kind: 'service-data',
186
+ schemaVersion: 1,
187
+ projectPath,
188
+ safeToPrune: true,
189
+ createdAt: existingMarker?.createdAt || new Date().toISOString(),
190
+ dataDirectories: entries,
191
+ };
192
+
193
+ const markerPath = getServiceDataMarkerPath(projectPath);
194
+ await plugins.smartfs.directory(plugins.path.dirname(markerPath)).recursive().create();
195
+ await plugins.smartfs
196
+ .file(markerPath)
197
+ .encoding('utf8')
198
+ .write(`${JSON.stringify(marker, null, 2)}\n`);
199
+ };
200
+
201
+ /**
202
+ * Labels applied to every container `gitzone services` creates.
203
+ *
204
+ * Mirrors the `git.zone.*` namespace already established by
205
+ * `@git.zone/tsdocker` so prune logic can identify tool-owned resources
206
+ * without ever matching on image or bare name.
207
+ */
208
+ export const getServiceContainerLabels = (optionsArg: {
209
+ projectPath: string;
210
+ service: TServiceName;
211
+ dataPath: string;
212
+ }): { [key: string]: string } => {
213
+ return {
214
+ 'git.zone.tool': serviceToolLabel,
215
+ 'git.zone.service': optionsArg.service,
216
+ 'git.zone.project-path': plugins.path.resolve(optionsArg.projectPath),
217
+ 'git.zone.data-path': plugins.path.resolve(optionsArg.dataPath),
218
+ 'git.zone.safe-to-prune': 'true',
219
+ };
220
+ };
221
+
222
+ /** True when a container's labels prove `gitzone services` created it. */
223
+ export const isServiceOwnedByLabels = (labelsArg: { [key: string]: string }): boolean => {
224
+ return (
225
+ labelsArg['git.zone.tool'] === serviceToolLabel &&
226
+ labelsArg['git.zone.safe-to-prune'] === 'true'
227
+ );
228
+ };