@git.zone/cli 3.0.1 → 3.0.2

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.
@@ -3,6 +3,7 @@ import * as smartlogDestinationLocal from '@push.rocks/smartlog-destination-loca
3
3
  import * as smartconfig from '@push.rocks/smartconfig';
4
4
  import * as path from 'path';
5
5
  import * as fs from 'node:fs/promises';
6
+ import * as crypto from 'node:crypto';
6
7
  import * as projectinfo from '@push.rocks/projectinfo';
7
8
  import * as smartcli from '@push.rocks/smartcli';
8
9
  import * as smartpath from '@push.rocks/smartpath';
@@ -15,4 +16,4 @@ import { SmartFs } from '@push.rocks/smartfs';
15
16
  import * as smartinteract from '@push.rocks/smartinteract';
16
17
  import * as smartdelay from '@push.rocks/smartdelay';
17
18
  export declare const smartfs: SmartFs;
18
- export { smartlog, smartlogDestinationLocal, smartconfig, path, fs, projectinfo, smartcli, smartpath, smartpromise, smartupdate, smartshell, smartnetwork, smartfile, smartinteract, smartdelay, };
19
+ export { smartlog, smartlogDestinationLocal, smartconfig, path, fs, crypto, projectinfo, smartcli, smartpath, smartpromise, smartupdate, smartshell, smartnetwork, smartfile, smartinteract, smartdelay, };
@@ -3,6 +3,7 @@ import * as smartlogDestinationLocal from '@push.rocks/smartlog-destination-loca
3
3
  import * as smartconfig from '@push.rocks/smartconfig';
4
4
  import * as path from 'path';
5
5
  import * as fs from 'node:fs/promises';
6
+ import * as crypto from 'node:crypto';
6
7
  import * as projectinfo from '@push.rocks/projectinfo';
7
8
  import * as smartcli from '@push.rocks/smartcli';
8
9
  import * as smartpath from '@push.rocks/smartpath';
@@ -16,5 +17,5 @@ import * as smartinteract from '@push.rocks/smartinteract';
16
17
  import * as smartdelay from '@push.rocks/smartdelay';
17
18
  // Create smartfs instance for filesystem operations
18
19
  export const smartfs = new SmartFs(new SmartFsProviderNode());
