@tdk-landscape/tdk-cli-core 1.3.157 → 1.3.159

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.
@@ -1017,6 +1017,7 @@ export const doctorCommand = new Command("doctor")
1017
1017
  .option("--strict", "Fail warnings such as a WSL project under /mnt/c")
1018
1018
  .option("--fix", "Repair this project's unused Docker networks and leftover doctor probes", false)
1019
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")
1020
1021
  .option("-y, --yes", "With --fix: apply every repair without asking", false)
1021
1022
  .option("--ping-timeout <ms>", "Per-service ping timeout in milliseconds", String(DEFAULT_PING_TIMEOUT_MS))
1022
1023
  .action(async (options) => {
@@ -1032,8 +1033,14 @@ export const doctorCommand = new Command("doctor")
1032
1033
  process.exit(2);
1033
1034
  return;
1034
1035
  }
1035
- if ((options.dryRun || options.yes) && !options.fix) {
1036
- 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`;
1037
1044
  if (options.json)
1038
1045
  console.log(JSON.stringify(createDoctorReport([], false, [{ code: "USAGE", message }])));
1039
1046
  console.error(message);
@@ -1100,9 +1107,10 @@ export const doctorCommand = new Command("doctor")
1100
1107
  }
1101
1108
  }
1102
1109
  const tiltPids = projectRoot ? projectTiltPids(projectRoot) : [];
1103
- const planned = await planDoctorFixes(projectName, undefined, tiltPids.length > 0
1104
- ? `Tilt is running for this project (pid ${tiltPids.join(", ")}); run tdk down first`
1105
- : undefined);
1110
+ const planned = await planDoctorFixes(projectName, undefined, {
1111
+ tiltPids,
1112
+ machine: options.scope === "machine",
1113
+ });
1106
1114
  fixActions = await applyDoctorFixes(planned, {
1107
1115
  dryRun: options.dryRun,
1108
1116
  confirm: options.yes || (!options.json && process.stdin.isTTY)
@@ -46,7 +46,7 @@ function pruneNetworksOrWarn(json) {
46
46
  export const downCommand = new Command("down")
47
47
  .description("Stop all tilt resources")
48
48
  .option("-v, --verbose", "Enable verbose output", false)
49
- .option("-f, --force", "Skip confirmation", false)
49
+ .option("-f, --force", "Skip confirmation (no-op; accepted for script compatibility)", false)
50
50
  .option("--prune-networks", "Also remove this project's Docker networks (only those no container is attached to)", false)
51
51
  .option("--dry-run", "Show what would be stopped without stopping", false)
52
52
  .option("--json", "Print one JSON object on stdout; implies quiet human output", false)
@@ -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.`;
@@ -1,10 +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";
5
6
  import { join } from "node:path";
6
7
  import { promisify } from "node:util";
7
- import { findProjectTiltUpPids, 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";
8
11
  import { findAllOnPath } from "./which.js";
9
12
  const execFilePromise = promisify(execFile);
10
13
  const DOCKER_TIMEOUT_MS = 15_000;
@@ -22,6 +25,51 @@ function firstLine(error) {
22
25
  const stderr = String(error.stderr ?? error).trim();
23
26
  return stderr.split(/\r?\n/)[0] ?? "";
24
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
+ }
25
73
  export function createDockerFixDeps(run = runDocker) {
26
74
  return {
27
75
  async listNetworks() {
@@ -58,6 +106,54 @@ export function createDockerFixDeps(run = runDocker) {
58
106
  throw new Error(firstLine(error));
59
107
  }
60
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
+ },
61
157
  isProcessAlive(pid) {
62
158
  try {
63
159
  process.kill(pid, 0);
@@ -72,17 +168,66 @@ export function createDockerFixDeps(run = runDocker) {
72
168
  }
73
169
  export const dockerFixDeps = createDockerFixDeps();
74
170
  /**
75
- * What `--fix` would do. Only unused networks that are either a leftover doctor probe or one of this project's own
76
- * (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.
77
174
  */
78
- export async function planDoctorFixes(projectName, deps = dockerFixDeps, projectBusy) {
79
- const networks = await deps.listNetworks();
175
+ export async function planDoctorFixes(projectName, deps = dockerFixDeps, context = {}) {
80
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
+ }
81
224
  const own = new Set(projectName ? projectNetworkNames(projectName) : []);
225
+ const prefixes = tdkNetworkPrefixes(networks.map((network) => network.name));
82
226
  for (const network of networks) {
83
227
  const probe = PROBE_NETWORK.exec(network.name);
84
228
  let scope;
85
229
  let check;
230
+ let reason = "unused network";
86
231
  if (probe) {
87
232
  scope = "probe";
88
233
  check = "Orphaned probe networks";
@@ -91,6 +236,11 @@ export async function planDoctorFixes(projectName, deps = dockerFixDeps, project
91
236
  scope = "project";
92
237
  check = "Docker networks";
93
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
+ }
94
244
  else {
95
245
  continue;
96
246
  }
@@ -100,7 +250,7 @@ export async function planDoctorFixes(projectName, deps = dockerFixDeps, project
100
250
  scope,
101
251
  command: `docker network rm ${network.name}`,
102
252
  status: "planned",
103
- reason: "unused network",
253
+ reason,
104
254
  };
105
255
  if (network.containers > 0) {
106
256
  action.status = "skipped";
@@ -147,9 +297,18 @@ export async function applyDoctorFixes(actions, options, deps = dockerFixDeps) {
147
297
  results.push({ ...action, status: "skipped", reason: "not confirmed" });
148
298
  continue;
149
299
  }
150
- const name = action.id.slice("remove-network:".length);
151
300
  try {
152
- 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));
153
312
  results.push({ ...action, status: "applied", reason: "removed" });
154
313
  }
155
314
  catch (error) {
@@ -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
+ }
@@ -92,9 +92,9 @@ export function buildTiltUpArgs(serviceNames, options = {}) {
92
92
  export function buildTiltDownArgs(options = {}) {
93
93
  const args = [];
94
94
  addTiltfilePath(args);
95
- if (options.force) {
96
- args.push("--force");
97
- }
95
+ // Note: `tilt down` has no `--force` flag. The `-f/--force` option is accepted
96
+ // by `tdk down` for script compatibility and skipping confirmation, but must
97
+ // not be passed to `tilt down` as Tilt will exit with an "unknown flag: --force" error.
98
98
  // `tilt down` re-evaluates the Tiltfile and removes only the resources that evaluation registers. Without `--focus`
99
99
  // the Tiltfile falls back to its default phase and never registers a stack's own compose project, so `tdk up shop`
100
100
  // containers survived `tdk down`. Pass the same selection `tdk up` used (every stack) after `--`.