@git.zone/cli 2.24.0 → 2.25.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.
package/readme.md CHANGED
@@ -527,6 +527,58 @@ non-local `MONGODB_HOST` is refused outright. Transactions continue to work, and
527
527
  created without it bootstraps the configured root user through MongoDB's
528
528
  localhost exception.
529
529
 
530
+ The setting is recorded in `.smartconfig.json`, so it is committed and a fresh
531
+ clone or CI run reproduces it without any manual step:
532
+
533
+ ```json
534
+ {
535
+ "@git.zone/cli": {
536
+ "services": ["mongodb"],
537
+ "serviceOptions": {
538
+ "mongodb": { "auth": false }
539
+ }
540
+ }
541
+ }
542
+ ```
543
+
544
+ `serviceOptions` is a **sibling** of `services`, never a richer `services`
545
+ value. `services` must stay a flat array of canonical lowercase strings because
546
+ `@git.zone/tsdeploy` derives a workload's `requiredCapabilities` from it and
547
+ rejects any other shape.
548
+
549
+ A committed declaration takes precedence over `.nogit/env.json`, so a stale
550
+ local file cannot silently diverge from what the repository declares. When
551
+ nothing is declared, an existing local value is preserved. When neither exists,
552
+ authentication is enabled. Because a declaration affects everyone who clones the
553
+ repository, `services status` states where the setting came from:
554
+
555
+ ```text
556
+ ⚠️ Auth: DISABLED (loopback only), declared in .smartconfig.json (applies to every checkout)
557
+ ```
558
+
559
+ An older CLI that predates `serviceOptions` ignores the key and starts MongoDB
560
+ with authentication enabled — it degrades to the secure default, never the
561
+ insecure one.
562
+
563
+ ### Checking which version is running
564
+
565
+ `gitzone --version` prints the bare version on the first line, followed by the
566
+ path it resolved from. A stale copy in a legacy pnpm global root can otherwise
567
+ make it look like an older version is installed when it is not:
568
+
569
+ ```bash
570
+ gitzone --version
571
+ # 2.25.0
572
+ # resolved from: /home/you/.local/share/pnpm/store/v11/links/@git.zone/cli/2.25.0/…
573
+
574
+ gitzone --version --json
575
+ # {"version":"2.25.0","resolvedFrom":"…"}
576
+ ```
577
+
578
+ `gitzone tools update` also removes inert copies of managed packages left behind
579
+ in legacy global roots, provided the active root already supplies them and no
580
+ command shim still points there.
581
+
530
582
  ## Templates
531
583
 
532
584
  Start new projects with built-in scaffolds:
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@git.zone/cli',
6
- version: '2.24.0',
6
+ version: '2.25.1',
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
  }
package/ts/gitzone.cli.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  getProcessUserArgv,
5
5
  getRawCliMode,
6
6
  parseCliArgv,
7
+ printJson,
7
8
  } from "./helpers.climode.js";
8
9
  import { commitinfo } from "./00_commitinfo_data.js";
9
10
 
@@ -130,7 +131,16 @@ export let run = async () => {
130
131
  }
131
132
  const argvArg = parseCliArgv(getProcessUserArgv());
132
133
  if (argvArg.v || argvArg.version) {
134
+ if (rawCliMode.output === "json") {
135
+ printJson({ version: packageVersion, resolvedFrom: paths.packageDir });
136
+ return;
137
+ }
138
+ // The bare version stays on its own line so `gitzone --version` remains
139
+ // parseable by scripts. The resolved path is added underneath because a
140
+ // stale copy in a legacy pnpm global root can otherwise make people
141
+ // conclude they are running a version they are not.
133
142
  console.log(packageVersion);
143
+ console.log(`resolved from: ${paths.packageDir}`);
134
144
  return;
135
145
  }
136
146
  await runParsedCommand(argvArg);
@@ -3,6 +3,12 @@ import * as helpers from './helpers.js';
3
3
  import { logger } from '../gitzone.logging.js';
4
4
  import { DockerContainer } from './classes.dockercontainer.js';
5
5
  import { getServiceDataDirectory } from './classes.servicedatamarker.js';
6
+ import {
7
+ readServiceOptions,
8
+ resolveMongoAuth,
9
+ writeMongodbAuthOption,
10
+ type TServiceOptionSource,
11
+ } from './classes.serviceoptions.js';
6
12
 
