@tdk-landscape/tdk-cli-core 1.3.147 → 1.3.148
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.
- package/cli/dist/commands/doctor.js +8 -4
- package/cli/dist/commands/status.js +15 -0
- package/cli/dist/components/BaseTooltip.js +2 -2
- package/cli/dist/utils/doctor-runtime.js +2 -2
- package/cli/dist/utils/host-port-config.js +43 -2
- package/cli/dist/utils/shared-infra.js +45 -0
- package/cli/dist-bundle/{chunk-LNZN426S.js → chunk-4RTR7KXM.js} +56 -55
- package/cli/dist-bundle/cli.js +255 -255
- package/cli/dist-bundle/{cold-preflight-D7CAIHND.js → cold-preflight-SJPUDOFL.js} +1 -1
- package/cli/package.json +1 -1
- package/discovery/AGENTS.md +3 -3
- package/engine/AGENTS.md +15 -15
- package/engine/README.md +1 -1
- package/engine/topologies/tilt/manifest/README.md +1 -1
- package/package.json +1 -1
|
@@ -18,7 +18,7 @@ import { checkDockerNetworkCapacity, checkDuplicateResourceNames, checkDuplicate
|
|
|
18
18
|
import { validateEnvFile } from "../utils/env-validator.js";
|
|
19
19
|
import { execAsync, isExecTimeout } from "../utils/exec-async.js";
|
|
20
20
|
import { formatCount } from "../utils/formatting.js";
|
|
21
|
-
import { getHostPortPlan } from "../utils/host-port-config.js";
|
|
21
|
+
import { getHostPortPlan, resolveRuntimeIngressPort } from "../utils/host-port-config.js";
|
|
22
22
|
import { createHostPortPlan } from "../utils/host-port-plan.js";
|
|
23
23
|
import { findProjectRoot, getPackageVersion } from "../utils/paths.js";
|
|
24
24
|
import { EnvUnreadableError, envSecretValues, redactSecrets, redactValue, } from "../utils/secret-redaction.js";
|
|
@@ -1131,9 +1131,13 @@ export const doctorCommand = new Command("doctor")
|
|
|
1131
1131
|
];
|
|
1132
1132
|
// Runs last: needs routable services (and working Traefik) to mean anything.
|
|
1133
1133
|
if (options.ping) {
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1134
|
+
// Probe the port a running stack is actually on, not the one the next `tdk up` would pick.
|
|
1135
|
+
const pingPort = projectRoot
|
|
1136
|
+
? resolveRuntimeIngressPort(projectRoot, hostPortPlan?.ingressHttp)
|
|
1137
|
+
: hostPortPlan?.ingressHttp;
|
|
1138
|
+
projectChecks.push(() => checkServiceHealth(pingTimeout, pingPort));
|
|
1139
|
+
projectChecks.push(() => checkServiceSmoke(pingTimeout, pingPort));
|
|
1140
|
+
projectChecks.push(() => checkAdvertisedEndpoints(pingTimeout, pingPort));
|
|
1137
1141
|
}
|
|
1138
1142
|
// Right after installing, people run `tdk doctor` before they have a
|
|
1139
1143
|
// project. Only the machine checks mean anything there; the project checks
|
|
@@ -10,6 +10,7 @@ import { getHostPortPlan } from "../utils/host-port-config.js";
|
|
|
10
10
|
import { createMachineEnvelope, writeMachineError } from "../utils/machine-output.js";
|
|
11
11
|
import { findProjectRoot } from "../utils/paths.js";
|
|
12
12
|
import { deriveServiceStates } from "../utils/service-runtime-state.js";
|
|
13
|
+
import { groupSharedPostgres, readProjectName } from "../utils/shared-infra.js";
|
|
13
14
|
import { buildServicePorts, buildStackPorts } from "../utils/status-ports.js";
|
|
14
15
|
import { isTiltAvailable, runTilt } from "../utils/tilt.js";
|
|
15
16
|
import { getTiltPollingPort } from "../utils/tilt-startup.js";
|
|
@@ -25,6 +26,11 @@ export const statusCommand = new Command("status")
|
|
|
25
26
|
const action = async () => {
|
|
26
27
|
// Discover first: outside a project this fails before any status line is printed.
|
|
27
28
|
const discovery = createDiscoveryContext();
|
|
29
|
+
const projectRoot = findProjectRoot();
|
|
30
|
+
const projectName = projectRoot ? readProjectName(projectRoot) : null;
|
|
31
|
+
const sharedInfraGroup = projectName
|
|
32
|
+
? groupSharedPostgres(projectName, discovery.stacks)
|
|
33
|
+
: null;
|
|
28
34
|
let tiltAvailable = false;
|
|
29
35
|
try {
|
|
30
36
|
tiltAvailable = await isTiltAvailable();
|
|
@@ -121,6 +127,7 @@ export const statusCommand = new Command("status")
|
|
|
121
127
|
resources: stack.resources.map((resource) => resource.name),
|
|
122
128
|
})),
|
|
123
129
|
unassignedResources: discovery.unassignedResources.map((resource) => resource.name),
|
|
130
|
+
sharedInfra: sharedInfraGroup,
|
|
124
131
|
};
|
|
125
132
|
console.log(JSON.stringify(createMachineEnvelope(data, errors)));
|
|
126
133
|
if (errors.length > 0) {
|
|
@@ -151,6 +158,14 @@ export const statusCommand = new Command("status")
|
|
|
151
158
|
}
|
|
152
159
|
}
|
|
153
160
|
}
|
|
161
|
+
if (sharedInfraGroup) {
|
|
162
|
+
console.log();
|
|
163
|
+
console.log(chalk.bold("Shared infrastructure:"));
|
|
164
|
+
showDetail(`${sharedInfraGroup.name} (one instance, ${formatCount(sharedInfraGroup.databases.length, "database")})`);
|
|
165
|
+
for (const database of sharedInfraGroup.databases) {
|
|
166
|
+
showDetail(` ${database.name} (stack ${database.stack}): ${database.services.join(", ")}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
154
169
|
if (serviceStates && Object.keys(serviceStates).length > 0) {
|
|
155
170
|
console.log();
|
|
156
171
|
console.log(chalk.bold("Services:"));
|
|
@@ -10,12 +10,12 @@ export const BaseTooltip = ({ content, shortcut, visible, maxWidth = 40, wrapTex
|
|
|
10
10
|
const lines = wrapText ? wrapContent(content, maxWidth) : [content];
|
|
11
11
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.ascii ? undefined : "single", borderColor: theme.warning, paddingX: 1, paddingY: 1, backgroundColor: theme.ascii ? undefined : "black", marginTop: marginTop, children: [lines.map((line, i) => (_jsxs(Text, { color: theme.warning, bold: theme.highContrast, children: [prefix, line] }, i))), shortcut && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.muted, children: "Press " }), _jsx(Text, { color: theme.accent, bold: true, children: shortcut.startsWith("[") ? shortcut : `[${shortcut}]` }), _jsx(Text, { color: theme.muted, children: " to use" })] }))] }));
|
|
12
12
|
};
|
|
13
|
-
function wrapContent(content, maxWidth) {
|
|
13
|
+
export function wrapContent(content, maxWidth) {
|
|
14
14
|
const words = content.split(" ");
|
|
15
15
|
const lines = [];
|
|
16
16
|
let currentLine = "";
|
|
17
17
|
for (const word of words) {
|
|
18
|
-
if ((currentLine + word).length > maxWidth - 2) {
|
|
18
|
+
if (currentLine && (currentLine + word).length > maxWidth - 2) {
|
|
19
19
|
lines.push(currentLine.trim());
|
|
20
20
|
currentLine = `${word} `;
|
|
21
21
|
}
|
|
@@ -518,8 +518,8 @@ export function summarizeAdvertisedProbes(probes) {
|
|
|
518
518
|
name: "Advertised Endpoints",
|
|
519
519
|
didPass: true,
|
|
520
520
|
isWarning: true,
|
|
521
|
-
message: `${formatCount(dead.length, "advertised link")} returns 404:\n ${details}`,
|
|
522
|
-
fix: 'Nothing handles this path, so the link is
|
|
521
|
+
message: `${formatCount(dead.length, "advertised link")} ${dead.length === 1 ? "returns" : "return"} 404 (the service may be healthy, but nothing answers at this exact path):\n ${details}`,
|
|
522
|
+
fix: 'Nothing handles this exact path, so opening the link shows a 404 even when /health is fine. If this API has no root route, you can leave it. Otherwise add a GET route for it (for example one that returns the service name), or declare a read-only "smoke" step on a real route in service.json. The URL tdk networks and the TDK App print stays the same until the CLI changes how it reports it.',
|
|
523
523
|
};
|
|
524
524
|
}
|
|
525
525
|
/**
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Copyright (c) 2026 TDK Landscape contributors
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join, resolve, sep } from "node:path";
|
|
6
6
|
import { PROJECT_JSON } from "./constants.js";
|
|
7
7
|
import { createHostPortPlan, isHostPortAvailable, PREFERRED_HOST_PORTS, } from "./host-port-plan.js";
|
|
8
8
|
const CONFIG_PATH = join(".tdk", ".tdk-out", "host-ports.json");
|
|
@@ -47,6 +47,47 @@ export function exportHostPortPlan(plan) {
|
|
|
47
47
|
process.env.TDK_HTTPS_PORT = String(plan.ingressHttps);
|
|
48
48
|
process.env.TDK_POSTGRES_PORT = String(plan.postgres);
|
|
49
49
|
}
|
|
50
|
+
const COMPOSE_WORKING_DIR_LABEL = "com.docker.compose.project.working_dir";
|
|
51
|
+
/**
|
|
52
|
+
* True when a container whose Compose working directory is inside this project publishes the port. A container-name prefix is not
|
|
53
|
+
* enough: tdk_shop_ also matches the containers of a different project called tdk-shop-extra. Compose records the directory each
|
|
54
|
+
* project was started from, so that identifies the project exactly. Containers without the label never count.
|
|
55
|
+
*/
|
|
56
|
+
export function isPortHeldByProjectContainers(dockerPs, projectRoot, port) {
|
|
57
|
+
const roots = new Set([resolve(projectRoot)]);
|
|
58
|
+
try {
|
|
59
|
+
roots.add(realpathSync(projectRoot));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// The project root is already known to exist; keep the unresolved path.
|
|
63
|
+
}
|
|
64
|
+
return dockerPs.split("\n").some((line) => {
|
|
65
|
+
const [, ports = "", workingDir = ""] = line.split("|");
|
|
66
|
+
if (!workingDir || !ports.includes(`:${port}->`))
|
|
67
|
+
return false;
|
|
68
|
+
return [...roots].some((root) => workingDir === root || workingDir.startsWith(`${root}${sep}`));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The HTTP ingress port to probe while a stack is running. getHostPortPlan moves a saved fallback port (8080) back to the
|
|
73
|
+
* preferred one (80) as soon as that is free, which is right for the next `tdk up` but wrong for a running stack: Traefik is still
|
|
74
|
+
* bound to the saved port, so probing the planned one finds nothing and every runtime check is skipped. When this project's own
|
|
75
|
+
* containers hold the saved port, probe that one.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveRuntimeIngressPort(projectRoot, plannedPort, readDockerPs = () => execFileSync("docker", ["ps", "--format", `{{.Names}}|{{.Ports}}|{{.Label "${COMPOSE_WORKING_DIR_LABEL}"}}`], { encoding: "utf-8", timeout: 5000 })) {
|
|
78
|
+
const saved = readSavedHostPortPlan(projectRoot);
|
|
79
|
+
if (!saved || saved.ingressHttp === plannedPort)
|
|
80
|
+
return plannedPort;
|
|
81
|
+
try {
|
|
82
|
+
if (isPortHeldByProjectContainers(readDockerPs(), projectRoot, saved.ingressHttp)) {
|
|
83
|
+
return saved.ingressHttp;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Docker unavailable: keep the planned port.
|
|
88
|
+
}
|
|
89
|
+
return plannedPort;
|
|
90
|
+
}
|
|
50
91
|
/** Reuse this project's last selection when its ports are still free or held by its own containers. */
|
|
51
92
|
export async function getHostPortPlan(projectRoot, options = {}) {
|
|
52
93
|
const saved = readSavedHostPortPlan(projectRoot);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Copyright (c) 2026 TDK Landscape contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DATABASE_MANAGEMENT_FEATURE, PROJECT_JSON } from "./constants.js";
|
|
6
|
+
import { POSTGRES_DEPENDENCY_NAMES } from "./shared-platform-postgres.js";
|
|
7
|
+
/** A resource uses the shared database when its features or dependsOn ask for it. */
|
|
8
|
+
export function usesSharedPostgres(resource) {
|
|
9
|
+
const config = resource.config;
|
|
10
|
+
if (!config)
|
|
11
|
+
return false;
|
|
12
|
+
// service.json is not type-checked here, so a malformed value must not throw inside `tdk status`.
|
|
13
|
+
if (Array.isArray(config.featuresEnabled) &&
|
|
14
|
+
config.featuresEnabled.includes(DATABASE_MANAGEMENT_FEATURE))
|
|
15
|
+
return true;
|
|
16
|
+
const dependsOn = Array.isArray(config.dependsOn) ? config.dependsOn : [];
|
|
17
|
+
return dependsOn.some((name) => POSTGRES_DEPENDENCY_NAMES.includes(name));
|
|
18
|
+
}
|
|
19
|
+
export function groupSharedPostgres(projectName, stacks) {
|
|
20
|
+
const prefix = projectName.replace(/-/g, "_");
|
|
21
|
+
const databases = [];
|
|
22
|
+
for (const stack of [...stacks].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
23
|
+
const services = stack.resources.filter(usesSharedPostgres).map((resource) => resource.name);
|
|
24
|
+
if (services.length === 0)
|
|
25
|
+
continue;
|
|
26
|
+
databases.push({
|
|
27
|
+
name: `${prefix}_${stack.name.replace(/-/g, "_")}`,
|
|
28
|
+
stack: stack.name,
|
|
29
|
+
services: services.sort(),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (databases.length === 0)
|
|
33
|
+
return null;
|
|
34
|
+
return { name: "postgres", databases };
|
|
35
|
+
}
|
|
36
|
+
/** `project.name` from `.tdk/project.json`, or null when the project file is missing or unreadable. */
|
|
37
|
+
export function readProjectName(projectRoot) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(join(projectRoot, ".tdk", PROJECT_JSON), "utf-8"));
|
|
40
|
+
return typeof parsed?.project?.name === "string" ? parsed.project.name : null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|