@tdk-landscape/tdk-cli-core 1.3.155 → 1.3.156

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.
@@ -6,24 +6,29 @@ import { join, normalize, relative } from "node:path";
6
6
  import chalk from "chalk";
7
7
  import { Command } from "commander";
8
8
  import { hasVerdaccioLicense } from "../generator/extension-fetch.js";
9
+ import { readProjectConfig } from "../generator/template-engine.js";
9
10
  import { DEVCONTAINER_DOCKER_FIX, detectHost, isContainerHost, WEBCONTAINER_DOCS, WEBCONTAINER_UP_MESSAGE, } from "../utils/agent-host.js";
10
11
  import { projectNeedsBun } from "../utils/bun-requirement.js";
11
12
  import { AUTOGENERATED_DIR, CONTAINER_RUNTIME_CHECK, MASTER_CONFIG_FILES, PACKAGE_JSON, REQUIRED_PACKAGE_SCRIPTS, } from "../utils/constants.js";
12
13
  import { isPathDiscovered, readDiscoveryPaths } from "../utils/discovery-paths.js";
14
+ import { checkCrashLoopingContainers } from "../utils/doctor-crashloop.js";
15
+ import { applyDoctorFixes, checkDuplicateTdkInstalls, hasFailedActions, planDoctorFixes, } from "../utils/doctor-fix.js";
13
16
  import { checkDiskSpace, dockerDesktopStuck } from "../utils/doctor-host.js";
14
17
  import { collectDoctorChecks, createDoctorReport, getDoctorExitCode, } from "../utils/doctor-report.js";
15
18
  import { checkHealthRoutes, checkIngressPorts, checkPrivateNpmRegistry, checkTiltResourceHealth, projectConfigEnablesVerdaccio, summarizeAdvertisedProbes, summarizeServiceProbes, summarizeSmokeResults, } from "../utils/doctor-runtime.js";
16
19
  import { checkStarlarkLoadExports } from "../utils/doctor-starlark.js";
17
20
  import { checkDockerNetworkCapacity, checkDuplicateResourceNames, checkDuplicateResourcePorts, checkFrontendBackendUrls, checkMigrationsInApi, checkNatsBroker, checkPrismaConsistency, checkResourcePackageJson, checkServiceUrlPorts, checkSharedPlatformPostgres, checkSharedStackRoutes, checkTiltInstances, } from "../utils/doctor-wiring.js";
21
+ import { findProjectTiltUpPids } from "../utils/down-cleanup.js";
18
22
  import { validateEnvFile } from "../utils/env-validator.js";
19
23
  import { execAsync, isExecTimeout } from "../utils/exec-async.js";
20
24
  import { formatCount } from "../utils/formatting.js";
21
25
  import { getHostPortPlan, resolveRuntimeIngressPort } from "../utils/host-port-config.js";
22
26
  import { createHostPortPlan } from "../utils/host-port-plan.js";
23
27
  import { findProjectRoot, getPackageVersion } from "../utils/paths.js";
28
+ import { promptConfirm } from "../utils/prompt.js";
24
29
  import { EnvUnreadableError, envSecretValues, redactSecrets, redactValue, } from "../utils/secret-redaction.js";
25
30
  import { checkCircularDependencies, checkDependsOnShape, checkSchemaVersions, checkServicePorts, } from "../utils/service-config-checks.js";
26
- import { buildAdvertisedTargets, buildHealthTargets, pingHealthTargets, } from "../utils/service-urls.js";
31
+ import { buildAdvertisedTargets, buildHealthTargets, getProjectName, pingHealthTargets, } from "../utils/service-urls.js";
27
32
  import { discoverResourcesFromRoot } from "../utils/services.js";
28
33
  import { buildSmokePlans, readOnlySmokePlans, runSmokePlans } from "../utils/smoke.js";
29
34
  import { evaluateTdkVersionFloor } from "../utils/tdk-version.js";
@@ -978,6 +983,26 @@ async function checkAdvertisedEndpoints(timeoutMs, ingressPort) {
978
983
  }
979
984
  return summarizeAdvertisedProbes(await pingHealthTargets(targets, timeoutMs));
980
985
  }
