@git.zone/cli 3.0.1 → 3.1.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.
@@ -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