@tdk-landscape/tdk-cli-core 1.3.147 → 1.3.149

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/CHANGELOG.md CHANGED
@@ -4,6 +4,7 @@ Notable changes to the `tdk` CLI. Versions match [npm](https://www.npmjs.com/pac
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ - `healthCheckPath` is optional for `appType: "bring-your-own"` in `service.json`, so a service that is not HTTP (Postgres, Redis, MinIO, an SMTP relay) no longer needs a made-up `"/"` to validate. Without it TDK sends no HTTP probe to the service and gives its Traefik route no health check; the container counts as healthy once Docker reports it running. `port` stays required, and a set `healthCheckPath` behaves as before. Fixes #1086.
7
8
  - `tdk up` now publishes Traefik on host port 80 (and 443) when they are free, so routed URLs read `http://app.<project>.localhost/<app>` instead of `...localhost:8080/<app>`. It always wanted 80 but only ever tried 8080-8180. On macOS the free-port check treated the kernel's refusal of a non-root bind below 1024 as "busy", although Docker Desktop publishes those ports itself; that also made `TDK_HTTP_PORT=80` fail with "already in use". A project whose saved plan is on 8080 moves to 80 on its next `tdk up` once 80 is free. On Linux and WSL2 a non-root user cannot check port 80, so the 8080-8180 range is kept there. Printed URLs no longer end in `:80`.
8
9
  - TDK MCP tools now accept an absolute `projectPath` so a projectless MCP client can run doctor, inspect services and logs, and manage the intended project without depending on the server launch directory.
9
10
  - Shared platform stacks (postgres, traefik, proxy, messaging, infisical, monitoring, cdc, elk) now run in Compose projects named `<project>-<stack>` (for example `tdk-erp-system-database-management`) instead of the directory-named `database-management`, `tdk-out` and so on. Two TDK projects on one Docker daemon no longer share them.
@@ -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";
@@ -137,17 +137,25 @@ function isTimeout(err) {
137
137
  return isExecTimeout(err);
138
138
  }
139
139
  function createExecCheck(name, command, successMessage, failureMessage, fixInstructions) {
140
- return async () => {
140
+ return async (exec = execAsync) => {
141
141
  try {
142
- await execAsync(command, EXEC_TIMEOUT_MS);
142
+ await exec(command, EXEC_TIMEOUT_MS);
143
143
  return {
144
144
  name,
145
145
  didPass: true,
146
146
  message: successMessage,
147
147
  };
148
148
  }
149
- catch {
150
- // Error details not needed - failure message tells user what to fix
149
+ catch (err) {
150
+ // A timeout means the command exists but did not answer in time, which is not the same as missing.
151
+ if (isTimeout(err)) {
152
+ return {
153
+ name,
154
+ didPass: false,
155
+ message: `${name} did not respond within ${EXEC_TIMEOUT_MS / 1000}s`,
156
+ fix: fixInstructions,
157
+ };
158
+ }
151
159
  return {
152
160
  name,
153
161
  didPass: false,
@@ -1131,9 +1139,13 @@ export const doctorCommand = new Command("doctor")
1131
1139
  ];
1132
1140
  // Runs last: needs routable services (and working Traefik) to mean anything.
1133
1141
  if (options.ping) {
1134
- projectChecks.push(() => checkServiceHealth(pingTimeout, hostPortPlan?.ingressHttp));
1135
- projectChecks.push(() => checkServiceSmoke(pingTimeout, hostPortPlan?.ingressHttp));
1136
- projectChecks.push(() => checkAdvertisedEndpoints(pingTimeout, hostPortPlan?.ingressHttp));
1142
+ // Probe the port a running stack is actually on, not the one the next `tdk up` would pick.
1143
+ const pingPort = projectRoot
1144
+ ? resolveRuntimeIngressPort(projectRoot, hostPortPlan?.ingressHttp)
1145
+ : hostPortPlan?.ingressHttp;
1146
+ projectChecks.push(() => checkServiceHealth(pingTimeout, pingPort));
1147
+ projectChecks.push(() => checkServiceSmoke(pingTimeout, pingPort));
1148
+ projectChecks.push(() => checkAdvertisedEndpoints(pingTimeout, pingPort));
1137
1149
  }
1138
1150
  // Right after installing, people run `tdk doctor` before they have a
1139
1151
  // project. Only the machine checks mean anything there; the project checks
@@ -3,10 +3,11 @@
3
3
  import { spawn } from "node:child_process";
4
4
  import { Command } from "commander";
5
5
  /**
6
- * The importer lives in its own repo so detectors can ship without a CLI release. Until it is
7
- * published to npm it is run from git; `TDK_IMPORT_PACKAGE` overrides the package spec.
6
+ * The importer lives in its own repo so it can ship without a CLI release, and is run from npm at a
7
+ * pinned version so a push to its main branch cannot change what `tdk import` does. Move the pin
8
+ * when a new importer is published. `TDK_IMPORT_PACKAGE` overrides the package spec.
8
9
  */
9
- export const IMPORT_PACKAGE = "github:tdk-landscape/tdk-import";
10
+ export const IMPORT_PACKAGE = "@tdk-landscape/tdk-import@0.2.1";
10
11
  export function importInvocation(args, env = process.env, platform = process.platform) {
11
12
  const pkg = env.TDK_IMPORT_PACKAGE || IMPORT_PACKAGE;
12
13
  return {
@@ -31,8 +32,8 @@ export function runImport(args, spawnFn = spawn) {
31
32
  });
32
33
  }
33
34
  export const importCommand = new Command("import")
34
- .description("Import the services a directory describes (runs tdk-import)")
35
- .argument("[args...]", "Passed to tdk-import: [dir] --dry-run --yes --force --only <ids>")
35
+ .description("Import the services in a directory's root docker-compose.yml (runs tdk-import)")
36
+ .argument("[args...]", "Passed to tdk-import: [dir] --dry-run --yes --force")
36
37
  .allowUnknownOption()
37
38
  .helpOption(false)
38
39
  .action(async (args) => {
@@ -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
+ }