986
+ function printFixActions(actions, dryRun) {
987
+ if (actions.length === 0) {
988
+ console.log(`${chalk.green("✓")} Nothing to repair\n`);
989
+ return;
990
+ }
991
+ for (const action of actions) {
992
+ const label = action.status === "planned"
993
+ ? `${chalk.cyan("would run")}`
994
+ : action.status === "applied"
995
+ ? chalk.green("ran")
996
+ : action.status === "failed"
997
+ ? chalk.red("failed")
998
+ : chalk.gray("skipped");
999
+ const detail = action.status === "planned" ? "" : ` (${action.reason})`;
1000
+ console.log(`${label}: ${action.command}${detail}`);
1001
+ }
1002
+ if (dryRun)
1003
+ console.log(chalk.gray("\nDry run: nothing was changed."));
1004
+ console.log("");
1005
+ }
981
1006
  export const doctorCommand = new Command("doctor")
982
1007
  .description("Check environment readiness for TDK")
983
1008
  .option("--json", "Output a versioned JSON readiness report", false)
@@ -991,6 +1016,9 @@ export const doctorCommand = new Command("doctor")
991
1016
  })
992
1017
  .option("--no-ping", "Skip pinging running services' /health endpoints")
993
1018
  .option("--strict", "Fail warnings such as a WSL project under /mnt/c")
1019
+ .option("--fix", "Repair this project's unused Docker networks and leftover doctor probes", false)
1020
+ .option("--dry-run", "With --fix: list the repairs without changing anything", false)
1021
+ .option("-y, --yes", "With --fix: apply every repair without asking", false)
994
1022
  .option("--ping-timeout <ms>", "Per-service ping timeout in milliseconds", String(DEFAULT_PING_TIMEOUT_MS))
995
1023
  .action(async (options) => {
996
1024
  // Validate before running any probe; parseInt would silently accept "100ms" or "1.5".
@@ -1005,6 +1033,14 @@ export const doctorCommand = new Command("doctor")
1005
1033
  process.exit(2);
1006
1034
  return;
1007
1035
  }
1036
+ if ((options.dryRun || options.yes) && !options.fix) {
1037
+ const message = `${options.dryRun ? "--dry-run" : "--yes"} only applies with --fix`;
1038
+ if (options.json)
1039
+ console.log(JSON.stringify(createDoctorReport([], false, [{ code: "USAGE", message }])));
1040
+ console.error(message);
1041
+ process.exit(2);
1042
+ return;
1043
+ }
1008
1044
  if (!options.json) {
1009
1045
  console.log("TDK doctor checks your local Docker + Tilt development environment; it does not check cluster deployments.");
1010
1046
  }
@@ -1051,6 +1087,51 @@ export const doctorCommand = new Command("doctor")
1051
1087
  console.log("Checking environment...\n");
1052
1088
  }
1053
1089
  const projectRoot = findProjectRoot();
1090
+ // Repairs run before the checks so the checks report the state after the fix.
1091
+ let fixActions;
1092
+ if (options.fix) {
1093
+ try {
1094
+ let projectName;
1095
+ if (projectRoot) {
1096
+ try {
1097
+ projectName = readProjectConfig(projectRoot).project.name;
1098
+ }
1099
+ catch {
1100
+ // An unreadable config is reported by the project checks below.
1101
+ }
1102
+ }
1103
+ const tiltPids = projectRoot
1104
+ ? findProjectTiltUpPids(join(projectRoot, ".tdk", ".tdk-out", "Tiltfile"))
1105
+ : [];
1106
+ const planned = await planDoctorFixes(projectName, undefined, tiltPids.length > 0
1107
+ ? `Tilt is running for this project (pid ${tiltPids.join(", ")}); run tdk down first`
1108
+ : undefined);
1109
+ fixActions = await applyDoctorFixes(planned, {
1110
+ dryRun: options.dryRun,
1111
+ confirm: options.yes || (!options.json && process.stdin.isTTY)
1112
+ ? async (action) => options.yes ||
1113
+ (await promptConfirm({
1114
+ message: `Run \`${action.command}\` (${action.check}, ${action.reason})?`,
1115
+ }))
1116
+ : undefined,
1117
+ });
1118
+ }
1119
+ catch (error) {
1120
+ const reason = error instanceof Error ? error.message : String(error);
1121
+ fixActions = [
1122
+ {
1123
+ id: "plan-fixes",
1124
+ check: "Docker networks",
1125
+ scope: "project",
1126
+ command: "docker network ls",
1127
+ status: "failed",
1128
+ reason: `could not list Docker networks: ${reason.split(/\r?\n/)[0]}`,
1129
+ },
1130
+ ];
1131
+ }
1132
+ if (!options.json)
1133
+ printFixActions(fixActions, options.dryRun);
1134
+ }
1054
1135
  let hostPortPlan;
