@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 +1 -0
- package/cli/dist/commands/doctor.js +20 -8
- package/cli/dist/commands/import.js +6 -5
- 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-PLUWAA7O.js +322 -0
- package/cli/dist-bundle/cli.js +255 -255
- package/cli/dist-bundle/{cold-preflight-D7CAIHND.js → cold-preflight-RABRN55J.js} +1 -1
- package/cli/package.json +1 -1
- package/discovery/AGENTS.md +3 -3
- package/discovery/manifest/loading.star +5 -1
- package/engine/AGENTS.md +15 -15
- package/engine/README.md +1 -1
- package/engine/schemas/service-schema.json +2 -3
- package/engine/topologies/platform/docker/compose/compose.star +3 -0
- package/engine/topologies/platform/docker/networking/traefik.star +16 -7
- package/engine/topologies/tilt/manifest/README.md +1 -1
- package/engine/topologies/tilt/manifest/parser.star +5 -0
- package/engine/topologies/tilt/manifest/tests/test_loader_parser.star +14 -0
- package/package.json +1 -1
- package/cli/dist-bundle/chunk-LNZN426S.js +0 -321
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
|
|
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
|
-
//
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
|
7
|
-
*
|
|
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 = "
|
|
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
|
|
35
|
-
.argument("[args...]", "Passed to tdk-import: [dir] --dry-run --yes --force
|
|
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
|
|
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
|
+
}
|