@git.zone/cli 3.0.0 → 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.0",
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
@@ -512,7 +526,11 @@ touch containers created by anything else.
512
526
  ### MongoDB authentication
513
527
 
514
528
  MongoDB runs as a single-node replica set with authentication enabled, so
515
- multi-document transactions work. Authentication can be disabled per project for
529
+ multi-document transactions work. The managed container also uses a 65,536
530
+ soft/hard `nofile` limit so persistent, collection-heavy test suites do not hit
531
+ Docker's low default. `services start` reconciles legacy containers to this limit
532
+ by recreating only the container while preserving the bind-mounted data directory.
533
+ Authentication can be disabled per project for
516
534
  runtimes whose `node:crypto` cannot complete a SCRAM handshake (notably Deno):
517
535
 
518
536
  ```bash
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@git.zone/cli',
6
- version: '3.0.0',
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(",")}`
@@ -4,6 +4,15 @@ import { logger } from '../gitzone.logging.js';
4
4
 
5
5
  export type ContainerStatus = 'running' | 'stopped' | 'not_exists';
6
6
 
7
+ export interface IDockerUlimit {
8
+ soft: number;
9
+ hard: number;
10
+ }
11
+
12
+ export type TDockerUlimits = Record<string, IDockerUlimit>;
13
+
14
+ export type TDockerUlimitMatch = 'match' | 'mismatch' | 'unavailable';
15
+
7
16
  export interface IDockerRunOptions {
8
17
  name: string;
9
18
  image: string;
@@ -11,10 +20,53 @@ export interface IDockerRunOptions {
11
20
  volumes?: { [key: string]: string };
12
21
  environment?: { [key: string]: string };
13
22
  labels?: { [key: string]: string };
23
+ ulimits?: TDockerUlimits;
14
24
  restart?: string;
15
25
  command?: string;
16
26
  }
17
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
+
18
70
  /** Container facts needed to decide, safely, whether a container may be removed. */
19
71
  export interface IContainerInspectInfo {
20
72
  id: string;
@@ -29,6 +81,66 @@ export interface IContainerInspectInfo {
29
81
  /** Single-quote a value for safe interpolation into a bash command. */
30
82
  export const shellQuote = (valueArg: string): string => `'${valueArg.replace(/'/g, `'"'"'`)}'`;
31
83
 
84
+ const assertDockerUlimit = (nameArg: string, limitArg: IDockerUlimit): void => {
85
+ if (!/^[a-z][a-z0-9_-]*$/u.test(nameArg)) {
86
+ throw new Error(`Invalid Docker ulimit name: ${nameArg}`);
87
+ }
88
+ if (
89
+ !Number.isSafeInteger(limitArg.soft) ||
90
+ !Number.isSafeInteger(limitArg.hard) ||
91
+ limitArg.soft <= 0 ||
92
+ limitArg.hard <= 0 ||
93
+ limitArg.hard < limitArg.soft
94
+ ) {
95
+ throw new Error(`Invalid Docker ulimit values for ${nameArg}`);
96
+ }
97
+ };
98
+
99
+ export const renderDockerUlimits = (ulimitsArg: TDockerUlimits = {}): string => {
100
+ let rendered = '';
101
+ for (const [name, limit] of Object.entries(ulimitsArg)) {
102
+ assertDockerUlimit(name, limit);
103
+ rendered += ` --ulimit ${shellQuote(`${name}=${limit.soft}:${limit.hard}`)}`;
104
+ }
105
+ return rendered;
106
+ };
107
+
108
+ export const matchDockerUlimits = (
109
+ inspectDataArg: unknown,
110
+ expectedArg: TDockerUlimits,
111
+ ): TDockerUlimitMatch => {
112
+ if (!Array.isArray(inspectDataArg) || inspectDataArg.length !== 1) {
113
+ return 'unavailable';
114
+ }
115
+ const container = inspectDataArg[0] as {
116
+ HostConfig?: {
117
+ Ulimits?: Array<{ Name?: unknown; Soft?: unknown; Hard?: unknown }> | null;
118
+ };
119
+ };
120
+ if (!container?.HostConfig) {
121
+ return 'unavailable';
122
+ }
123
+ if (!Object.hasOwn(container.HostConfig, 'Ulimits')) {
124
+ return 'unavailable';
125
+ }
126
+ const actual = container.HostConfig.Ulimits ?? [];
127
+ if (!Array.isArray(actual)) {
128
+ return 'unavailable';
129
+ }
130
+ for (const [name, expected] of Object.entries(expectedArg)) {
131
+ assertDockerUlimit(name, expected);
132
+ const matchingName = actual.filter((entry) => entry?.Name === name);
133
+ if (
134
+ matchingName.length !== 1 ||
135
+ matchingName[0].Soft !== expected.soft ||
136
+ matchingName[0].Hard !== expected.hard
137
+ ) {
138
+ return 'mismatch';
139
+ }
140
+ }
141
+ return 'match';
142
+ };
143
+
32
144
  export class DockerContainer {
33
145
  private smartshell: plugins.smartshell.Smartshell;
34
146
  /**
@@ -54,6 +166,108 @@ export class DockerContainer {
54
166
  return `timeout ${seconds}s docker`;
55
167
  }
56
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
+
57
271
  /**
58
272
  * Check if Docker is installed and available
59
273
  */
@@ -171,6 +385,8 @@ export class DockerContainer {
171
385
  }
172
386
  }
173
387
 
388
+ command += renderDockerUlimits(options.ulimits);
389
+
174
390
  // Add restart policy
175
391
  if (options.restart) {
176
392
  command += ` --restart ${options.restart}`;
@@ -334,9 +550,9 @@ export class DockerContainer {
334
550
  ): Promise<{ exitCode: number; output: string }> {
335
551
  try {
336
552
  const result = await this.smartshell.execSilent(
337
- `${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command} 2>&1`,
553
+ `${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command}`,
338
554
  );
339
- return { exitCode: result.exitCode, output: result.stdout || '' };
555
+ return { exitCode: result.exitCode, output: result.combinedOutput };
340
556
  } catch (error) {
341
557
  const errorMessage = error instanceof Error ? error.message : String(error);
342
558
  return { exitCode: -1, output: errorMessage };
@@ -350,7 +566,7 @@ export class DockerContainer {
350
566
  try {
351
567
  const tailFlag = lines ? `--tail ${lines}` : '';
352
568
  const result = await this.smartshell.exec(`docker logs ${tailFlag} ${containerName}`);
353
- return result.stdout;
569
+ return result.combinedOutput;
354
570
  } catch (error) {
355
571
  const errorMessage = error instanceof Error ? error.message : String(error);
356
572
  return `Error getting logs: ${errorMessage}`;
@@ -448,7 +664,7 @@ export class DockerContainer {
448
664
  `${this.bounded(this.queryTimeoutSeconds)} ps ${allFlag} ${filters} --format '{{.ID}}'`,
449
665
  );
450
666
  if (result.exitCode !== 0) {
451
- throw new Error(result.stderr || result.stdout || 'docker ps failed');
667
+ throw new Error(result.combinedOutput);
452
668
  }
453
669
  if (!result.stdout.trim()) {
454
670
  return [];
@@ -472,7 +688,7 @@ export class DockerContainer {
472
688
  `${this.bounded(this.queryTimeoutSeconds)} inspect ${idsArg.map((id) => shellQuote(id)).join(' ')}`,
473
689
  );
474
690
  if (result.exitCode !== 0) {
475
- throw new Error(result.stderr || result.stdout || 'docker inspect failed');
691
+ throw new Error(result.combinedOutput);
476
692
  }
477
693
  let parsed: any;
478
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