1055
1136
  let portPlanError;
1056
1137
  try {
@@ -1094,6 +1175,7 @@ export const doctorCommand = new Command("doctor")
1094
1175
  () => checkPublishedBindAddress(process.env, options.strict),
1095
1176
  // Each project needs several networks; a full address pool fails `tdk up` late.
1096
1177
  () => checkDockerNetworkCapacity(),
1178
+ () => checkDuplicateTdkInstalls(),
1097
1179
  ];
1098
1180
  const projectChecks = [
1099
1181
  () => checkTdkVersion(),
@@ -1134,6 +1216,24 @@ export const doctorCommand = new Command("doctor")
1134
1216
  },
1135
1217
  // Runtime checks: skip gracefully if the stack isn't started yet.
1136
1218
  checkContainerResourceHealth,
1219
+ // A container restarted thousands of times is invisible in `docker ps` until someone looks.
1220
+ () => {
1221
+ const root = findProjectRoot() ?? process.cwd();
1222
+ let secrets = [];
1223
+ try {
1224
+ secrets = envSecretValues(root);
1225
+ }
1226
+ catch {
1227
+ // Without the secret values, log lines cannot be shown safely.
1228
+ return {
1229
+ name: "Crash-looping containers",
1230
+ didPass: true,
1231
+ isSkipped: true,
1232
+ message: "Could not read .env - skipped crash-loop check",
1233
+ };
1234
+ }
1235
+ return checkCrashLoopingContainers(getProjectName(), secrets);
1236
+ },
1137
1237
  // When Tilt is up, surface red resources (Traefik/apps never started).
1138
1238
  checkTiltResourceHealth,
1139
1239
  ];
@@ -1160,6 +1260,11 @@ export const doctorCommand = new Command("doctor")
1160
1260
  }
1161
1261
  }
1162
1262
  const report = createDoctorReport(orderDoctorResults(results), inProject, errors, hostPortPlan, host);
1263
+ if (fixActions)
1264
+ report.data.actions = fixActions;
1265
+ const fixFailed = fixActions ? hasFailedActions(fixActions) : false;
1266
+ if (fixFailed)
1267
+ report.data.ready = false;
1163
1268
  const exitCode = getDoctorExitCode(report);
1164
1269
  const allPassed = report.data.ready;