19
- export { smartlog, smartlogDestinationLocal, smartconfig, path, fs, projectinfo, smartcli, smartpath, smartpromise, smartupdate, smartshell, smartnetwork, smartfile, smartinteract, smartdelay, };
20
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssd0JBQXdCLE1BQU0sd0NBQXdDLENBQUM7QUFDbkYsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLE1BQU0sQ0FBQztBQUM3QixPQUFPLEtBQUssRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBQ3ZDLE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFDdkQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBQ25ELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssVUFBVSxNQUFNLHdCQUF3QixDQUFDO0FBQ3JELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEVBQUUsT0FBTyxFQUFFLG1CQUFtQixFQUFFLE1BQU0scUJBQXFCLENBQUM7QUFDbkUsT0FBTyxLQUFLLGFBQWEsTUFBTSwyQkFBMkIsQ0FBQztBQUMzRCxPQUFPLEtBQUssVUFBVSxNQUFNLHdCQUF3QixDQUFDO0FBRXJELG9EQUFvRDtBQUNwRCxNQUFNLENBQUMsTUFBTSxPQUFPLEdBQUcsSUFBSSxPQUFPLENBQUMsSUFBSSxtQkFBbUIsRUFBRSxDQUFDLENBQUM7QUFFOUQsT0FBTyxFQUNMLFFBQVEsRUFDUix3QkFBd0IsRUFDeEIsV0FBVyxFQUNYLElBQUksRUFDSixFQUFFLEVBQ0YsV0FBVyxFQUNYLFFBQVEsRUFDUixTQUFTLEVBQ1QsWUFBWSxFQUNaLFdBQVcsRUFDWCxVQUFVLEVBQ1YsWUFBWSxFQUNaLFNBQVMsRUFDVCxhQUFhLEVBQ2IsVUFBVSxHQUNYLENBQUMifQ==
20
+ export { smartlog, smartlogDestinationLocal, smartconfig, path, fs, crypto, projectinfo, smartcli, smartpath, smartpromise, smartupdate, smartshell, smartnetwork, smartfile, smartinteract, smartdelay, };
21
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssd0JBQXdCLE1BQU0sd0NBQXdDLENBQUM7QUFDbkYsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLE1BQU0sQ0FBQztBQUM3QixPQUFPLEtBQUssRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBQ3ZDLE9BQU8sS0FBSyxNQUFNLE1BQU0sYUFBYSxDQUFDO0FBQ3RDLE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFDdkQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBQ25ELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssVUFBVSxNQUFNLHdCQUF3QixDQUFDO0FBQ3JELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEVBQUUsT0FBTyxFQUFFLG1CQUFtQixFQUFFLE1BQU0scUJBQXFCLENBQUM7QUFDbkUsT0FBTyxLQUFLLGFBQWEsTUFBTSwyQkFBMkIsQ0FBQztBQUMzRCxPQUFPLEtBQUssVUFBVSxNQUFNLHdCQUF3QixDQUFDO0FBRXJELG9EQUFvRDtBQUNwRCxNQUFNLENBQUMsTUFBTSxPQUFPLEdBQUcsSUFBSSxPQUFPLENBQUMsSUFBSSxtQkFBbUIsRUFBRSxDQUFDLENBQUM7QUFFOUQsT0FBTyxFQUNMLFFBQVEsRUFDUix3QkFBd0IsRUFDeEIsV0FBVyxFQUNYLElBQUksRUFDSixFQUFFLEVBQ0YsTUFBTSxFQUNOLFdBQVcsRUFDWCxRQUFRLEVBQ1IsU0FBUyxFQUNULFlBQVksRUFDWixXQUFXLEVBQ1gsVUFBVSxFQUNWLFlBQVksRUFDWixTQUFTLEVBQ1QsYUFBYSxFQUNiLFVBQVUsR0FDWCxDQUFDIn0=
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@git.zone/cli",
3
3
  "private": false,
4
- "version": "3.0.1",
4
+ "version": "3.0.2",
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,9 +40,9 @@
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.5",
44
- "@git.zone/tstest": "^3.6.6",
45
- "@types/node": "^25.9.3"
43
+ "@git.zone/tsrun": "^2.0.6",
44
+ "@git.zone/tstest": "^3.6.7",
45
+ "@types/node": "^26.1.1"
46
46
  },
47
47
  "dependencies": {
48
48
  "@git.zone/tsdoc": "^2.1.1",
@@ -69,7 +69,7 @@
69
69
  "@push.rocks/smartpath": "^6.0.0",
70
70
  "@push.rocks/smartpromise": "^4.2.3",
71
71
  "@push.rocks/smartscaf": "^4.0.22",
72
- "@push.rocks/smartshell": "^3.5.0",
72
+ "@push.rocks/smartshell": "^4.0.0",
73
73
  "@push.rocks/smartunique": "^3.0.9",
74
74
  "@push.rocks/smartupdate": "^2.0.7",
75
75
  "prettier": "^3.8.1"
package/readme.md CHANGED
@@ -458,6 +458,20 @@ gitzone services logs mongo 50
458
458
 
459
459
  Service config is stored in `.nogit/env.json`. Data is stored below `.nogit/`, so it stays out of Git.
460
460
 
461
+ GitZone derives new MinIO bucket names from the project name using S3 naming
462
+ rules. When loading older configuration it repairs only the exact generated
463
+ `<project>-documents` value if that value is invalid; custom bucket names are
464
+ never rewritten. `services start minio` validates custom names, recreates a
465
+ container whose configured credentials or ports drifted, authenticates the
466
+ configured alias, creates the bucket idempotently, and verifies it before
467
+ reporting success. Before GitZone starts, recreates, or executes inside an
468
+ existing MinIO container, current-project ownership must be proven by exact
469
+ GitZone labels or one unambiguous legacy registry claim. Every lifecycle
470
+ operation after discovery uses the container's immutable id. If startup fails,
471
+ GitZone stops only containers that this invocation started; it never stops a
472
+ container that was already running. Invalid custom names do not block read-only
473
+ or recovery commands such as `status`, `logs`, `stop`, `remove`, and `clean`.
474
+
461
475
  ### Consuming a service programmatically
462
476
 
463
477
  `gitzone services status --json` emits only JSON on stdout, so a test suite or
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@git.zone/cli',
6
- version: '3.0.1',
6
+ version: '3.0.2',
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
  }