7
13
  /** Hosts that are considered local for the purposes of the no-auth guard. */
8
14
  const localMongoHosts = ['localhost', '127.0.0.1', '::1'];
@@ -44,30 +50,60 @@ export class ServiceConfiguration {
44
50
  private configPath: string;
45
51
  private config!: IServiceConfig;
46
52
  private docker: DockerContainer;
47
-
53
+ /** Where the effective MongoDB auth mode came from. */
54
+ private mongoAuthSource: TServiceOptionSource = 'default';
55
+
48
56
  constructor() {
49
57
  this.configPath = plugins.path.join(process.cwd(), '.nogit', 'env.json');
50
58
  this.docker = new DockerContainer();
51
59
  }
52
-
60
+
53
61
  /**
54
62
  * Load or create the configuration
55
63
  */
56
64
  public async loadOrCreate(): Promise<IServiceConfig> {
57
65
  await this.ensureNogitDirectory();
58
-
66
+
59
67
  if (await this.configExists()) {
60
68
  await this.loadConfig();
61
69
  await this.updateMissingFields();
62
70
  } else {
63
71
  await this.createDefaultConfig();
64
72
  }
65
-
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
+
66
78
  // Sync ports from existing Docker containers if they exist
67
79
  await this.syncPortsFromDocker();
68
-
80
+
69
81
  return this.config;
70
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
+ }
71
107
 
72
108
  /**
73
109
  * Get the current configuration
@@ -128,13 +164,19 @@ export class ServiceConfiguration {
128
164
  }
129
165
 
130
166
  /**
131
- * Persist the MongoDB auth mode and recompute derived fields.
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.
132
172
  */
133
173
  public async setMongoAuthEnabled(enabledArg: boolean): Promise<void> {
134
174
  this.config.MONGODB_AUTH_ENABLED = enabledArg;
135
175
  this.updateDerivedFields();
136
176
  this.assertMongoExposureIsSafe();
137
177
  await this.saveConfig();
178
+ await writeMongodbAuthOption(enabledArg, process.cwd());
179
+ this.mongoAuthSource = enabledArg ? 'default' : 'declared';
138
180
  }
139
181
 
140
182
  /**
@@ -11,6 +11,7 @@ import {
11
11
  } from './classes.servicedatamarker.js';
12
12
  import { logger } from '../gitzone.logging.js';
13
13
  import type { ContainerStatus } from './classes.dockercontainer.js';
14
+ import type { TServiceOptionSource } from './classes.serviceoptions.js';
14
15
 
15
16
  export interface IServiceStatus {
16
17
  service: TServiceName;
@@ -25,6 +26,8 @@ export interface IServiceStatus {
25
26
  portAvailable: boolean | null;
26
27
  /** MongoDB only: whether the instance enforces authentication. */
27
28
  authEnabled?: boolean;
29
+ /** MongoDB only: whether the auth mode is declared in committed config. */
30
+ authSource?: TServiceOptionSource;
28
31
  }
29
32
 