1165
1270
  if (options.json) {
@@ -29,6 +29,7 @@ import { buildSmokePlans, formatSmokeFailure, runSmokePlans } from "../utils/smo
29
29
  import { buildStartupReport, formatStartupReport, isStartupStalled, } from "../utils/startup-report.js";
30
30
  import { evaluateTdkVersionFloor } from "../utils/tdk-version.js";
31
31
  import { buildTiltUpArgs, runTilt } from "../utils/tilt.js";
32
+ import { findTiltPortOwner, isForeignTilt } from "../utils/tilt-port-owner.js";
32
33
  import { stopTiltOnPort } from "../utils/tilt-process.js";
33
34
  import { parseTiltPort, resolveTiltPort, secondUpAction, stopTiltForUp, } from "../utils/tilt-startup.js";
34
35
  import { findUnknownServices, resolveOnlySelection } from "../utils/up-only.js";
@@ -369,13 +370,35 @@ export const upCommand = new Command("up")
369
370
  const watchedPort = configuredTiltPort ?? basePort;
370
371
  const candidatePorts = options.only ? [...new Set([basePort, watchedPort])] : [watchedPort];
371
372
  const running = [];
373
+ const foreign = [];
372
374
  for (const candidate of candidatePorts) {
373
- if ((await tiltGetUiResources(candidate)) !== null)
375
+ if ((await tiltGetUiResources(candidate)) === null)
376
+ continue;
377
+ // A Tilt on this port may belong to another project; that is not "this environment is running".
378
+ const owner = findTiltPortOwner(candidate);
379
+ if (owner && isForeignTilt(owner, projectRoot)) {
380
+ foreign.push({ port: candidate, root: owner.root });
381
+ }
382
+ else {
374
383
  running.push(candidate);
384
+ }
375
385
  }
386
+ if (foreign.length > 0 && configuredTiltPort !== undefined) {
387
+ const other = foreign[0];
388
+ const message = `TILT_PORT=${other?.port} is used by another project's Tilt (${other?.root}). Choose a free port, or run tdk down in that project.`;
389
+ emit?.({ ok: false }, [{ code: "TILT_PORT_IN_USE", message }]);
390
+ showErrorAndExit(message);
391
+ }
392
+ if (!options.quiet) {
393
+ for (const other of foreign) {
394
+ console.log(chalk.yellow(`⚠️ Tilt on port ${other.port} belongs to another project (${other.root}); this project will use another port.`));
395
+ }
396
+ }
397
+ // --force replaces this project's Tilt; it must never stop another project's.
398
+ const force = options.force && foreign.length === 0;
376
399
  const decision = secondUpAction({
377
400
  runningPorts: running,
378
- force: options.force,
401
+ force,
379
402
  only: Boolean(options.only),
380
403
  });
381
404
  if (decision.action === "already-running") {
@@ -400,7 +423,7 @@ export const upCommand = new Command("up")
400
423
  }
401
424
  const resolution = await resolveTiltPort({
402
425
  configuredPort: configuredTiltPort,
403
- force: options.force,
426
+ force,
404
427
  basePort,
405
428
  findAvailablePort,
406
429
  });
@@ -410,7 +433,7 @@ export const upCommand = new Command("up")
410
433
  console.log(chalk.blue(`🔄 Auto-switching to port ${port}\n`));
411
434
  }
412
435
  process.env.TILT_PORT = port.toString();
