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

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.
@@ -12,13 +12,12 @@ import { projectNeedsBun } from "../utils/bun-requirement.js";
12
12
  import { AUTOGENERATED_DIR, CONTAINER_RUNTIME_CHECK, MASTER_CONFIG_FILES, PACKAGE_JSON, REQUIRED_PACKAGE_SCRIPTS, } from "../utils/constants.js";
13
13
  import { isPathDiscovered, readDiscoveryPaths } from "../utils/discovery-paths.js";
14
14
  import { checkCrashLoopingContainers } from "../utils/doctor-crashloop.js";
15
- import { applyDoctorFixes, checkDuplicateTdkInstalls, hasFailedActions, planDoctorFixes, } from "../utils/doctor-fix.js";
15
+ import { applyDoctorFixes, checkDuplicateTdkInstalls, hasFailedActions, planDoctorFixes, projectTiltPids, } from "../utils/doctor-fix.js";
16
16
  import { checkDiskSpace, dockerDesktopStuck } from "../utils/doctor-host.js";
17
17
  import { collectDoctorChecks, createDoctorReport, getDoctorExitCode, } from "../utils/doctor-report.js";
18
18
  import { checkHealthRoutes, checkIngressPorts, checkPrivateNpmRegistry, checkTiltResourceHealth, projectConfigEnablesVerdaccio, summarizeAdvertisedProbes, summarizeServiceProbes, summarizeSmokeResults, } from "../utils/doctor-runtime.js";
19
19
  import { checkStarlarkLoadExports } from "../utils/doctor-starlark.js";
20
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";
22
21
  import { validateEnvFile } from "../utils/env-validator.js";
23
22
  import { execAsync, isExecTimeout } from "../utils/exec-async.js";
24
23
  import { formatCount } from "../utils/formatting.js";
@@ -1018,6 +1017,7 @@ export const doctorCommand = new Command("doctor")
1018
1017
  .option("--strict", "Fail warnings such as a WSL project under /mnt/c")
1019
1018
  .option("--fix", "Repair this project's unused Docker networks and leftover doctor probes", false)
1020
1019
  .option("--dry-run", "With --fix: list the repairs without changing anything", false)
1020
+ .option("--scope <scope>", "With --fix: project (default) or machine, which also removes unused networks of other TDK projects", "project")
1021
1021
  .option("-y, --yes", "With --fix: apply every repair without asking", false)
1022
1022
  .option("--ping-timeout <ms>", "Per-service ping timeout in milliseconds", String(DEFAULT_PING_TIMEOUT_MS))
