@bensandee/tooling 0.65.0 → 0.67.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.
@@ -1,586 +0,0 @@
1
- import path from "node:path";
2
- import { existsSync, readFileSync, readdirSync } from "node:fs";
3
- import { execFileSync, spawnSync } from "node:child_process";
4
- import { z } from "zod";
5
- import { parse } from "yaml";
6
- //#region src/utils/debug.ts
7
- const REDACT_PATTERNS = [/x-access-token:[^\s@]+/g, /Authorization:\s*(?:token|Bearer)\s+\S+/gi];
8
- const debugOut = (msg) => console.error(msg);
9
- /** Redact secrets (tokens, credentials) from text before logging. */
10
- function redact(text) {
11
- let result = text;
12
- for (const pattern of REDACT_PATTERNS) result = result.replace(pattern, "[REDACTED]");
13
- return result;
14
- }
15
- /** Check if verbose/debug mode is enabled via environment variables. */
16
- function isEnvVerbose() {
17
- return process.env["TOOLING_DEBUG"] === "true";
18
- }
19
- /** Log a debug message when verbose mode is enabled. */
20
- function debug(config, message) {
21
- if (config.verbose) debugOut(`[debug] ${message}`);
22
- }
23
- /** Log the result of an exec call when verbose mode is enabled. */
24
- function debugExec(config, label, result) {
25
- if (!config.verbose) return;
26
- const lines = [`[debug] ${redact(label)} (exit code ${String(result.exitCode)})`];
27
- if (result.stdout.trim()) lines.push(` stdout: ${redact(result.stdout.trim())}`);
28
- if (result.stderr.trim()) lines.push(` stderr: ${redact(result.stderr.trim())}`);
29
- debugOut(lines.join("\n"));
30
- }
31
- //#endregion
32
- //#region src/docker-check/compose-lint.ts
33
- const ComposeFileShape = z.looseObject({ name: z.unknown().optional() });
34
- /**
35
- * Compose files live by convention in `./` or `docker/`. The lint walks those
36
- * by default; if `.tooling.json#dockerCheck.composeFiles` overrides the
37
- * location, each listed file (and its parent dir) is also linted.
38
- */
39
- const COMPOSE_DIR_CANDIDATES = [".", "docker"];
40
- /** Base compose file names whose project namespace defaults to dir basename. */
41
- const BASE_COMPOSE_FILES = /* @__PURE__ */ new Set([
42
- "docker-compose.yml",
43
- "docker-compose.yaml",
44
- "compose.yml",
45
- "compose.yaml"
46
- ]);
47
- const COMPOSE_FILENAME_PATTERN = /^(?:docker-compose|compose).*\.(?:yml|yaml)$/;
48
- /**
49
- * Walk compose files in `targetDir` and report lint violations:
50
- *
51
- * - Rule A (`missing-name`): every override compose file must declare a
52
- * top-level `name:` directive. Base files (docker-compose.yml etc.) are
53
- * exempt — their default of dir-basename is fine — but overrides without
54
- * `name:` silently inherit the base's project namespace, which is how a
55
- * `compose down -v` against the check overlay wiped the prod-volume project.
56
- * - Rule B (`duplicate-name`): no two compose files in a single dir may
57
- * declare the same `name:`. Base files participate in collision detection.
58
- */
59
- function lintComposeFiles(targetDir, config) {
60
- const issues = [];
61
- const visitedDirs = /* @__PURE__ */ new Set();
62
- const standaloneFiles = [];
63
- const dockerCheck = config?.dockerCheck;
64
- const isObjectConfig = dockerCheck !== void 0 && dockerCheck !== false;
65
- for (const candidate of COMPOSE_DIR_CANDIDATES) {
66
- const absDir = path.resolve(targetDir, candidate);
67
- if (existsSync(absDir)) visitDir(targetDir, absDir, visitedDirs, issues);
68
- }
69
- if (isObjectConfig && dockerCheck.composeFiles) for (const rel of dockerCheck.composeFiles) {
70
- const absFile = path.resolve(targetDir, rel);
71
- if (!existsSync(absFile)) continue;
72
- const dir = path.dirname(absFile);
73
- if (visitedDirs.has(dir)) continue;
74
- const basename = path.basename(absFile);
75
- standaloneFiles.push({
76
- absPath: absFile,
77
- relPath: path.relative(targetDir, absFile),
78
- basename
79
- });
80
- }
81
- const standaloneByDir = /* @__PURE__ */ new Map();
82
- for (const file of standaloneFiles) {
83
- const dir = path.dirname(file.absPath);
84
- const list = standaloneByDir.get(dir) ?? [];
85
- list.push(file);
86
- standaloneByDir.set(dir, list);
87
- }
88
- for (const files of standaloneByDir.values()) {
89
- files.sort((a, b) => a.relPath.localeCompare(b.relPath));
90
- lintDirFiles(files, issues);
91
- }
92
- return issues;
93
- }
94
- function visitDir(targetDir, absDir, visitedDirs, issues) {
95
- if (visitedDirs.has(absDir)) return;
96
- visitedDirs.add(absDir);
97
- let entries;
98
- try {
99
- entries = readdirSync(absDir);
100
- } catch (_error) {
101
- return;
102
- }
103
- const files = entries.filter((name) => COMPOSE_FILENAME_PATTERN.test(name)).toSorted().map((name) => {
104
- const absPath = path.join(absDir, name);
105
- return {
106
- absPath,
107
- relPath: path.relative(targetDir, absPath),
108
- basename: name
109
- };
110
- });
111
- if (files.length === 0) return;
112
- lintDirFiles(files, issues);
113
- }
114
- function lintDirFiles(files, issues) {
115
- const namesByFile = /* @__PURE__ */ new Map();
116
- for (const file of files) {
117
- const declaredName = readTopLevelName(file.absPath);
118
- namesByFile.set(file.relPath, declaredName);
119
- if (!BASE_COMPOSE_FILES.has(file.basename) && (declaredName === void 0 || declaredName === "")) {
120
- const suggested = suggestProjectName(file);
121
- issues.push({
122
- kind: "missing-name",
123
- files: [file.relPath],
124
- message: `${file.relPath}: override compose file is missing required top-level 'name:' directive.\n Add: name: ${suggested}\n Why: without an explicit name, this override inherits the base file's project namespace.\n A 'docker compose down -v' against this override would target the base project's volumes\n — which has caused production volume loss in the past.`
125
- });
126
- }
127
- }
128
- const filesByName = /* @__PURE__ */ new Map();
129
- for (const [relPath, declaredName] of namesByFile) {
130
- if (declaredName === void 0 || declaredName === "") continue;
131
- const list = filesByName.get(declaredName) ?? [];
132
- list.push(relPath);
133
- filesByName.set(declaredName, list);
134
- }
135
- for (const [declaredName, dupFiles] of filesByName) {
136
- if (dupFiles.length < 2) continue;
137
- dupFiles.sort();
138
- const [first, second, ...rest] = dupFiles;
139
- const ext = rest.length > 0 ? `, ${rest.join(", ")}` : "";
140
- issues.push({
141
- kind: "duplicate-name",
142
- files: dupFiles,
143
- message: `${String(first)} and ${String(second)}${ext} both declare 'name: ${declaredName}'.\n Each compose file in the same directory must declare a unique 'name:'.\n Suggested fix: rename one (e.g. 'name: ${declaredName}-test' in one of them).`
144
- });
145
- }
146
- }
147
- /** Suggest a default name for an override file by stripping the variant from the filename. */
148
- function suggestProjectName(file) {
149
- const parts = file.basename.replace(/\.(yml|yaml)$/, "").split(".");
150
- const variant = parts[parts.length - 1];
151
- if (parts.length > 1 && variant) return `<project>-${variant}`;
152
- return "<project>-check";
153
- }
154
- function readTopLevelName(absPath) {
155
- let raw;
156
- try {
157
- raw = readFileSync(absPath, "utf-8");
158
- } catch (_error) {
159
- return;
160
- }
161
- let yamlValue;
162
- try {
163
- yamlValue = parse(raw);
164
- } catch (_error) {
165
- return;
166
- }
167
- const parsed = ComposeFileShape.safeParse(yamlValue);
168
- if (!parsed.success) return void 0;
169
- const value = parsed.data.name;
170
- if (typeof value !== "string") return void 0;
171
- return value;
172
- }
173
- //#endregion
174
- //#region src/docker-check/executor.ts
175
- /** Create an executor that runs real commands, fetches, and manages process signals. */
176
- function createRealExecutor() {
177
- return {
178
- exec(file, args, options) {
179
- const result = spawnSync(file, args, {
180
- cwd: options?.cwd,
181
- env: options?.env ? {
182
- ...process.env,
183
- ...options.env
184
- } : void 0,
185
- encoding: "utf-8",
186
- stdio: [
187
- "pipe",
188
- "pipe",
189
- "pipe"
190
- ]
191
- });
192
- return {
193
- stdout: result.stdout ?? "",
194
- stderr: result.stderr ?? "",
195
- exitCode: result.status ?? 1
196
- };
197
- },
198
- execInherit(file, args, options) {
199
- execFileSync(file, args, {
200
- cwd: options?.cwd,
201
- env: options?.env ? {
202
- ...process.env,
203
- ...options.env
204
- } : void 0,
205
- stdio: "inherit"
206
- });
207
- },
208
- fetch: globalThis.fetch,
209
- now: () => Date.now(),
210
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
211
- onSignal(signal, handler) {
212
- process.on(signal, handler);
213
- return () => {
214
- process.removeListener(signal, handler);
215
- };
216
- },
217
- log: (msg) => console.log(msg),
218
- logError: (msg) => console.error(msg)
219
- };
220
- }
221
- //#endregion
222
- //#region src/docker-check/compose.ts
223
- /** Zod schema for a single container entry from `docker compose ps --format json`. */
224
- const ContainerInfoSchema = z.object({
225
- Service: z.string(),
226
- Health: z.string()
227
- });
228
- /** Build the `docker compose` base args from config. */
229
- function composeArgs(config) {
230
- const args = [
231
- "compose",
232
- "--project-name",
233
- config.projectName
234
- ];
235
- for (const f of config.composeFiles) args.push("-f", f);
236
- if (config.envFile) args.push("--env-file", config.envFile);
237
- return args;
238
- }
239
- /**
240
- * Compose's unset-interpolation warning. The variable name is quoted, and the
241
- * quotes arrive backslash-escaped when stderr is a pipe (which it always is
242
- * here) but bare when it is a TTY — so both forms are accepted.
243
- */
244
- const UNSET_VARIABLE_WARNING = /The\s+\\?"([^"\\]+)\\?"\s+variable is not set/g;
245
- /** Names of the interpolation variables compose warned were unset, in order, de-duplicated. */
246
- function parseUnsetVariables(stderr) {
247
- const names = /* @__PURE__ */ new Set();
248
- for (const match of stderr.matchAll(UNSET_VARIABLE_WARNING)) {
249
- const name = match[1];
250
- if (name) names.add(name);
251
- }
252
- return [...names];
253
- }
254
- /**
255
- * Ask compose which interpolation variables are unset, and map each to an empty
256
- * string so it can be injected into the child env of every later compose call.
257
- *
258
- * Compose warns `The "FOO" variable is not set` once per file per invocation —
259
- * including on `down` — for a `${FOO}` with no default. Nothing expressible in
260
- * the check overlay silences that: interpolation happens per-file, before the
261
- * merge. Setting the variable does silence it, and setting it to the *empty
262
- * string* is what compose already substitutes, so the value each container sees
263
- * is unchanged. A non-empty placeholder would not be: a service that dials
264
- * `${LYRION_HOST}` would start resolving a hostname where today it gets "".
265
- *
266
- * Detection is delegated to compose rather than re-implementing its grammar:
267
- * `config` has already consulted `--env-file` and the process env, so it reports
268
- * exactly the variables that still need filling, in every position (`image:`,
269
- * `ports:`, `volumes:`, …) and every form, with `$$` escapes and `${FOO:-def}`
270
- * defaults already accounted for. If the wording ever changes we fill nothing
271
- * and the warnings simply come back — a `${FOO:?required}` error is never masked.
272
- *
273
- * Variables already present in `config.env` are left alone: the free-port logic
274
- * owns PORT, and an empty string there would unbind the published port.
275
- */
276
- function fillUnsetVariables(executor, config) {
277
- const result = executor.exec("docker", [...composeArgs(config), "config"], {
278
- cwd: config.cwd,
279
- env: config.env
280
- });
281
- const filled = {};
282
- for (const name of parseUnsetVariables(result.stderr)) {
283
- if (config.env && name in config.env) continue;
284
- filled[name] = "";
285
- }
286
- return filled;
287
- }
288
- /** Run the build command if configured. */
289
- function buildImages(executor, config) {
290
- if (!config.buildCommand) return;
291
- const [file, ...args] = config.buildCommand;
292
- if (!file) return;
293
- executor.execInherit(file, args, { cwd: config.buildCwd ?? config.compose.cwd });
294
- }
295
- /** Run the smoketest command against the running stack. */
296
- function runSmoketest(executor, config) {
297
- if (!config.smoketest) return;
298
- const [file, ...args] = config.smoketest;
299
- if (!file) return;
300
- executor.execInherit(file, args, {
301
- cwd: config.smoketestCwd ?? config.compose.cwd,
302
- env: config.compose.env
303
- });
304
- }
305
- /** Start the compose stack in detached mode. */
306
- function composeUp(executor, config) {
307
- executor.execInherit("docker", [
308
- ...composeArgs(config),
309
- "up",
310
- "-d"
311
- ], {
312
- cwd: config.cwd,
313
- env: config.env
314
- });
315
- }
316
- /** Tear down the compose stack, removing volumes and orphans. Swallows errors. */
317
- function composeDown(executor, config) {
318
- try {
319
- executor.execInherit("docker", [
320
- ...composeArgs(config),
321
- "down",
322
- "-v",
323
- "--remove-orphans"
324
- ], {
325
- cwd: config.cwd,
326
- env: config.env
327
- });
328
- } catch (_error) {}
329
- }
330
- /** Show logs for a specific service (or all services if not specified). Swallows errors. */
331
- function composeLogs(executor, config, service) {
332
- try {
333
- const args = [...composeArgs(config), "logs"];
334
- if (service) args.push(service);
335
- executor.execInherit("docker", args, {
336
- cwd: config.cwd,
337
- env: config.env
338
- });
339
- } catch (_error) {}
340
- }
341
- /**
342
- * Query container status via `docker compose ps --format json`.
343
- * Handles both JSON array and newline-delimited JSON (varies by docker compose version).
344
- */
345
- function composePs(executor, config) {
346
- const output = executor.exec("docker", [
347
- ...composeArgs(config),
348
- "ps",
349
- "--format",
350
- "json"
351
- ], {
352
- cwd: config.cwd,
353
- env: config.env
354
- }).stdout.trim();
355
- if (!output) return [];
356
- const ArraySchema = z.array(ContainerInfoSchema);
357
- try {
358
- const direct = ArraySchema.safeParse(JSON.parse(output));
359
- if (direct.success) return direct.data;
360
- const single = ContainerInfoSchema.safeParse(JSON.parse(output));
361
- if (single.success) return [single.data];
362
- } catch (_error) {}
363
- try {
364
- const joined = `[${output.split("\n").join(",")}]`;
365
- const delimited = ArraySchema.safeParse(JSON.parse(joined));
366
- return delimited.success ? delimited.data : [];
367
- } catch (_error) {
368
- return [];
369
- }
370
- }
371
- //#endregion
372
- //#region src/docker-check/health.ts
373
- /** Look up the health status of a specific service from container info. */
374
- function getContainerHealth(containers, serviceName) {
375
- return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
376
- }
377
- /** Run a single HTTP health check, returning true if the validator passes. */
378
- async function checkHttpHealth(executor, check) {
379
- try {
380
- const response = await executor.fetch(check.url);
381
- return await check.validate(response);
382
- } catch (_error) {
383
- return false;
384
- }
385
- }
386
- //#endregion
387
- //#region src/docker-check/check.ts
388
- const DEFAULT_TIMEOUT_MS = 12e4;
389
- const DEFAULT_POLL_INTERVAL_MS = 5e3;
390
- /**
391
- * Fill any compose interpolation variable that is still unset with an empty
392
- * string, so `${FOO}` with no default stops warning on every compose call —
393
- * `up` and, after the check has already passed, teardown. See
394
- * `fillUnsetVariables` for why empty (not a placeholder) and why compose itself
395
- * decides which variables need it.
396
- *
397
- * This runs here rather than in the `docker:check` command so the env reaches
398
- * every compose invocation *and* the smoketest through one `CheckConfig`, and so
399
- * programmatic consumers of the `docker-check` export get it too.
400
- */
401
- function withFilledVariables(executor, config) {
402
- const filled = fillUnsetVariables(executor, config.compose);
403
- const names = Object.keys(filled);
404
- if (names.length === 0) return config;
405
- executor.log(`Unset compose variables set to an empty string: ${names.join(", ")}. To supply real values, put them in ${config.compose.envFile ?? "docker-compose.check.env"} beside the compose file.`);
406
- return {
407
- ...config,
408
- compose: {
409
- ...config.compose,
410
- env: {
411
- ...config.compose.env,
412
- ...filled
413
- }
414
- }
415
- };
416
- }
417
- /** Probe any not-yet-healthy HTTP endpoints, flipping them to healthy in `healthStatus`. */
418
- async function updateHttpHealth(executor, healthChecks, healthStatus) {
419
- for (const check of healthChecks) {
420
- if (healthStatus.get(check.name)) continue;
421
- if (await checkHttpHealth(executor, check)) {
422
- healthStatus.set(check.name, true);
423
- executor.log(`${check.name} is healthy!`);
424
- }
425
- }
426
- }
427
- /**
428
- * Run the configured smoketest (if any). Returns a failure `CheckResult` when
429
- * the smoketest fails (after dumping logs and cleaning up), or `null` on success
430
- * or when no smoketest is configured.
431
- */
432
- function runSmoketestStep(executor, config, cleanup, startTime) {
433
- if (!config.smoketest) return null;
434
- const { compose } = config;
435
- executor.log(`Running smoketest: ${config.smoketest.join(" ")}`);
436
- try {
437
- runSmoketest(executor, config);
438
- executor.log("Smoketest passed!");
439
- return null;
440
- } catch (error) {
441
- const message = error instanceof Error ? error.message : String(error);
442
- executor.logError(`Smoketest failed: ${message}`);
443
- for (const service of compose.services) composeLogs(executor, compose, service);
444
- cleanup();
445
- return {
446
- success: false,
447
- reason: "smoketest-failed",
448
- message,
449
- elapsedMs: executor.now() - startTime
450
- };
451
- }
452
- }
453
- /** Run the full Docker check lifecycle. */
454
- async function runDockerCheck(executor, rawConfig) {
455
- const config = withFilledVariables(executor, rawConfig);
456
- const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
457
- const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
458
- const { compose } = config;
459
- const vc = { verbose: config.verbose ?? false };
460
- debug(vc, `Compose files: ${compose.composeFiles.join(", ")}`);
461
- debug(vc, `Services: ${compose.services.join(", ")}`);
462
- debug(vc, `Health checks: ${config.healthChecks.map((c) => c.name).join(", ") || "(none)"}`);
463
- debug(vc, `Timeout: ${String(timeoutMs)}ms, poll interval: ${String(pollIntervalMs)}ms`);
464
- const cleanup = () => composeDown(executor, compose);
465
- let cleaningUp = false;
466
- const gracefulShutdown = () => {
467
- if (cleaningUp) {
468
- executor.log("Cleanup in progress, please wait...");
469
- return;
470
- }
471
- cleaningUp = true;
472
- executor.log("Interrupted — shutting down compose stack...");
473
- cleanup();
474
- process.exit(1);
475
- };
476
- const disposeInt = executor.onSignal("SIGINT", gracefulShutdown);
477
- const disposeTerm = executor.onSignal("SIGTERM", gracefulShutdown);
478
- try {
479
- if (config.buildCommand) {
480
- executor.log("Building images...");
481
- buildImages(executor, config);
482
- }
483
- executor.log("Starting compose stack...");
484
- const startTime = executor.now();
485
- try {
486
- composeUp(executor, compose);
487
- } catch (error) {
488
- const message = error instanceof Error ? error.message : String(error);
489
- executor.logError(`Compose stack failed to start — dumping logs`);
490
- for (const service of compose.services) composeLogs(executor, compose, service);
491
- cleanup();
492
- return {
493
- success: false,
494
- reason: "startup-failed",
495
- message,
496
- elapsedMs: executor.now() - startTime
497
- };
498
- }
499
- executor.log(`Waiting for stack to be healthy (max ${timeoutMs / 1e3}s)...`);
500
- let lastStatusLogTime = startTime;
501
- const healthStatus = new Map(config.healthChecks.map((c) => [c.name, false]));
502
- while (executor.now() - startTime < timeoutMs) {
503
- const containers = composePs(executor, compose);
504
- debug(vc, `compose ps: ${containers.length} container(s)`);
505
- let allContainersHealthy = true;
506
- for (const service of compose.services) {
507
- const status = getContainerHealth(containers, service);
508
- debug(vc, `Container ${service}: ${status || "(no healthcheck)"}`);
509
- if (status === "unhealthy") {
510
- executor.logError(`Container ${service} is unhealthy`);
511
- composeLogs(executor, compose, service);
512
- cleanup();
513
- return {
514
- success: false,
515
- reason: "unhealthy-container",
516
- message: service,
517
- elapsedMs: executor.now() - startTime
518
- };
519
- }
520
- if (status === "starting") allContainersHealthy = false;
521
- }
522
- await updateHttpHealth(executor, config.healthChecks, healthStatus);
523
- if (allContainersHealthy && [...healthStatus.values()].every(Boolean)) {
524
- const smoketestFailure = runSmoketestStep(executor, config, cleanup, startTime);
525
- if (smoketestFailure) return smoketestFailure;
526
- executor.log("Check successful! All systems operational.");
527
- cleanup();
528
- return {
529
- success: true,
530
- elapsedMs: executor.now() - startTime
531
- };
532
- }
533
- const now = executor.now();
534
- if (now - lastStatusLogTime >= 5e3) {
535
- lastStatusLogTime = now;
536
- const elapsed = Math.floor((now - startTime) / 1e3);
537
- const parts = [compose.services.map((s) => `${s}=${getContainerHealth(containers, s)}`).join(", "), [...healthStatus.entries()].map(([name, ok]) => `${name}=${ok ? "OK" : "Pending"}`).join(", ")].filter(Boolean).join(" | ");
538
- executor.log(`Waiting... (${elapsed}s elapsed). ${parts}`);
539
- }
540
- await executor.sleep(pollIntervalMs);
541
- }
542
- executor.logError("Timeout waiting for stack to become healthy");
543
- for (const service of compose.services) composeLogs(executor, compose, service);
544
- cleanup();
545
- return {
546
- success: false,
547
- reason: "timeout",
548
- message: "Exceeded timeout",
549
- elapsedMs: executor.now() - startTime
550
- };
551
- } catch (error) {
552
- cleanup();
553
- return {
554
- success: false,
555
- reason: "error",
556
- message: error instanceof Error ? error.message : String(error),
557
- elapsedMs: 0
558
- };
559
- } finally {
560
- disposeInt();
561
- disposeTerm();
562
- }
563
- }
564
- //#endregion
565
- //#region src/docker-check/project-name.ts
566
- /**
567
- * Derive the `docker compose --project-name` value for a check run.
568
- *
569
- * Always returns a unique, non-colliding namespace so a stray `compose down -v`
570
- * during the check cannot target volumes belonging to a long-running stack
571
- * sharing the same docker daemon (the failure mode that wiped prod volumes
572
- * when a CI runner bind-mounted /var/run/docker.sock and the check overlay
573
- * inherited the base file's `name:`).
574
- *
575
- * Format: `<sanitized-repo>-check-<suffix>` where suffix prefers a CI run id
576
- * for traceability and falls back to `process.pid` locally so two concurrent
577
- * `bst docker:check` invocations on the same machine don't collide.
578
- */
579
- function deriveProjectName(repoName, env = process.env) {
580
- return `${sanitize(repoName)}-check-${sanitize(env["GITHUB_RUN_ID"] ?? env["CI_RUN_ID"] ?? String(process.pid))}`;
581
- }
582
- function sanitize(name) {
583
- return name.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
584
- }
585
- //#endregion
586
- export { composeArgs as a, composePs as c, lintComposeFiles as d, debug as f, redact as h, getContainerHealth as i, composeUp as l, isEnvVerbose as m, runDockerCheck as n, composeDown as o, debugExec as p, checkHttpHealth as r, composeLogs as s, deriveProjectName as t, createRealExecutor as u };