@tdk-landscape/tdk-cli-core 1.3.154 → 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.
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ // Copyright (c) 2026 TDK Landscape contributors
2
+ // SPDX-License-Identifier: MIT
3
+ import { execFileSync } from "node:child_process";
4
+ import { realpathSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { parseTiltProcesses } from "./doctor-wiring.js";
7
+ const run = (command, args) => execFileSync(command, args, {
8
+ encoding: "utf-8",
9
+ stdio: "pipe",
10
+ timeout: 5000,
11
+ windowsHide: true,
12
+ });
13
+ /**
14
+ * The project whose `tilt up` listens on `port`, or null when it cannot be told (no lsof, Windows, a Tilt started
15
+ * without `-f`, a listener that is not Tilt). Callers must treat null as "unknown", not as "not mine".
16
+ */
17
+ export function findTiltPortOwner(port, platform = process.platform, commandRunner = run) {
18
+ if (platform === "win32")
19
+ return null;
20
+ try {
21
+ const pids = commandRunner("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"])
22
+ .split(/\r?\n/)
23
+ .map((line) => line.trim())
24
+ .filter(Boolean);
25
+ for (const pid of pids) {
26
+ const command = commandRunner("ps", ["-p", pid, "-o", "command="]).trim();
27
+ const [owner] = parseTiltProcesses(`${pid} ${command}`);
28
+ if (owner)
29
+ return owner;
30
+ }
31
+ }
32
+ catch {
33
+ // lsof exits 1 when nothing listens, and may be missing.
34
+ }
35
+ return null;
36
+ }
37
+ /** Symlinked checkouts (macOS /tmp -> /private/tmp) must not make a project look foreign to itself. */
38
+ function canonical(path) {
39
+ try {
40
+ return realpathSync(path);
41
+ }
42
+ catch {
43
+ return resolve(path);
44
+ }
45
+ }
46
+ /** Whether `owner` is another project's Tilt, as opposed to this project's or unknown. */
47
+ export function isForeignTilt(owner, projectRoot) {
48
+ return owner !== null && canonical(owner.root) !== canonical(projectRoot);
49
+ }
@@ -25,3 +25,32 @@ export function findOnPath(command) {
25
25
  }
26
26
  return null;
27
27
  }
28
+ export function findAllOnPath(command) {
29
+ const pathEnv = process.env.PATH ?? process.env.Path ?? "";
30
+ const windows = isWindows();
31
+ const names = pathLookups(command, windows);
32
+ if (windows && !extname(command)) {
33
+ for (const extension of (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")) {
34
+ const suffix = extension.toLowerCase();
35
+ if (suffix && !names.includes(`${command}${suffix}`))
36
+ names.push(`${command}${suffix}`);
37
+ }
38
+ }
39
+ const found = [];
40
+ const seen = new Set();
41
+ for (const dir of pathEnv.split(delimiter)) {
42
+ if (!dir)
43
+ continue;
44
+ for (const name of names) {
45
+ const candidate = join(dir, name);
46
+ if (!existsSync(candidate))
47
+ continue;
48
+ const key = windows ? candidate.toLowerCase() : candidate;
49
+ if (!seen.has(key)) {
50
+ seen.add(key);
51
+ found.push(candidate);
52
+ }
53
+ }
54
+ }
55
+ return found;
56
+ }