@@ -71,15 +71,14 @@ const dockerCommand = `timeout ${dockerTimeoutSeconds}s docker`;
71
71
  export class DockerPruner {
72
72
  private smartshell = new plugins.smartshell.Smartshell({ executor: 'bash' });
73
73
 
74
- private async run(commandArg: string): Promise<{ exitCode: number; stdout: string }> {
75
- const result = await this.smartshell.execSilent(commandArg);
76
- return { exitCode: result.exitCode, stdout: result.stdout || '' };
74
+ private async run(commandArg: string): Promise<plugins.smartshell.IExecResult> {
75
+ return this.smartshell.execSilent(commandArg);
77
76
  }
78
77
 
79
78
  private async listLines(commandArg: string): Promise<string[]> {
80
79
  const result = await this.run(commandArg);
81
80
  if (result.exitCode !== 0) {
82
- throw new Error(result.stdout || `docker command failed: ${commandArg}`);
81
+ throw new Error(result.combinedOutput);
83
82
  }
84
83
  if (!result.stdout.trim()) {
85
84
  return [];
@@ -99,7 +98,7 @@ export class DockerPruner {
99
98
  `${dockerCommand} inspect ${idsArg.map((id) => shellQuote(id)).join(' ')}`,
100
99
  );
101
100
  if (result.exitCode !== 0) {
102
- throw new Error(result.stdout || 'docker inspect failed');
101
+ throw new Error(result.combinedOutput);
103
102
  }
104
103
  try {
105
104
  const parsed = JSON.parse(result.stdout);
@@ -287,7 +286,7 @@ export class DockerPruner {
287
286
  }
288
287
  const result = await this.run(`${dockerCommand} rm ${shellQuote(planned.id)}`);
289
288
  if (result.exitCode !== 0) {
290
- throw new Error(`Failed to remove container ${planned.name}: ${result.stdout}`);
289
+ throw new Error(`Failed to remove container ${planned.name}: ${result.combinedOutput}`);
291
290
  }
292
291
  removedContainers.push(planned.name);
293
292
  logger.log('ok', `Removed container ${planned.name}`);
@@ -322,7 +321,7 @@ export class DockerPruner {
322
321
  }
323
322
  const result = await this.run(`${dockerCommand} volume rm ${shellQuote(planned.name)}`);
324
323
  if (result.exitCode !== 0) {
325
- throw new Error(`Failed to remove volume ${planned.name}: ${result.stdout}`);
324
+ throw new Error(`Failed to remove volume ${planned.name}: ${result.combinedOutput}`);
326
325
  }
327
326
  removedVolumes.push(planned.name);
328
327
  logger.log('ok', `Removed volume ${planned.name}`);
@@ -247,7 +247,7 @@ async function runNpmTarget(
247
247
  for (const registry of workflow.npmRegistries) {
248
248
  const command = `pnpm publish --registry=${registry} --access=${workflow.npmAccessLevel}`;
249
249
  const result = await smartshellInstance.exec(command);
250
- const output = `${result.stdout || ""}\n${(result as any).stderr || ""}\n${(result as any).combinedOutput || ""}`;
250
+ const output = result.combinedOutput;
251
251
  if (result.exitCode === 0) {
252
252
  results.push({ target: registry, status: "success" });
253
253
  } else if (isAlreadyPublishedOutput(output) && workflow.npmAlreadyPublished === "success") {
@@ -269,7 +269,7 @@ async function runDockerTarget(
269
269
 
270
270
  const command = buildTsdockerPushCommand(workflow);
271
271
  const result = await smartshellInstance.exec(command);
272
- const output = `${result.stdout || ""}\n${(result as any).stderr || ""}\n${(result as any).combinedOutput || ""}`;
272
+ const output = result.combinedOutput;
273
273
  return [{
274
274
  target: workflow.dockerPatterns.length > 0
275
275
  ? `tsdocker:${workflow.dockerPatterns.join(",")}`
@@ -25,6 +25,48 @@ export interface IDockerRunOptions {
25
25
  command?: string;
26
26
  }
27
27
 
28
+ export interface IDockerCreateArgvOptions extends Omit<IDockerRunOptions, 'command'> {
29
+ /** Container command passed as exact argv entries. */
30
+ command?: readonly string[];
31
+ }
32
+
33
+ export interface IDockerExecArgvOptions {
34
+ /** Hard per-process wall-clock bound. */
35
+ timeoutMs: number;
36
+ }
37
+
38
+ export interface IDockerExecArgvResult {
39
+ stdout: string;
40
+ stderr: string;
41
+ }
42
+
43
+ export interface IDockerArgvExecutionErrorOptions {
44
+ timeoutMs: number;
45
+ timedOut: boolean;
46
+ exitCode?: number;
47
+ signal?: NodeJS.Signals;
48
+ }
49
+
50
+ /** Sanitised failure from the shell-free Docker execution path. */
51
+ export class DockerArgvExecutionError extends Error {
52
+ public readonly exitCode?: number;
53
+ public readonly signal?: NodeJS.Signals;
54
+
55
+ constructor(optionsArg: IDockerArgvExecutionErrorOptions) {
56
+ const detail = optionsArg.timedOut
57
+ ? `timed out after ${optionsArg.timeoutMs}ms`
58
+ : optionsArg.signal
59
+ ? `terminated by signal ${optionsArg.signal}`
60
+ : optionsArg.exitCode === undefined
61
+ ? 'could not be started'
62
+ : `exited with code ${optionsArg.exitCode}`;
63
+ super(`Docker command ${detail}`);
64
+ this.name = 'DockerArgvExecutionError';
65
+ this.exitCode = optionsArg.exitCode;
66
+ this.signal = optionsArg.signal;
67
+ }
68
+ }
69
+
28
70
  /** Container facts needed to decide, safely, whether a container may be removed. */
29
71
  export interface IContainerInspectInfo {
30
72
  id: string;
@@ -124,6 +166,108 @@ export class DockerContainer {
124
166
  return `timeout ${seconds}s docker`;
125
167
  }
126
168
 
169
+ /**
170
+ * Execute Docker directly with exact argv boundaries and strict failures.
171
+ * Arguments and output are not retained in errors, keeping this safe for
172
+ * credential-boundary operations.
173
+ */
174
+ public async execArgv(
175
+ argvArg: readonly string[],
176
+ optionsArg: IDockerExecArgvOptions,
177
+ ): Promise<IDockerExecArgvResult> {
178
+ return this.execArgvInternal(argvArg, optionsArg, process.env);
179
+ }
180
+
181
+ private async execArgvInternal(
182
+ argvArg: readonly string[],
183
+ optionsArg: IDockerExecArgvOptions,
184
+ environmentArg: NodeJS.ProcessEnv,
185
+ ): Promise<IDockerExecArgvResult> {
186
+ if (!Array.isArray(argvArg) || argvArg.length === 0) {
187
+ throw new Error('Docker argv must contain at least one argument');
188
+ }
189
+ if (argvArg.some((argumentArg) => typeof argumentArg !== 'string' || argumentArg.includes('\0'))) {
190
+ throw new Error('Docker argv must contain only NUL-free strings');
191
+ }
192
+ if (!Number.isSafeInteger(optionsArg?.timeoutMs) || optionsArg.timeoutMs <= 0) {
193
+ throw new Error('Docker argv timeoutMs must be a positive safe integer');
194
+ }
195
+
196
+ try {
197
+ const result = await this.smartshell.execSpawn('docker', [...argvArg], {
198
+ silent: true,
199
+ strict: true,
200
+ timeout: optionsArg.timeoutMs,
201
+ timeoutKillGraceMs: 0,
202
+ maxBuffer: 1024 * 1024,
203
+ env: environmentArg,
204
+ });
205
+ return {
206
+ stdout: result.stdout,
207
+ stderr: result.stderr,
208
+ };
209
+ } catch (error) {
210
+ if (error instanceof plugins.smartshell.SmartshellError) {
211
+ throw new DockerArgvExecutionError({
212
+ timeoutMs: optionsArg.timeoutMs,
213
+ timedOut: error.timedOut,
214
+ exitCode: error.exitCode,
215
+ signal: error.signal,
216
+ });
217
+ }
218
+ throw new DockerArgvExecutionError({
219
+ timeoutMs: optionsArg.timeoutMs,
220
+ timedOut: false,
221
+ });
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Create, but do not start, a container without a host shell. Environment
227
+ * values are inherited by name so credentials do not enter Docker's argv or
228
+ * command errors. The returned stdout contains Docker's immutable id.
229
+ */
230
+ public async createArgv(
231
+ optionsArg: IDockerCreateArgvOptions,
232
+ executionOptionsArg: IDockerExecArgvOptions,
233
+ ): Promise<IDockerExecArgvResult> {
234
+ const argv: string[] = ['create', '--name', optionsArg.name];
235
+
236
+ for (const [hostPort, containerPort] of Object.entries(optionsArg.ports || {})) {
237
+ argv.push('-p', `${hostPort}:${containerPort}`);
238
+ }
239
+ for (const [hostPath, containerPath] of Object.entries(optionsArg.volumes || {})) {
240
+ argv.push('-v', `${hostPath}:${containerPath}`);
241
+ }
242
+
243
+ const childEnvironment: NodeJS.ProcessEnv = { ...process.env };
244
+ for (const [key, value] of Object.entries(optionsArg.environment || {})) {
245
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) {
246
+ throw new Error(`Invalid Docker environment variable name: ${key}`);
247
+ }
248
+ childEnvironment[key] = value;
249
+ argv.push('--env', key);
250
+ }
251
+
252
+ for (const [key, value] of Object.entries(optionsArg.labels || {})) {
253
+ argv.push('--label', `${key}=${value}`);
254
+ }
255
+ for (const [name, limit] of Object.entries(optionsArg.ulimits || {})) {
256
+ assertDockerUlimit(name, limit);
257
+ argv.push('--ulimit', `${name}=${limit.soft}:${limit.hard}`);
258
+ }
259
+ if (optionsArg.restart) {
260
+ argv.push('--restart', optionsArg.restart);
261
+ }
262
+
263
+ argv.push(optionsArg.image);
264
+ if (optionsArg.command) {
265
+ argv.push(...optionsArg.command);
266
+ }
267
+
268
+ return this.execArgvInternal(argv, executionOptionsArg, childEnvironment);
269
+ }
270
+
127
271
  /**
128
272
  * Check if Docker is installed and available
129
273
  */
@@ -406,9 +550,9 @@ export class DockerContainer {
406
550
  ): Promise<{ exitCode: number; output: string }> {
407
551
  try {
408
552
  const result = await this.smartshell.execSilent(
409
- `${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command} 2>&1`,
553
+ `${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command}`,
410
554
  );
411
- return { exitCode: result.exitCode, output: result.stdout || '' };
555
+ return { exitCode: result.exitCode, output: result.combinedOutput };
412
556
  } catch (error) {
413
557
  const errorMessage = error instanceof Error ? error.message : String(error);
414
558
  return { exitCode: -1, output: errorMessage };
@@ -422,7 +566,7 @@ export class DockerContainer {
422
566
  try {
423
567
  const tailFlag = lines ? `--tail ${lines}` : '';
424
568
  const result = await this.smartshell.exec(`docker logs ${tailFlag} ${containerName}`);
425
- return result.stdout;
569
+ return result.combinedOutput;
426
570
  } catch (error) {
427
571
  const errorMessage = error instanceof Error ? error.message : String(error);
428
572
  return `Error getting logs: ${errorMessage}`;
@@ -520,7 +664,7 @@ export class DockerContainer {
520
664
  `${this.bounded(this.queryTimeoutSeconds)} ps ${allFlag} ${filters} --format '{{.ID}}'`,
521
665
  );
522
666
  if (result.exitCode !== 0) {
523
- throw new Error(result.stderr || result.stdout || 'docker ps failed');
667
+ throw new Error(result.combinedOutput);
524
668
  }
525
669
  if (!result.stdout.trim()) {
526
670
  return [];
@@ -544,7 +688,7 @@ export class DockerContainer {
544
688
  `${this.bounded(this.queryTimeoutSeconds)} inspect ${idsArg.map((id) => shellQuote(id)).join(' ')}`,
545
689
  );
546
690
  if (result.exitCode !== 0) {
547
- throw new Error(result.stderr || result.stdout || 'docker inspect failed');
691
+ throw new Error(result.combinedOutput);
548
692
  }
549
693
  let parsed: any;
550
694
  try {
@@ -17,6 +17,104 @@ export const isLocalMongoHost = (hostArg: string): boolean => {
17
17
  return localMongoHosts.includes(hostArg.trim().toLowerCase());
18
18
  };
19
19
 
20
+ const s3ReservedPrefixes = ['xn--', 'sthree-', 'amzn-s3-demo-'];
21
+ const s3ReservedSuffixes = ['-s3alias', '--ol-s3', '.mrap', '--x-s3', '--table-s3'];
22
+ const s3BucketPattern = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/u;
23
+ const ipv4AddressPattern = /^(?:\d{1,3}\.){3}\d{1,3}$/u;
24
+
25
+ const isIpv4Address = (valueArg: string): boolean => {
26
+ if (!ipv4AddressPattern.test(valueArg)) {
27
+ return false;
28
+ }
29
+ return valueArg.split('.').every((octetArg) => Number(octetArg) <= 255);
30
+ };
31
+
32
+ /** Validate a bucket against the current S3 general-purpose naming rules. */
33
+ export const getS3BucketNameValidationError = (bucketArg: unknown): string | undefined => {
34
+ if (typeof bucketArg !== 'string') {
35
+ return 'must be a string';
36
+ }
37
+ if (bucketArg.length < 3 || bucketArg.length > 63) {
38
+ return 'must contain between 3 and 63 characters';
39
+ }
40
+ if (!s3BucketPattern.test(bucketArg)) {
41
+ return 'must use only lowercase letters, numbers, periods, and hyphens, with an alphanumeric first and last character';
42
+ }
43
+ if (bucketArg.includes('..')) {
44
+ return 'must not contain adjacent periods';
45
+ }
46
+ const reservedPrefix = s3ReservedPrefixes.find((prefixArg) => bucketArg.startsWith(prefixArg));
47
+ if (reservedPrefix) {
48
+ return `must not start with the reserved prefix "${reservedPrefix}"`;
49
+ }
50
+ const reservedSuffix = s3ReservedSuffixes.find((suffixArg) => bucketArg.endsWith(suffixArg));
51
+ if (reservedSuffix) {
52
+ return `must not end with the reserved suffix "${reservedSuffix}"`;
53
+ }
54
+ if (isIpv4Address(bucketArg)) {
55
+ return 'must not be formatted as an IPv4 address';
56
+ }
57
+ return undefined;
58
+ };
59
+
60
+ export const isValidS3BucketName = (bucketArg: unknown): bucketArg is string => {
61
+ return getS3BucketNameValidationError(bucketArg) === undefined;
62
+ };
63
+
64
+ /**
65
+ * Derive GitZone's bucket from a project name using a deliberately smaller
66
+ * alphabet than custom S3 names. Long names retain a stable hash suffix.
67
+ */
68
+ export const deriveS3BucketName = (projectNameArg: string): string => {
69
+ const source = `${projectNameArg}-documents`;
70
+ let canonical = source
71
+ .trim()
72
+ .toLowerCase()
73
+ .replace(/[^a-z0-9]+/gu, '-')
74
+ .replace(/^-+|-+$/gu, '');
75
+
76
+ if (!canonical) {
77
+ canonical = 'project-documents';
78
+ }
79
+
80
+ if (
81
+ s3ReservedPrefixes.some((prefixArg) => canonical.startsWith(prefixArg)) ||
82
+ s3ReservedSuffixes.some((suffixArg) => canonical.endsWith(suffixArg)) ||
83
+ isIpv4Address(canonical)
84
+ ) {
85
+ canonical = `gitzone-${canonical}`;
86
+ }
87
+
88
+ if (canonical.length > 63) {
89
+ const stableHash = plugins.crypto.createHash('sha256').update(source).digest('hex').slice(0, 12);
90
+ const prefixLength = 63 - stableHash.length - 1;
91
+ const prefix = canonical.slice(0, prefixLength).replace(/-+$/u, '');
92
+ canonical = `${prefix}-${stableHash}`;
93
+ }
94
+
95
+ const validationError = getS3BucketNameValidationError(canonical);
96
+ if (validationError) {
97
+ throw new Error(`Could not derive a valid S3 bucket name: ${validationError}`);
98
+ }
99
+ return canonical;
100
+ };
101
+
102
+ export type TLegacyS3BucketRepair =
103
+ | { bucket: unknown; repaired: false }
104
+ | { bucket: string; repaired: true };
105
+
106
+ /** Repair only the exact invalid value emitted by older GitZone releases. */
107
+ export const repairLegacyGeneratedS3Bucket = (
108
+ projectNameArg: string,
109
+ bucketArg: unknown,
110
+ ): TLegacyS3BucketRepair => {
111
+ const legacyGeneratedBucket = `${projectNameArg}-documents`;
112
+ if (bucketArg !== legacyGeneratedBucket || isValidS3BucketName(bucketArg)) {
113
+ return { bucket: bucketArg, repaired: false };
114
+ }
115
+ return { bucket: deriveS3BucketName(projectNameArg), repaired: true };
116
+ };
117
+
20
118
  export interface IServiceConfig {
21
119
  PROJECT_NAME: string;
22
120
  MONGODB_HOST: string;
@@ -255,7 +353,7 @@ export class ServiceConfiguration {
255
353
  S3_CONSOLE_PORT: s3ConsolePort.toString(),
256
354
  S3_ACCESSKEY: 'defaultadmin',
257
355
  S3_SECRETKEY: 'defaultpass',
258
- S3_BUCKET: `${projectName}-documents`,
356
+ S3_BUCKET: deriveS3BucketName(projectName),
259
357
  S3_ENDPOINT: s3Host,
260
358
  S3_USESSL: false,
261
359
  ELASTICSEARCH_HOST: esHost,
@@ -373,9 +471,19 @@ export class ServiceConfiguration {
373
471
  }
374
472
 
375
473
  if (!this.config.S3_BUCKET) {
376
- this.config.S3_BUCKET = `${projectName}-documents`;
474
+ this.config.S3_BUCKET = deriveS3BucketName(this.config.PROJECT_NAME);
377
475
  fieldsAdded.push('S3_BUCKET');
378
476
  updated = true;
477
+ } else {
478
+ const repair = repairLegacyGeneratedS3Bucket(
479
+ this.config.PROJECT_NAME,
480
+ this.config.S3_BUCKET,
481
+ );
482
+ if (repair.repaired) {
483
+ this.config.S3_BUCKET = repair.bucket;
484
+ fieldsAdded.push('S3_BUCKET(repaired legacy generated name)');
485
+ updated = true;
486
+ }
379
487
  }
380
488
 
381
489
  // `undefined`, not falsy: a stored `false` is a valid value, and treating it