@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.
@@ -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
- projectChecks.push(() => checkServiceHealth(pingTimeout, hostPortPlan?.ingressHttp));
1135
- projectChecks.push(() => checkServiceSmoke(pingTimeout, hostPortPlan?.ingressHttp));
1136
- projectChecks.push(() => checkAdvertisedEndpoints(pingTimeout, hostPortPlan?.ingressHttp));
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 dead. Add a route for it, 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.',
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
+ }