413
- await stopTiltForUp({ force: options.force, quiet: options.quiet, port }, {
436
+ await stopTiltForUp({ force, quiet: options.quiet, port }, {
414
437
  stop: stopTiltOnPort,
415
438
  log: (message) => console.log(chalk.yellow(message)),
416
439
  wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
@@ -505,6 +505,26 @@ export async function generateMasterConfigs(projectRoot, options = {}) {
505
505
  // vendor a working copy into .tdk/.tdk-out/tdk-cli-ext/ at generation time.
506
506
  // This makes `tdk up` work offline and on any machine - no hardcoded paths,
507
507
  // no dependency on a private GitHub repo.
508
+ // The engine's own unit tests (for example wake-gateway/decide.test.ts, which imports bun:test) are
509
+ // not needed to run a stack. Vendoring them under .tdk/.tdk-out makes a Node project's test runner
510
+ // (Vitest, Jest) discover them and fail, so they are left out of the vendored copy.
511
+ const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
512
+ export function isTestFile(filePath) {
513
+ return TEST_FILE_PATTERN.test(filePath);
514
+ }
515
+ function removeVendoredTestFiles(dir) {
516
+ if (!fs.existsSync(dir))
517
+ return;
518
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
519
+ const entryPath = path.join(dir, entry.name);
520
+ if (entry.isDirectory()) {
521
+ removeVendoredTestFiles(entryPath);
522
+ }
523
+ else if (isTestFile(entryPath)) {
524
+ fs.rmSync(entryPath, { force: true });
525
+ }
526
+ }
527
+ }
508
528
  const TDK_EXTENSION_DIRS = ["engine", "discovery", "specs", "ext"];
509
529
  const PROJECT_RUNTIME_ASSET_DIRS = [["shared-platform-engineering", "docker-templates"]];
510
530
  const REQUIRED_DOCKER_TEMPLATE_FILES = [
@@ -618,8 +638,11 @@ async function vendorTdkExtension(projectRoot) {
618
638
  }
619
639
  fs.copyFileSync(path.join(sourceRoot, "Tiltfile"), path.join(vendoredDir, "Tiltfile"));
620
640
  for (const dir of TDK_EXTENSION_DIRS) {
641
+ // A project vendored by an older TDK already holds test files; cpSync only adds, so drop them.
642
+ removeVendoredTestFiles(path.join(vendoredDir, dir));
621
643
  fs.cpSync(path.join(sourceRoot, dir), path.join(vendoredDir, dir), {
622
644
  recursive: true,
645
+ filter: (source) => !isTestFile(source),
623
646
  });
624
647
  }
625
648
  console.log(`✓ Vendored TDK extension → .tdk/.tdk-out/tdk-cli-ext/`);
@@ -0,0 +1,160 @@
1
+ // Copyright (c) 2026 TDK Landscape contributors
2
+ // SPDX-License-Identifier: MIT
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { toComposeProjectPrefix } from "./doctor-runtime.js";
6
+ import { redactSecrets } from "./secret-redaction.js";
7
+ const execFilePromise = promisify(execFile);
8
+ const DOCKER_TIMEOUT_MS = 10_000;
9
+ /** Restarts that make a container a crash loop rather than a one-off. */
10
+ export const CRASH_LOOP_RESTARTS = 5;
11
+ /** A container that restarted this recently is still looping; an old count on a healthy container is history. */
12
+ const RECENT_START_MS = 10 * 60_000;
13
+ const LOG_LINES = 3;
14
+ const LOG_LINE_MAX = 200;
15
+ const runDocker = async (args) => {
16
+ const { stdout, stderr } = await execFilePromise("docker", args, {
17
+ encoding: "utf-8",
18
+ timeout: DOCKER_TIMEOUT_MS,
19
+ windowsHide: true,
20
+ });
21
+ return `${stdout}${stderr}`;
22
+ };
23
+ /** Whether this container's compose project belongs to the TDK project, however the engine spelled the separator. */
24
+ export function belongsToProject(composeProject, projectName) {
25
+ const prefix = toComposeProjectPrefix(projectName).toLowerCase();
26
+ const label = composeProject.replace(/-/g, "_").toLowerCase();
27
+ return label === prefix || label.startsWith(`${prefix}_`);
28
+ }
29
+ export function isCrashLooping(state, now = Date.now()) {
30
+ if (state.status === "restarting")
31
+ return true;
32
+ if (state.restartCount < CRASH_LOOP_RESTARTS)
33
+ return false;
34
+ if (state.status === "running") {
35
+ const started = Date.parse(state.startedAt);
36
+ return Number.isNaN(started) || now - started < RECENT_START_MS;
37
+ }
38
+ // Stopped: only a recent failure is a loop that gave up (restart policy exhausted). A container stopped by hand
39
+ // keeps its old restart count, and that is history.
40
+ const finished = Date.parse(state.finishedAt ?? "");
41
+ return state.exitCode !== 0 && !Number.isNaN(finished) && now - finished < RECENT_START_MS;
42
+ }
43
+ /** A hint for a cause that the last log lines name. Null when they do not say. */
44
+ export function crashLoopHint(logs, state) {
45
+ const database = /database "([^"]+)" does not exist/i.exec(logs);
46
+ if (database) {
47
+ return `database "${database[1]}" does not exist: re-run the project's database provisioning (tdk up) or create it`;
48
+ }
49
+ if (/Cannot find module|MODULE_NOT_FOUND|module not found/i.test(logs)) {
50
+ return "a module is missing from the image: rebuild it (tdk up --force) after checking the resource's dependencies";
51
+ }
52
+ if (/EADDRINUSE|address already in use/i.test(logs)) {
53
+ return "the port inside the container is already in use: check for two processes started by the same command";
54
+ }
55
+ if (/ECONNREFUSED|connection refused|could not connect/i.test(logs)) {
56
+ return "a dependency is not reachable: check that the service it connects to is running and healthy";
57
+ }
58
+ if (/(is not set|is required|missing required|undefined).*(env|environment|variable)|environment variable.*(missing|not set|required)/i.test(logs)) {
59
+ return "a required environment variable is missing: check the project's .env";
60
+ }
61
+ if (state.exitCode === 137)
62
+ return "killed (exit 137), usually out of memory: raise the container's memory limit";
63
+ return null;
64
+ }
65
+ function parseStates(output) {
66
+ const states = [];
67
+ for (const line of output.split(/\r?\n/)) {
68
+ const [name, restarts, status, startedAt, exitCode, finishedAt] = line.split("\t");
69
+ if (!name || restarts === undefined)
70
+ continue;
71
+ states.push({
72
+ name: name.replace(/^\//, ""),
73
+ restartCount: Number(restarts) || 0,
74
+ status: status ?? "",
75
+ startedAt: startedAt ?? "",
76
+ exitCode: Number(exitCode) || 0,
77
+ finishedAt: finishedAt ?? "",
78
+ });
79
+ }
80
+ return states;
81
+ }
82
+ /** Containers of this project that keep restarting, with their last log lines and a cause hint when one is clear. */
83
+ export async function checkCrashLoopingContainers(projectName, secrets = [], run = runDocker, now = Date.now()) {
84
+ const name = "Crash-looping containers";
85
+ let candidates;
86
+ try {
87
+ const listing = await run([
88
+ "ps",
89
+ "-a",
90
+ "--format",
91
+ '{{.ID}}\t{{.Label "com.docker.compose.project"}}',
92
+ ]);
93
+ candidates = listing
94
+ .split(/\r?\n/)
95
+ .map((line) => line.split("\t"))
96
+ .filter(([id, project]) => id && project && belongsToProject(project, projectName))
97
+ .map(([id]) => id);
98
+ }
99
+ catch {
100
+ return {
101
+ name,
102
+ didPass: true,
103
+ isSkipped: true,
104
+ message: "Docker not available - skipped crash-loop check",
105
+ };
106
+ }
107
+ if (candidates.length === 0) {
108
+ return { name, didPass: true, isSkipped: true, message: "No project containers to check" };
109
+ }
110
+ let states;
111
+ try {
112
+ states = parseStates(await run([
113
+ "inspect",
114
+ "--format",
115
+ "{{.Name}}\t{{.RestartCount}}\t{{.State.Status}}\t{{.State.StartedAt}}\t{{.State.ExitCode}}\t{{.State.FinishedAt}}",
116
+ ...candidates,
117
+ ]));
118
+ }
119
+ catch {
120
+ // A container removed between ps and inspect fails the whole batch; that is not a finding.
121
+ return {
122
+ name,
123
+ didPass: true,
124
+ isSkipped: true,
125
+ message: "Containers changed while checking - skipped crash-loop check",
126
+ };
127
+ }
128
+ const looping = states.filter((state) => isCrashLooping(state, now));
129
+ if (looping.length === 0) {
130
+ return { name, didPass: true, message: "No project container is crash-looping" };
131
+ }
132
+ const details = [];
133
+ const hints = new Set();
134
+ for (const state of looping) {
135
+ let logs = "";
136
+ try {
137
+ logs = await run(["logs", "--tail", String(LOG_LINES), state.name]);
138
+ }
139
+ catch {
140
+ // The container may have just been removed; report it without logs.
141
+ }
142
+ const lines = redactSecrets(logs, secrets)
143
+ .split(/\r?\n/)
144
+ .map((line) => line.trim().slice(0, LOG_LINE_MAX))
145
+ .filter(Boolean);
146
+ const hint = crashLoopHint(logs, state);
147
+ if (hint)
148
+ hints.add(hint);
149
+ details.push([
150
+ `${state.name}: ${state.status}, restarted ${state.restartCount} times`,
151
+ ...lines.map((line) => ` | ${line}`),
152
+ ].join("\n "));
153
+ }
154
+ return {
155
+ name,
156
+ didPass: false,
157
+ message: `${looping.length} container${looping.length === 1 ? " is" : "s are"} crash-looping:\n ${details.join("\n ")}`,
158
+ fix: [...hints, "Read the full log: docker logs <container>"].join("; "),
159
+ };
160
+ }
@@ -0,0 +1,211 @@
1
+ // Copyright (c) 2026 TDK Landscape contributors
2
+ // SPDX-License-Identifier: MIT
3
+ import { execFile } from "node:child_process";
4
+ import { realpathSync } from "node:fs";
5
+ import { promisify } from "node:util";
6
+ import { projectNetworkNames } from "./down-cleanup.js";
7
+ import { findAllOnPath } from "./which.js";
8
+ const execFilePromise = promisify(execFile);
9
+ const DOCKER_TIMEOUT_MS = 15_000;
10
+ /** The networks `tdk doctor` creates to test capacity; an interrupted run leaves one behind. */
11
+ const PROBE_NETWORK = /^tdk_doctor_probe_(\d+)$/;
12
+ const runDocker = async (args) => {
13
+ const { stdout } = await execFilePromise("docker", args, {
14
+ encoding: "utf-8",
15
+ timeout: DOCKER_TIMEOUT_MS,
16
+ windowsHide: true,
17
+ });
18
+ return stdout;
19
+ };
20
+ function firstLine(error) {
21
+ const stderr = String(error.stderr ?? error).trim();
22
+ return stderr.split(/\r?\n/)[0] ?? "";
23
+ }
24
+ export function createDockerFixDeps(run = runDocker) {
25
+ return {
26
+ async listNetworks() {
27
+ const names = (await run(["network", "ls", "--format", "{{.Name}}"]))
28
+ .split(/\r?\n/)
29
+ .map((line) => line.trim())
30
+ .filter(Boolean);
31
+ // One inspect per network: another process (a doctor probe, `tdk down`) can remove one mid-listing, and a
32
+ // batched inspect would then fail for all of them.
33
+ const inspected = await Promise.all(names.map(async (name) => {
34
+ try {
35
+ const count = await run([
36
+ "network",
37
+ "inspect",
38
+ "--format",
39
+ "{{len .Containers}}",
40
+ name,
41
+ ]);
42
+ return { name, containers: Number(count.trim()) || 0 };
43
+ }
44
+ catch (error) {
45
+ if (/no such network|not found/i.test(String(error)))
46
+ return null;
47
+ throw error;
48
+ }
49
+ }));
50
+ return inspected.filter((network) => network !== null);
51
+ },
52
+ async removeNetwork(name) {
53
+ try {
54
+ await run(["network", "rm", name]);
55
+ }
56
+ catch (error) {
57
+ throw new Error(firstLine(error));
58
+ }
59
+ },
60
+ isProcessAlive(pid) {
61
+ try {
62
+ process.kill(pid, 0);
63
+ return true;
64
+ }
65
+ catch (error) {
66
+ // EPERM means the process exists but belongs to someone else.
67
+ return error.code === "EPERM";
68
+ }
69
+ },
70
+ };
71
+ }
72
+ export const dockerFixDeps = createDockerFixDeps();
73
+ /**
74
+ * What `--fix` would do. Only unused networks that are either a leftover doctor probe or one of this project's own
75
+ * (the exact names the engine creates, and only while `projectBusy` is unset) are planned; another project's networks are never listed here.
76
+ */
77
+ export async function planDoctorFixes(projectName, deps = dockerFixDeps, projectBusy) {
78
+ const networks = await deps.listNetworks();
79
+ const actions = [];
80
+ const own = new Set(projectName ? projectNetworkNames(projectName) : []);
81
+ for (const network of networks) {
82
+ const probe = PROBE_NETWORK.exec(network.name);
83
+ let scope;
84
+ let check;
85
+ if (probe) {
86
+ scope = "probe";
87
+ check = "Orphaned probe networks";
88
+ }
89
+ else if (own.has(network.name)) {
90
+ scope = "project";
91
+ check = "Docker networks";
92
+ }
93
+ else {
94
+ continue;
95
+ }
96
+ const action = {
97
+ id: `remove-network:${network.name}`,
98
+ check,
99
+ scope,
100
+ command: `docker network rm ${network.name}`,
101
+ status: "planned",
102
+ reason: "unused network",
103
+ };
104
+ if (network.containers > 0) {
105
+ action.status = "skipped";
106
+ action.reason = `${network.containers} container(s) still attached`;
107
+ }
108
+ else if (scope === "project" && projectBusy) {
109
+ // A starting stack creates its networks before any container joins them, so "unused" is not safe to remove.
110
+ action.status = "skipped";
111
+ action.reason = projectBusy;
112
+ }
113
+ else if (probe && deps.isProcessAlive(Number(probe[1]))) {
114
+ // The doctor run that created it may still be using it.
115
+ action.status = "skipped";
116
+ action.reason = `probe of a running process (pid ${probe[1]})`;
117
+ }
118
+ actions.push(action);
119
+ }
120
+ return actions;
121
+ }
122
+ /** Run the planned actions in order. A declined or failed action never stops the rest, so a re-run converges. */
123
+ export async function applyDoctorFixes(actions, options, deps = dockerFixDeps) {
124
+ const results = [];
125
+ for (const action of actions) {
126
+ if (action.status !== "planned" || options.dryRun) {
127
+ results.push(action);
128
+ continue;
129
+ }
130
+ if (!options.confirm) {
131
+ results.push({
132
+ ...action,
133
+ status: "skipped",
134
+ reason: "confirmation required; run in a terminal or pass --yes",
135
+ });
136
+ continue;
137
+ }
138
+ let confirmed = false;
139
+ try {
140
+ confirmed = await options.confirm(action);
141
+ }
142
+ catch {
143
+ // No terminal to ask on.
144
+ }
145
+ if (!confirmed) {
146
+ results.push({ ...action, status: "skipped", reason: "not confirmed" });
147
+ continue;
148
+ }
149
+ const name = action.id.slice("remove-network:".length);
150
+ try {
151
+ await deps.removeNetwork(name);
152
+ results.push({ ...action, status: "applied", reason: "removed" });
153
+ }
154
+ catch (error) {
155
+ results.push({ ...action, status: "failed", reason: firstLine(error) });
156
+ }
157
+ }
158
+ return results;
159
+ }
160
+ /** Whether any action leaves a problem behind that a re-run could still repair. */
161
+ export function hasFailedActions(actions) {
162
+ return actions.some((action) => action.status === "failed");
163
+ }
164
+ async function readTdkVersion(path) {
165
+ try {
166
+ const { stdout } = await execFilePromise(path, ["--version"], {
167
+ encoding: "utf-8",
168
+ timeout: 5000,
169
+ windowsHide: true,
170
+ });
171
+ return stdout.trim().split(/\r?\n/)[0] || null;
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ }
177
+ /** Every distinct `tdk` on PATH (symlinks to one binary count once), in PATH order. */
178
+ export async function findTdkInstalls(paths = findAllOnPath("tdk"), readVersion = readTdkVersion) {
179
+ const seen = new Set();
180
+ const installs = [];
181
+ for (const path of paths) {
182
+ let real = path;
183
+ try {
184
+ real = realpathSync(path);
185
+ }
186
+ catch {
187
+ // Keep the PATH entry as is.
188
+ }
189
+ if (seen.has(real))
190
+ continue;
191
+ seen.add(real);
192
+ installs.push({ path, version: await readVersion(path) });
193
+ }
194
+ return installs;
195
+ }
196
+ /** Two `tdk` binaries can disagree (a dashboard or MCP client may launch the older one), so say which is which. */
197
+ export async function checkDuplicateTdkInstalls(installs) {
198
+ const found = installs ?? (await findTdkInstalls());
199
+ if (found.length <= 1) {
200
+ return { name: "TDK installs", didPass: true, message: "One tdk install on PATH" };
201
+ }
202
+ const list = found.map((i) => `${i.path} (${i.version ?? "version unknown"})`).join(", ");
203
+ const versions = new Set(found.map((i) => i.version));
204
+ return {
205
+ name: "TDK installs",
206
+ didPass: false,
207
+ isWarning: true,
208
+ message: `${found.length} tdk installs on PATH${versions.size > 1 ? " with different versions" : ""}: ${list}`,
209
+ fix: "Keep one: remove the others, or run tdk upgrade. The first on PATH is the one your shell runs; tools that launch /usr/local/bin/tdk directly may use another",
210
+ };
211
+ }