30
33
  export interface IServicesStatus {
@@ -73,7 +76,10 @@ export class ServiceManager {
73
76
  // the misconfiguration. It is enforced where it matters, on the path that
74
77
  // actually starts an unauthenticated database.
75
78
  if (!this.config.isMongoAuthEnabled()) {
76
- logger.log('note', '⚠️ MongoDB auth is disabled for this project (loopback only)');
79
+ logger.log(
80
+ 'note',
81
+ `⚠️ MongoDB auth is disabled for this project (loopback only) — ${this.describeMongoAuthSource()}`,
82
+ );
77
83
  }
78
84
 
79
85
  // Load service selection from .smartconfig.json
@@ -88,6 +94,22 @@ export class ServiceManager {
88
94
  await this.globalRegistry.touchProject(process.cwd());
89
95
  }
90
96
 
97
+ /**
98
+ * Human phrasing for where the auth mode came from. A declared auth-off is
99
+ * committed to the repository and therefore affects everyone who clones it,
100
+ * so it must never read the same as a local-only choice.
101
+ */
102
+ private describeMongoAuthSource(): string {
103
+ switch (this.config.getMongoAuthSource()) {
104
+ case 'declared':
105
+ return 'declared in .smartconfig.json (applies to every checkout)';
106
+ case 'local':
107
+ return 'set locally in .nogit/env.json';
108
+ default:
109
+ return 'default';
110
+ }
111
+ }
112
+
91
113
  /**
92
114
  * Expose the resolved service configuration to the command layer.
93
115
  */
@@ -332,7 +354,10 @@ export class ServiceManager {
332
354
  logger.log('info', ` Container: ${containers.mongo}`);
333
355
  logger.log('info', ` Port: ${config.MONGODB_PORT}`);
334
356
  if (!this.config.isMongoAuthEnabled()) {
335
- logger.log('note', ' ⚠️ Auth: DISABLED — published on loopback only (127.0.0.1)');
357
+ logger.log(
358
+ 'note',
359
+ ` ⚠️ Auth: DISABLED — published on loopback only (127.0.0.1), ${this.describeMongoAuthSource()}`,
360
+ );
336
361
  }
337
362
  logger.log('info', ` Connection: ${this.config.getMongoConnectionString()}`);
338
363
 
@@ -866,6 +891,7 @@ export class ServiceManager {
866
891
  this.config.getMongoConnectionString(),
867
892
  );
868
893
  mongo.authEnabled = this.config.isMongoAuthEnabled();
894
+ mongo.authSource = this.config.getMongoAuthSource();
869
895
 
870
896
  const minio = await describe(
871
897
  'minio',
@@ -913,7 +939,10 @@ export class ServiceManager {
913
939
  logger.log('info', ` ├─ Container: ${containers.mongo}`);
914
940
  logger.log('info', ` ├─ Port: ${config.MONGODB_PORT}`);
915
941
  if (!this.config.isMongoAuthEnabled()) {
916
- logger.log('note', ' ├─ ⚠️ Auth: DISABLED (loopback only)');
942
+ logger.log(
943
+ 'note',
944
+ ` ├─ ⚠️ Auth: DISABLED (loopback only), ${this.describeMongoAuthSource()}`,
945
+ );
917
946
  }
918
947
  logger.log('info', ` ├─ Connection: ${this.config.getMongoConnectionString()}`);
919
948
 
@@ -0,0 +1,135 @@
1
+ import * as plugins from './mod.plugins.js';
2
+ import {
3
+ getCliConfigValueFromData,
4
+ readSmartconfigFile,
5
+ setCliConfigValueInData,
6
+ writeSmartconfigFile,
7
+ } from '../helpers.smartconfig.js';
8
+ import type { TServiceName } from './classes.servicedatamarker.js';
9
+
10
+ /**
11
+ * Committed, per-service configuration.
12
+ *
13
+ * Deliberately a SIBLING of `@git.zone/cli.services` rather than a richer
14
+ * `services` value. `@git.zone/tsdeploy` derives a workload's
15
+ * `requiredCapabilities` from that array and **throws** on any element that is
16
+ * not a canonical lowercase string, so an array of objects — or a mixed array —
17
+ * would hard-fail real deployments. `services` therefore stays exactly
18
+ * `string[]`, and configuration lives here where TsDeploy never looks.
19
+ *
20
+ * Config path: `@git.zone/cli.serviceOptions`.
21
+ */
22
+ export const serviceOptionsConfigKey = 'serviceOptions';
23
+
24
+ export interface IMongodbServiceOptions {
25
+ /**
26
+ * When false, mongod runs without authentication and is published on
27
+ * loopback only. Opt-in, for runtimes whose `node:crypto` cannot complete a
28
+ * SCRAM handshake (notably Deno). Absent means enabled.
29
+ */
30
+ auth?: boolean;
31
+ }
32
+
33
+ export interface IServiceOptions {
34
+ mongodb?: IMongodbServiceOptions;
35
+ }
36
+
37
+ const isPlainObject = (value: unknown): value is Record<string, any> => {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
39
+ };
40
+
41
+ /**
42
+ * Read declared service options from `.smartconfig.json`.
43
+ *
44
+ * Unknown or malformed values are ignored rather than throwing: this file is
45
+ * hand-edited, and a typo must not make every services command unusable. The
46
+ * effect of ignoring is always the safe default (auth enabled).
47
+ */
48
+ export const readServiceOptions = async (
49
+ cwd: string = process.cwd(),
50
+ ): Promise<IServiceOptions> => {
51
+ const smartconfigData = await readSmartconfigFile(cwd);
52
+ const rawOptions = getCliConfigValueFromData(smartconfigData, serviceOptionsConfigKey);
53
+ if (!isPlainObject(rawOptions)) {
54
+ return {};
55
+ }
56
+
57
+ const options: IServiceOptions = {};
58
+ if (isPlainObject(rawOptions.mongodb) && typeof rawOptions.mongodb.auth === 'boolean') {
59
+ options.mongodb = { auth: rawOptions.mongodb.auth };
60
+ }
61
+ return options;
62
+ };
63
+
64
+ /**
65
+ * Declare whether MongoDB enforces authentication for this project.
66
+ *
67
+ * Writing `true` removes the declaration instead of recording it: the default
68
+ * is already "enabled", and an absent key keeps committed config free of noise
69
+ * while remaining unambiguous to an older CLI that ignores this key entirely.
70
+ */
71
+ export const writeMongodbAuthOption = async (
72
+ authEnabledArg: boolean,
73
+ cwd: string = process.cwd(),
74
+ ): Promise<void> => {
75
+ const smartconfigData = await readSmartconfigFile(cwd);
76
+ const rawOptions = getCliConfigValueFromData(smartconfigData, serviceOptionsConfigKey);
77
+ const nextOptions: Record<string, any> = isPlainObject(rawOptions) ? { ...rawOptions } : {};
78
+
79
+ if (authEnabledArg) {
80
+ if (isPlainObject(nextOptions.mongodb)) {
81
+ const { auth, ...restOfMongodb } = nextOptions.mongodb;
82
+ if (Object.keys(restOfMongodb).length > 0) {
83
+ nextOptions.mongodb = restOfMongodb;
84
+ } else {
85
+ delete nextOptions.mongodb;
86
+ }
87
+ }
88
+ } else {
89
+ nextOptions.mongodb = { ...(isPlainObject(nextOptions.mongodb) ? nextOptions.mongodb : {}), auth: false };
90
+ }
91
+
92
+ if (Object.keys(nextOptions).length === 0) {
93
+ // Drop the key entirely rather than leaving an empty object behind.
94
+ const cliConfig = smartconfigData['@git.zone/cli'];
95
+ if (isPlainObject(cliConfig)) {
96
+ delete cliConfig[serviceOptionsConfigKey];
97
+ }
98
+ } else {
99
+ setCliConfigValueInData(smartconfigData, serviceOptionsConfigKey, nextOptions);
100
+ }
101
+
102
+ await writeSmartconfigFile(smartconfigData, cwd);
103
+ };
104
+
105
+ /**
106
+ * Where an effective setting came from, so the CLI can say so out loud.
107
+ */
108
+ export type TServiceOptionSource = 'declared' | 'local' | 'default';
109
+
110
+ export interface IResolvedMongoAuth {
111
+ authEnabled: boolean;
112
+ source: TServiceOptionSource;
113
+ }
114
+
115
+ /**
116
+ * Resolve the effective MongoDB auth mode.
117
+ *
118
+ * A committed declaration wins over `.nogit/env.json`. That ordering is what
119
+ * makes a fresh checkout reproducible: without it, a stale local env.json would
120
+ * silently diverge from what the repository declares. When nothing is declared,
121
+ * an existing local value is preserved so projects configured before
122
+ * `serviceOptions` existed keep working.
123
+ */
124
+ export const resolveMongoAuth = (
125
+ declaredOptionsArg: IServiceOptions,
126
+ localValueArg: boolean | undefined,
127
+ ): IResolvedMongoAuth => {
128
+ if (typeof declaredOptionsArg.mongodb?.auth === 'boolean') {
129
+ return { authEnabled: declaredOptionsArg.mongodb.auth, source: 'declared' };
130
+ }
131
+ if (typeof localValueArg === 'boolean') {
132
+ return { authEnabled: localValueArg, source: 'local' };
133
+ }
134
+ return { authEnabled: true, source: 'default' };
135
+ };
@@ -35,6 +35,11 @@ export interface ILegacyCleanupResult {
35
35
  globalDir: string;
36
36
  deleted: boolean;
37
37
  reason?: string;
38
+ /**
39
+ * Managed packages removed from a legacy root that itself had to be kept.
40
+ * Each entry is `name@version` of an inert copy that nothing resolves to.
41
+ */
42
+ stalePackagesRemoved?: string[];
38
43
  }
39
44
 
40
45
  export interface IShimSyncResult {
@@ -179,9 +184,18 @@ export class PackageManagerUtil {
179
184
  }
180
185
 
181
186
  if (!legacyRoot.safeToDelete) {
187
+ // The root must stay, but the managed copies inside it are inert: the
188
+ // current global root already provides those packages and no shim
189
+ // resolves here. Leaving them behind makes the directory look like the
190
+ // installed version, which misleads anyone inspecting it.
191
+ const stalePackagesRemoved = await this.removeStaleManagedPackages(
192
+ legacyRoot,
193
+ currentPackageNames,
194
+ );
182
195
  cleanupResults.push({
183
196
  globalDir: legacyRoot.globalDir,
184
197
  deleted: false,
198
+ stalePackagesRemoved,
185
199
  reason:
186
200
  legacyRoot.unmanagedPackageNames.length > 0
187
201
  ? `kept because it also contains ${legacyRoot.unmanagedPackageNames.join(", ")}`
@@ -231,6 +245,46 @@ export class PackageManagerUtil {
231
245
  return cleanupResults;
232
246
  }
233
247
 
248
+ /**
249
+ * Remove managed package copies from a legacy global root that has to be kept.
250
+ *
251
+ * Only removes a package when the active global root already provides it and
252
+ * no command shim points into this legacy root, so nothing that currently
253
+ * resolves can be affected. Fails closed: any doubt leaves the copy in place.
254
+ */
255
+ private async removeStaleManagedPackages(
256
+ legacyRootArg: ILegacyGlobalRootInfo,
257
+ currentPackageNamesArg: Set<string>,
258
+ ): Promise<string[]> {
259
+ const shimReferences = await this.getShimReferences(legacyRootArg.globalDir);
260
+ if (shimReferences === null || shimReferences.length > 0) {
261
+ // PNPM_HOME unknown, or something still resolves here.
262
+ return [];
263
+ }
264
+
265
+ const removed: string[] = [];
266
+ for (const packageInfo of legacyRootArg.packages) {
267
+ if (!packageInfo.packagePath) {
268
+ continue;
269
+ }
270
+ if (!currentPackageNamesArg.has(packageInfo.name)) {
271
+ // Not provided by the active root — removing it would lose the package.
272
+ continue;
273
+ }
274
+ try {
275
+ await plugins.fs.rm(packageInfo.packagePath, {
276
+ recursive: true,
277
+ force: true,
278
+ });
279
+ removed.push(`${packageInfo.name}@${packageInfo.version}`);
280
+ } catch {
281
+ // Leave it rather than half-removing it.
282
+ }
283
+ }
284
+
285
+ return removed;
286
+ }
287
+
234
288
  public async syncCurrentGlobalShims(): Promise<IShimSyncResult[]> {
235
289
  const pnpmShimDirs = await this.getPnpmShimDirs();
236
290
  if (!pnpmShimDirs) {
@@ -95,7 +95,12 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
95
95
  const legacyCleanupRoots = legacyRoots.filter(
96
96
  (legacyRoot) => legacyRoot.safeToDelete,
97
97
  );
98
- const legacyCleanupNeeded = legacyCleanupRoots.length > 0;
98
+ // A root that must be kept can still hold inert copies of managed packages.
99
+ // Gating cleanup on wholesale deletability alone left those copies in place
100
+ // forever, so the directory kept advertising a stale version.
101
+ const legacyCleanupNeeded =
102
+ legacyCleanupRoots.length > 0 ||
103
+ legacyRoots.some((legacyRoot) => !legacyRoot.safeToDelete && legacyRoot.packages.length > 0);
99
104
 
100
105
  if (managedInstalledPackages.length === 0) {
101
106
  console.log("No managed @git.zone packages found installed globally.");
@@ -485,6 +490,11 @@ async function cleanupLegacyInstalls(
485
490
  console.log(
486
491
  ` ${cleanupResult.globalDir} kept (${cleanupResult.reason || "unknown reason"})`,
487
492
  );
493
+ for (const stalePackage of cleanupResult.stalePackagesRemoved || []) {
494
+ console.log(
495
+ ` removed stale inert copy ${stalePackage} (the active global root provides it)`,
496
+ );
497
+ }
488
498
  }
489
499
  }
490
500
  console.log("");