1023
1023
  .action(async (options) => {
@@ -1033,8 +1033,14 @@ export const doctorCommand = new Command("doctor")
1033
1033
  process.exit(2);
1034
1034
  return;
1035
1035
  }
1036
- if ((options.dryRun || options.yes) && !options.fix) {
1037
- const message = `${options.dryRun ? "--dry-run" : "--yes"} only applies with --fix`;
1036
+ const scopeInvalid = !["project", "machine"].includes(options.scope);
1037
+ const scopeWithoutFix = options.scope !== "project" && !options.fix;
1038
+ if (scopeInvalid || scopeWithoutFix || ((options.dryRun || options.yes) && !options.fix)) {
1039
+ const message = scopeInvalid
1040
+ ? `--scope must be project or machine, got "${options.scope}"`
1041
+ : scopeWithoutFix
1042
+ ? "--scope only applies with --fix"
1043
+ : `${options.dryRun ? "--dry-run" : "--yes"} only applies with --fix`;
1038
1044
  if (options.json)
1039
1045
  console.log(JSON.stringify(createDoctorReport([], false, [{ code: "USAGE", message }])));
1040
1046
  console.error(message);
@@ -1100,12 +1106,11 @@ export const doctorCommand = new Command("doctor")
1100
1106
  // An unreadable config is reported by the project checks below.
1101
1107
  }
1102
1108
  }
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
+ const tiltPids = projectRoot ? projectTiltPids(projectRoot) : [];
1110
+ const planned = await planDoctorFixes(projectName, undefined, {
1111
+ tiltPids,
1112
+ machine: options.scope === "machine",
1113
+ });
1109
1114
  fixActions = await applyDoctorFixes(planned, {
1110
1115
  dryRun: options.dryRun,
1111
1116
  confirm: options.yes || (!options.json && process.stdin.isTTY)
@@ -29,7 +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
+ import { classifyTiltPorts } from "../utils/tilt-port-owner.js";
33
33
  import { stopTiltOnPort } from "../utils/tilt-process.js";
34
34
  import { parseTiltPort, resolveTiltPort, secondUpAction, stopTiltForUp, } from "../utils/tilt-startup.js";
35
35
  import { findUnknownServices, resolveOnlySelection } from "../utils/up-only.js";
@@ -369,20 +369,7 @@ export const upCommand = new Command("up")
369
369
  const basePort = STANDARD_PORTS.tiltUi;
370
370
  const watchedPort = configuredTiltPort ?? basePort;
371
371
  const candidatePorts = options.only ? [...new Set([basePort, watchedPort])] : [watchedPort];
372
- const running = [];
373
- const foreign = [];
374
- for (const candidate of candidatePorts) {
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 {
383
- running.push(candidate);
384
- }
385
- }
372
+ const { running, foreign } = await classifyTiltPorts(candidatePorts, projectRoot, async (candidate) => (await tiltGetUiResources(candidate)) !== null);
386
373
  if (foreign.length > 0 && configuredTiltPort !== undefined) {
387
374
  const other = foreign[0];
388
375
  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.`;
@@ -55,7 +55,8 @@ export function crashLoopHint(logs, state) {
55
55
  if (/ECONNREFUSED|connection refused|could not connect/i.test(logs)) {
56
56
  return "a dependency is not reachable: check that the service it connects to is running and healthy";
57
57
  }
58
- if (/(is not set|is required|missing required|undefined).*(env|environment|variable)|environment variable.*(missing|not set|required)/i.test(logs)) {
58
+ if (/\b(?:environment variable|env var)\b.*\b(?:missing|not set|required|undefined)\b/i.test(logs) ||
59
+ /\b[A-Z][A-Z0-9_]{2,}\b (?:is )?(?:not set|required|missing)\b/.test(logs)) {
59
60
  return "a required environment variable is missing: check the project's .env";
60
61
  }
61
62
  if (state.exitCode === 137)
@@ -1,9 +1,13 @@
1
1
  // Copyright (c) 2026 TDK Landscape contributors
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { execFile } from "node:child_process";
4
- import { realpathSync } from "node:fs";
4
+ import { realpathSync, statfsSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
5
7
  import { promisify } from "node:util";
6
- import { projectNetworkNames } from "./down-cleanup.js";
8
+ import { DISK_WARN_GIB } from "./doctor-host.js";
9
+ import { findProjectTiltUpPids, projectNetworkNames, tdkNetworkOwner, tdkNetworkPrefixes, } from "./down-cleanup.js";
10
+ import { tiltGetUiResources } from "./up-readiness.js";
7
11
  import { findAllOnPath } from "./which.js";
8
12
  const execFilePromise = promisify(execFile);
9
13
  const DOCKER_TIMEOUT_MS = 15_000;
@@ -21,6 +25,51 @@ function firstLine(error) {
21
25
  const stderr = String(error.stderr ?? error).trim();
22
26
  return stderr.split(/\r?\n/)[0] ?? "";
23
27
  }
28
+ const STALE_TILT_MIN_AGE_S = 120;
29
+ /** `ps -o etime=` ("[[dd-]hh:]mm:ss") in seconds. */
30
+ export function parseElapsed(etime) {
31
+ const match = /^\s*(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)\s*$/.exec(etime);
32
+ if (!match)
33
+ return null;
34
+ const [, days = "0", hours = "0", minutes = "0", seconds = "0"] = match;
35
+ return ((Number(days) * 24 + Number(hours)) * 60 + Number(minutes)) * 60 + Number(seconds);
36
+ }
37
+ /**
38
+ * Whether any of `ports` answers as a Tilt UI. A busy Tilt can miss one request, and a wrong "stale" ends in a kill,
39
+ * so a port gets a second try before it counts as silent.
40
+ */
41
+ export async function anyTiltAnswers(ports, probe = tiltGetUiResources, attempts = 2, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
42
+ for (let attempt = 0; attempt < attempts; attempt++) {
43
+ if (attempt > 0)
44
+ await wait(1000);
45
+ for (const port of ports) {
46
+ if ((await probe(port)) !== null)
47
+ return true;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+ async function tiltProcessStatus(pid) {
53
+ if (process.platform === "win32")
54
+ return "unknown";
55
+ try {
56
+ const { stdout: etime } = await execFilePromise("ps", ["-p", String(pid), "-o", "etime="], {
57
+ encoding: "utf-8",
58
+ timeout: 5000,
59
+ });
60
+ const age = parseElapsed(etime);
61
+ // A Tilt that is still starting has not bound its port yet.
62
+ if (age === null || age < STALE_TILT_MIN_AGE_S)
63
+ return "unknown";
64
+ const { stdout } = await execFilePromise("lsof", ["-nP", "-a", "-p", String(pid), "-iTCP", "-sTCP:LISTEN", "-Fn"], { encoding: "utf-8", timeout: 5000 });
65
+ const ports = [...stdout.matchAll(/^n.*:(\d+)$/gm)].map((m) => Number(m[1]));
66
+ return (await anyTiltAnswers(ports)) ? "healthy" : "stale";
67
+ }
68
+ catch {
69
+ // No lsof, or the process is gone: do not guess.
70
+ return "unknown";
71
+ }
72
+ }
24
73
  export function createDockerFixDeps(run = runDocker) {
25
74
  return {
26
75
  async listNetworks() {
@@ -57,6 +106,54 @@ export function createDockerFixDeps(run = runDocker) {
57
106
  throw new Error(firstLine(error));
58
107
  }
59
108
  },
109
+ freeDiskGib() {
110
+ try {
111
+ const stats = statfsSync(homedir());
112
+ return (Number(stats.bavail) * Number(stats.bsize)) / 1024 ** 3;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ },
118
+ async reclaimable() {
119
+ let buildCache = null;
120
+ try {
121
+ const df = await run(["system", "df", "--format", "{{.Type}}\t{{.Reclaimable}}"]);
122
+ for (const line of df.split(/\r?\n/)) {
123
+ const [type, size] = line.split("\t");
124
+ // "1.531GB" or "1.5GB (39%)"; the percentage is of the whole, not useful here.
125
+ if (type === "Build Cache" && size)
126
+ buildCache = size.replace(/\s*\(.*\)/, "").trim();
127
+ }
128
+ }
129
+ catch {
130
+ // Leave unknown.
131
+ }
132
+ let danglingImages = 0;
133
+ try {
134
+ const ids = await run(["images", "-q", "-f", "dangling=true"]);
135
+ danglingImages = ids.split(/\r?\n/).filter(Boolean).length;
136
+ }
137
+ catch {
138
+ // Leave zero.
139
+ }
140
+ return { buildCache, danglingImages };
141
+ },
142
+ async prune(kind) {
143
+ try {
144
+ // Never `volume prune`, never `-a`: only the build cache and untagged images.
145
+ await run(kind === "builder" ? ["builder", "prune", "-f"] : ["image", "prune", "-f"]);
146
+ }
147
+ catch (error) {
148
+ throw new Error(firstLine(error));
149
+ }
150
+ },
151
+ async tiltStatus(pid) {
152
+ return tiltProcessStatus(pid);
153
+ },
154
+ stopProcess(pid) {
155
+ process.kill(pid, "SIGTERM");
156
+ },
60
157
  isProcessAlive(pid) {
61
158
  try {
62
159
  process.kill(pid, 0);
@@ -71,17 +168,66 @@ export function createDockerFixDeps(run = runDocker) {
71
168
  }
72
169
  export const dockerFixDeps = createDockerFixDeps();
73
170
  /**
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.
171
+ * What `--fix` would do: stop this project's stale Tilt; remove unused networks that are a leftover doctor probe or
172
+ * this project's own (the exact names the engine creates, skipped while its Tilt runs). Other TDK projects' unused
173
+ * networks are planned only with `context.machine`, each naming its owner.
76
174
  */
77
- export async function planDoctorFixes(projectName, deps = dockerFixDeps, projectBusy) {
78
- const networks = await deps.listNetworks();
175
+ export async function planDoctorFixes(projectName, deps = dockerFixDeps, context = {}) {
79
176
  const actions = [];
177
+ // A Tilt that has run a while without serving its UI is stale and may be stopped; any other Tilt of this project is
178
+ // running or starting, and its networks are not to be touched.
179
+ const liveTilt = [];
180
+ for (const pid of context.tiltPids ?? []) {
181
+ if ((await deps.tiltStatus(pid)) === "stale") {
182
+ actions.push({
183
+ id: `stop-tilt:${pid}`,
184
+ check: "Tilt processes",
185
+ scope: "project",
186
+ command: `kill -TERM ${pid}`,
187
+ status: "planned",
188
+ reason: "this project's Tilt is running but its UI does not answer",
189
+ });
190
+ }
191
+ else {
192
+ liveTilt.push(pid);
193
+ }
194
+ }
195
+ const projectBusy = liveTilt.length > 0
196
+ ? `Tilt is running for this project (pid ${liveTilt.join(", ")}); run tdk down first`
197
+ : undefined;
198
+ const networks = await deps.listNetworks();
199
+ const freeGib = deps.freeDiskGib();
200
+ if (freeGib !== null && freeGib < DISK_WARN_GIB) {
201
+ const { buildCache, danglingImages } = await deps.reclaimable();
202
+ const low = `${freeGib.toFixed(1)} GB free`;
203
+ if (buildCache && !/^0(\.0+)?\s*B$/i.test(buildCache)) {
204
+ actions.push({
205
+ id: "prune:builder",
206
+ check: "Disk Space",
207
+ scope: "host",
208
+ command: "docker builder prune -f",
209
+ status: "planned",
210
+ reason: `${low}; ${buildCache} of build cache reclaimable`,
211
+ });
212
+ }
213
+ if (danglingImages > 0) {
214
+ actions.push({
215
+ id: "prune:images",
216
+ check: "Disk Space",
217
+ scope: "host",
218
+ command: "docker image prune -f",
219
+ status: "planned",
220
+ reason: `${low}; ${danglingImages} untagged image${danglingImages === 1 ? "" : "s"}`,
221
+ });
222
+ }
223
+ }
80
224
  const own = new Set(projectName ? projectNetworkNames(projectName) : []);
225
+ const prefixes = tdkNetworkPrefixes(networks.map((network) => network.name));
81
226
  for (const network of networks) {
82
227
  const probe = PROBE_NETWORK.exec(network.name);
83
228
  let scope;
84
229
  let check;
230
+ let reason = "unused network";
85
231
  if (probe) {
86
232
  scope = "probe";
87
233
  check = "Orphaned probe networks";
@@ -90,6 +236,11 @@ export async function planDoctorFixes(projectName, deps = dockerFixDeps, project
90
236
  scope = "project";
91
237
  check = "Docker networks";
92
238
  }
239
+ else if (context.machine && tdkNetworkOwner(network.name, prefixes)) {
240
+ scope = "machine";
241
+ check = "Docker networks";
242
+ reason = `unused network of project "${tdkNetworkOwner(network.name, prefixes)}"`;
243
+ }
93
244
  else {
94
245
  continue;
95
246
  }
@@ -99,7 +250,7 @@ export async function planDoctorFixes(projectName, deps = dockerFixDeps, project
99
250
  scope,
100
251
  command: `docker network rm ${network.name}`,
101
252
  status: "planned",
102
- reason: "unused network",
253
+ reason,
103
254
  };
104
255
  if (network.containers > 0) {
105
256
  action.status = "skipped";
@@ -146,9 +297,18 @@ export async function applyDoctorFixes(actions, options, deps = dockerFixDeps) {
146
297
  results.push({ ...action, status: "skipped", reason: "not confirmed" });
147
298
  continue;
148
299
  }
149
- const name = action.id.slice("remove-network:".length);
150
300
  try {
151
- await deps.removeNetwork(name);
301
+ if (action.id.startsWith("prune:")) {
302
+ await deps.prune(action.id === "prune:builder" ? "builder" : "images");
303
+ results.push({ ...action, status: "applied", reason: "pruned" });
304
+ continue;
305
+ }
306
+ if (action.id.startsWith("stop-tilt:")) {
307
+ deps.stopProcess(Number(action.id.slice("stop-tilt:".length)));
308
+ results.push({ ...action, status: "applied", reason: "stopped" });
309
+ continue;
310
+ }
311
+ await deps.removeNetwork(action.id.slice("remove-network:".length));
152
312
  results.push({ ...action, status: "applied", reason: "removed" });
153
313
  }
154
314
  catch (error) {
@@ -209,3 +369,22 @@ export async function checkDuplicateTdkInstalls(installs) {
209
369
  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
370
  };
211
371
  }
372
+ /**
373
+ * PIDs of `tilt up` processes for this project. The process list shows the Tiltfile path as it was typed at launch,
374
+ * which may or may not be the symlink-resolved path, so both spellings are looked up.
375
+ */
376
+ export function projectTiltPids(projectRoot, find = findProjectTiltUpPids) {
377
+ const roots = new Set([projectRoot]);
378
+ try {
379
+ roots.add(realpathSync(projectRoot));
380
+ }
381
+ catch {
382
+ // Keep the path as given.
383
+ }
384
+ const pids = new Set();
385
+ for (const root of roots) {
386
+ for (const pid of find(join(root, ".tdk", ".tdk-out", "Tiltfile")))
387
+ pids.add(pid);
388
+ }
389
+ return [...pids];
390
+ }
@@ -17,6 +17,32 @@ export function projectNetworkNames(projectName) {
17
17
  const prefix = projectName.replace(/-/g, "_");
18
18
  return PROJECT_NETWORK_SUFFIXES.map((suffix) => `${prefix}_${suffix}`);
19
19
  }
20
+ /**
21
+ * Prefixes of TDK projects among `networkNames`, from the networks the engine creates. Generic suffixes such as
22
+ * `backend` also occur in non-TDK compose projects, so a prefix counts only if it has a Traefik network, which
23
+ * only TDK creates.
24
+ */
25
+ export function tdkNetworkPrefixes(networkNames) {
26
+ const prefixes = new Set();
27
+ for (const name of networkNames) {
28
+ for (const marker of ["traefik-public", "traefik-private"]) {
29
+ if (name.endsWith(`_${marker}`))
30
+ prefixes.add(name.slice(0, -(marker.length + 1)));
31
+ }
32
+ }
33
+ return prefixes;
34
+ }
35
+ /** The TDK project prefix a network belongs to (`umami` for `umami_backend`), or null. */
36
+ export function tdkNetworkOwner(name, prefixes) {
37
+ for (const suffix of PROJECT_NETWORK_SUFFIXES) {
38
+ if (name.endsWith(`_${suffix}`)) {
39
+ const prefix = name.slice(0, -(suffix.length + 1));
40
+ if (prefixes.has(prefix))
41
+ return prefix;
42
+ }
43
+ }
44
+ return null;
45
+ }
20
46
  /**
21
47
  * Remove this project's own networks. Only the exact names the engine creates are touched, and `docker network rm`
22
48
  * itself refuses a network that still has containers attached, so another project's networks and in-use networks
@@ -47,3 +47,17 @@ function canonical(path) {
47
47
  export function isForeignTilt(owner, projectRoot) {
48
48
  return owner !== null && canonical(owner.root) !== canonical(projectRoot);
49
49
  }
50
+ /** Split the candidate Tilt UI ports that answer into this project's and other projects'. */
51
+ export async function classifyTiltPorts(candidatePorts, projectRoot, isServing, ownerOf = findTiltPortOwner) {
52
+ const result = { running: [], foreign: [] };
53
+ for (const port of candidatePorts) {
54
+ if (!(await isServing(port)))
55
+ continue;
56
+ const owner = ownerOf(port);
57
+ if (owner && isForeignTilt(owner, projectRoot))
58
+ result.foreign.push({ port, root: owner.root });
59
+ else
60
+ result.running.push(port);
61
+ }
62
+ return result;
63
+ }