@tdk-landscape/tdk-cli-core 1.3.144 → 1.3.146
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/README.md +2 -2
- package/Tiltfile +2 -0
- package/cli/dist/commands/doctor.js +55 -2
- package/cli/dist/commands/status.js +17 -20
- package/cli/dist/commands/up.js +10 -1
- package/cli/dist/generator/template-engine.js +6 -3
- package/cli/dist/utils/doctor-host.js +66 -0
- package/cli/dist/utils/doctor-runtime.js +60 -0
- package/cli/dist/utils/host-port-config.js +11 -2
- package/cli/dist/utils/host-port-plan.js +25 -3
- package/cli/dist/utils/service-urls.js +16 -2
- package/cli/dist/utils/smoke.js +28 -0
- package/cli/dist-bundle/chunk-SWAGCVPQ.js +321 -0
- package/cli/dist-bundle/cli.js +206 -206
- package/cli/dist-bundle/{cold-preflight-CGPSYGDB.js → cold-preflight-2ZV2PDU2.js} +1 -1
- package/cli/package.json +1 -1
- package/cli/templates/Tiltfile.hbs +12 -0
- package/engine/Tiltfile +2 -0
- package/engine/assets/scripts/cleanup-tilt-images.sh +2 -2
- package/engine/topologies/platform/docker/build/frontend_build_plan.star +65 -0
- package/engine/topologies/platform/docker/build/golden_image_build.star +104 -48
- package/engine/topologies/platform/docker/layers/l3_builder_layers.star +3 -1
- package/engine/topologies/tilt/resources/build_concurrency.star +56 -0
- package/engine/topologies/tilt/resources/infra-loader.star +4 -0
- package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +24 -2
- package/engine/topologies/tilt/resources/orchestrator/generators/manifest_resource.star +5 -1
- package/engine/topologies/tilt/resources/watchers.star +7 -4
- package/ext/ide-components/shared/templates/base.html +4 -8
- package/package.json +1 -1
- package/cli/dist-bundle/chunk-2PTDOT3Y.js +0 -315
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
|
+
- `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`.
|
|
7
8
|
- 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.
|
|
8
9
|
- 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.
|
|
9
10
|
|
package/README.md
CHANGED
|
@@ -52,10 +52,10 @@ mkdir shop && cd shop
|
|
|
52
52
|
tdk project --yes
|
|
53
53
|
tdk resource orders-api --type backend --stack shop --yes
|
|
54
54
|
tdk up shop
|
|
55
|
-
curl http://api.shop.localhost
|
|
55
|
+
curl http://api.shop.localhost/api/orders/health
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
`tdk up` prints the host ports it chose.
|
|
58
|
+
`tdk up` prints the host ports it chose. It uses port 80 when it is free, so URLs carry no port. Otherwise it picks one from 8080-8180, and the URLs it prints include that port (on Linux and WSL2 a non-root user cannot check port 80, so the range is used there). Use the URL `tdk up` prints, or set `TDK_HTTP_PORT`. A resource named `orders-api` is served at `/api/orders` (a trailing `-api` is dropped); `tdk up` prints the exact URLs.
|
|
59
59
|
|
|
60
60
|

|
|
61
61
|
|
package/Tiltfile
CHANGED
|
@@ -36,6 +36,7 @@ load('./engine/topologies/tilt/resources/declaration.star', _Orchestrator='Orche
|
|
|
36
36
|
load('./engine/topologies/tilt/resources/ordering.star', _Infra='Infra')
|
|
37
37
|
load('./engine/topologies/tilt/resources/triggers.star', _Watchers='Watchers')
|
|
38
38
|
load('./engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star', _resource_defers_start='resource_defers_start')
|
|
39
|
+
load('./engine/topologies/platform/docker/build/frontend_build_plan.star', _frontend_build_plan='frontend_build_plan')
|
|
39
40
|
|
|
40
41
|
# =============================================================================
|
|
41
42
|
# SPECS (Tech stack and standards)
|
|
@@ -132,6 +133,7 @@ Watchers = _Watchers
|
|
|
132
133
|
# True when a manifest's deferStart is actually active (licensed); decides whether a `<name>-image` prebuild resource exists.
|
|
133
134
|
# The generated Tiltfile loads this from ext://tdk-cli, which resolves to this root Tiltfile.
|
|
134
135
|
resource_defers_start = _resource_defers_start
|
|
136
|
+
FrontendBuildPlan = _frontend_build_plan
|
|
135
137
|
|
|
136
138
|
# Specs
|
|
137
139
|
assert_tech_stack = _assert_tech_stack
|
|
@@ -10,8 +10,9 @@ import { DEVCONTAINER_DOCKER_FIX, detectHost, isContainerHost, WEBCONTAINER_DOCS
|
|
|
10
10
|
import { projectNeedsBun } from "../utils/bun-requirement.js";
|
|
11
11
|
import { AUTOGENERATED_DIR, CONTAINER_RUNTIME_CHECK, MASTER_CONFIG_FILES, PACKAGE_JSON, REQUIRED_PACKAGE_SCRIPTS, } from "../utils/constants.js";
|
|
12
12
|
import { isPathDiscovered, readDiscoveryPaths } from "../utils/discovery-paths.js";
|
|
13
|
+
import { checkDiskSpace, dockerDesktopStuck } from "../utils/doctor-host.js";
|
|
13
14
|
import { collectDoctorChecks, createDoctorReport, getDoctorExitCode, } from "../utils/doctor-report.js";
|
|
14
|
-
import { checkHealthRoutes, checkIngressPorts, checkPrivateNpmRegistry, checkTiltResourceHealth, projectConfigEnablesVerdaccio, summarizeServiceProbes, } from "../utils/doctor-runtime.js";
|
|
15
|
+
import { checkHealthRoutes, checkIngressPorts, checkPrivateNpmRegistry, checkTiltResourceHealth, projectConfigEnablesVerdaccio, summarizeAdvertisedProbes, summarizeServiceProbes, summarizeSmokeResults, } from "../utils/doctor-runtime.js";
|
|
15
16
|
import { checkStarlarkLoadExports } from "../utils/doctor-starlark.js";
|
|
16
17
|
import { checkDockerNetworkCapacity, checkDuplicateResourceNames, checkDuplicateResourcePorts, checkFrontendBackendUrls, checkMigrationsInApi, checkNatsBroker, checkPrismaConsistency, checkResourcePackageJson, checkServiceUrlPorts, checkSharedPlatformPostgres, checkSharedStackRoutes, checkTiltInstances, } from "../utils/doctor-wiring.js";
|
|
17
18
|
import { validateEnvFile } from "../utils/env-validator.js";
|
|
@@ -22,8 +23,9 @@ import { createHostPortPlan } from "../utils/host-port-plan.js";
|
|
|
22
23
|
import { findProjectRoot, getPackageVersion } from "../utils/paths.js";
|
|
23
24
|
import { EnvUnreadableError, envSecretValues, redactSecrets, redactValue, } from "../utils/secret-redaction.js";
|
|
24
25
|
import { checkCircularDependencies, checkDependsOnShape, checkSchemaVersions, checkServicePorts, } from "../utils/service-config-checks.js";
|
|
25
|
-
import { buildHealthTargets, pingHealthTargets } from "../utils/service-urls.js";
|
|
26
|
+
import { buildAdvertisedTargets, buildHealthTargets, pingHealthTargets, } from "../utils/service-urls.js";
|
|
26
27
|
import { discoverResourcesFromRoot } from "../utils/services.js";
|
|
28
|
+
import { buildSmokePlans, readOnlySmokePlans, runSmokePlans } from "../utils/smoke.js";
|
|
27
29
|
import { evaluateTdkVersionFloor } from "../utils/tdk-version.js";
|
|
28
30
|
import { findOnPath } from "../utils/which.js";
|
|
29
31
|
export { checkIngressPorts, checkPrivateNpmRegistry, checkTiltResourceHealth, summarizeServiceProbes, summarizeTiltBuildError, } from "../utils/doctor-runtime.js";
|
|
@@ -202,6 +204,10 @@ export async function checkDockerRuntime() {
|
|
|
202
204
|
};
|
|
203
205
|
}
|
|
204
206
|
}
|
|
207
|
+
// Docker Desktop open but its engine down or stuck: say so instead of "not running".
|
|
208
|
+
const stuck = dockerDesktopStuck(await succeeds("pgrep -f com.docker.backend"));
|
|
209
|
+
if (stuck)
|
|
210
|
+
return stuck;
|
|
205
211
|
// Docker not running: check for Colima, then Podman
|
|
206
212
|
if (await succeeds("colima status")) {
|
|
207
213
|
return {
|
|
@@ -920,6 +926,50 @@ async function checkServiceHealth(timeoutMs, ingressPort) {
|
|
|
920
926
|
}
|
|
921
927
|
return summarizeServiceProbes(await pingHealthTargets(targets, timeoutMs));
|
|
922
928
|
}
|
|
929
|
+
/**
|
|
930
|
+
* Runs the GET steps of each service's "smoke" block through its public URL. These are the checks `tdk up` already runs, minus the
|
|
931
|
+
* writes, so a service with an empty /health but a missing list route is caught before anyone clicks a dead link.
|
|
932
|
+
*
|
|
933
|
+
* Like the health ping, this only runs once services answer. A stopped stack is skipped, not failed.
|
|
934
|
+
*/
|
|
935
|
+
async function checkServiceSmoke(timeoutMs, ingressPort) {
|
|
936
|
+
const projectRoot = findProjectRoot() ?? process.cwd();
|
|
937
|
+
const resources = discoverResourcesFromRoot(projectRoot);
|
|
938
|
+
const plans = readOnlySmokePlans(buildSmokePlans(resources, ingressPort), {
|
|
939
|
+
timeoutSeconds: Math.ceil(timeoutMs / 1000),
|
|
940
|
+
});
|
|
941
|
+
if (plans.length === 0) {
|
|
942
|
+
return summarizeSmokeResults([]);
|
|
943
|
+
}
|
|
944
|
+
const probes = await pingHealthTargets(buildHealthTargets(resources, ingressPort), timeoutMs);
|
|
945
|
+
if (!probes.some((probe) => probe.status !== undefined)) {
|
|
946
|
+
return {
|
|
947
|
+
name: "Service Smoke",
|
|
948
|
+
didPass: true,
|
|
949
|
+
isSkipped: true,
|
|
950
|
+
message: "No services responded - skipped smoke checks",
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
return summarizeSmokeResults(await runSmokePlans(plans));
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Requests the URL each routable service advertises, the same one `tdk networks` and the TDK App show, and warns on a 404.
|
|
957
|
+
* Like the other runtime checks, it only runs once services answer. A stopped stack is skipped, not reported.
|
|
958
|
+
*/
|
|
959
|
+
async function checkAdvertisedEndpoints(timeoutMs, ingressPort) {
|
|
960
|
+
const projectRoot = findProjectRoot() ?? process.cwd();
|
|
961
|
+
const resources = discoverResourcesFromRoot(projectRoot);
|
|
962
|
+
const targets = buildAdvertisedTargets(resources, ingressPort);
|
|
963
|
+
if (targets.length === 0) {
|
|
964
|
+
return {
|
|
965
|
+
name: "Advertised Endpoints",
|
|
966
|
+
didPass: true,
|
|
967
|
+
isSkipped: true,
|
|
968
|
+
message: "No routable services to check",
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
return summarizeAdvertisedProbes(await pingHealthTargets(targets, timeoutMs));
|
|
972
|
+
}
|
|
923
973
|
export const doctorCommand = new Command("doctor")
|
|
924
974
|
.description("Check environment readiness for TDK")
|
|
925
975
|
.option("--json", "Output a versioned JSON readiness report", false)
|
|
@@ -1026,6 +1076,7 @@ export const doctorCommand = new Command("doctor")
|
|
|
1026
1076
|
message: "Ingress port plan is unavailable",
|
|
1027
1077
|
},
|
|
1028
1078
|
checkDockerRuntime,
|
|
1079
|
+
() => checkDiskSpace(),
|
|
1029
1080
|
checkDockerOperatingSystem,
|
|
1030
1081
|
checkTilt,
|
|
1031
1082
|
checkDockerCompose,
|
|
@@ -1081,6 +1132,8 @@ export const doctorCommand = new Command("doctor")
|
|
|
1081
1132
|
// Runs last: needs routable services (and working Traefik) to mean anything.
|
|
1082
1133
|
if (options.ping) {
|
|
1083
1134
|
projectChecks.push(() => checkServiceHealth(pingTimeout, hostPortPlan?.ingressHttp));
|
|
1135
|
+
projectChecks.push(() => checkServiceSmoke(pingTimeout, hostPortPlan?.ingressHttp));
|
|
1136
|
+
projectChecks.push(() => checkAdvertisedEndpoints(pingTimeout, hostPortPlan?.ingressHttp));
|
|
1084
1137
|
}
|
|
1085
1138
|
// Right after installing, people run `tdk doctor` before they have a
|
|
1086
1139
|
// project. Only the machine checks mean anything there; the project checks
|
|
@@ -11,7 +11,7 @@ import { createMachineEnvelope, writeMachineError } from "../utils/machine-outpu
|
|
|
11
11
|
import { findProjectRoot } from "../utils/paths.js";
|
|
12
12
|
import { deriveServiceStates } from "../utils/service-runtime-state.js";
|
|
13
13
|
import { buildServicePorts, buildStackPorts } from "../utils/status-ports.js";
|
|
14
|
-
import {
|
|
14
|
+
import { isTiltAvailable, runTilt } from "../utils/tilt.js";
|
|
15
15
|
import { getTiltPollingPort } from "../utils/tilt-startup.js";
|
|
16
16
|
import { evaluateTiltReadiness, tiltGetUiResources } from "../utils/up-readiness.js";
|
|
17
17
|
export const statusCommand = new Command("status")
|
|
@@ -43,22 +43,6 @@ export const statusCommand = new Command("status")
|
|
|
43
43
|
}
|
|
44
44
|
let tiltResources = null;
|
|
45
45
|
let queryError = null;
|
|
46
|
-
if (options.json && options.tilt && tiltAvailable) {
|
|
47
|
-
const result = await runTilt("get", ["-f", getTiltfilePath(), "resources", "--output=json"], {
|
|
48
|
-
inheritStdio: false,
|
|
49
|
-
});
|
|
50
|
-
if (result.exitCode === 0) {
|
|
51
|
-
try {
|
|
52
|
-
tiltResources = JSON.parse(result.stdout);
|
|
53
|
-
}
|
|
54
|
-
catch (error) {
|
|
55
|
-
queryError = `Tilt returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
queryError = result.stderr || "Could not retrieve Tilt resource status";
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
46
|
// A single look at the running Tilt, so a caller that started `tdk up` detached can poll for readiness. Null when no
|
|
63
47
|
// Tilt answers or its output cannot be read.
|
|
64
48
|
let readiness = null;
|
|
@@ -67,6 +51,20 @@ export const statusCommand = new Command("status")
|
|
|
67
51
|
const tiltPort = getTiltPollingPort(process.env.TILT_PORT);
|
|
68
52
|
if (tiltAvailable) {
|
|
69
53
|
const text = await tiltGetUiResources(tiltPort);
|
|
54
|
+
// `tilt get` has no -f flag; resources come from the running Tilt's API on its port, the same source as readiness.
|
|
55
|
+
if (options.json && options.tilt) {
|
|
56
|
+
if (text) {
|
|
57
|
+
try {
|
|
58
|
+
tiltResources = JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
queryError = `Tilt returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
queryError = `No Tilt is answering on port ${tiltPort}. Start it with: tdk up`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
70
68
|
if (text) {
|
|
71
69
|
const deferred = getDeferredResourceNames();
|
|
72
70
|
try {
|
|
@@ -171,15 +169,14 @@ export const statusCommand = new Command("status")
|
|
|
171
169
|
if (options.tilt && tiltAvailable) {
|
|
172
170
|
console.log();
|
|
173
171
|
showStep("Tilt Resources:");
|
|
174
|
-
const
|
|
175
|
-
const result = await runTilt("get", ["-f", tiltfilePath, "resources"], {
|
|
172
|
+
const result = await runTilt("get", ["uiresources", "--port", String(tiltPort)], {
|
|
176
173
|
inheritStdio: false,
|
|
177
174
|
});
|
|
178
175
|
if (result.exitCode === 0) {
|
|
179
176
|
console.log(result.stdout || chalk.gray(" No active tilt resources"));
|
|
180
177
|
}
|
|
181
178
|
else {
|
|
182
|
-
showDetail(
|
|
179
|
+
showDetail(`No Tilt is answering on port ${tiltPort}. Start it with: tdk up`);
|
|
183
180
|
}
|
|
184
181
|
}
|
|
185
182
|
console.log();
|
package/cli/dist/commands/up.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Copyright (c) 2026 TDK Landscape contributors
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { connect } from "node:net";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import { Command } from "commander";
|
|
7
|
-
import { ensureProjectRuntimeAssets, ProjectConfigNotFoundError, verifyMasterConfigs, } from "../generator/template-engine.js";
|
|
8
|
+
import { ensureProjectRuntimeAssets, generateMasterConfigs, ProjectConfigNotFoundError, verifyMasterConfigs, } from "../generator/template-engine.js";
|
|
8
9
|
import { DEVCONTAINER_DOCKER_FIX, detectHost, isContainerHost, WEBCONTAINER_UP_MESSAGE, } from "../utils/agent-host.js";
|
|
9
10
|
import { handleDryRun } from "../utils/command-helpers.js";
|
|
10
11
|
import { STANDARD_PORTS } from "../utils/constants.js";
|
|
@@ -256,6 +257,14 @@ export const upCommand = new Command("up")
|
|
|
256
257
|
console.log(chalk.gray(`Added ${addedEnvKeys.join(", ")} to .env (generated; existing values unchanged)`));
|
|
257
258
|
}
|
|
258
259
|
}
|
|
260
|
+
// A project made by `tdk import` has .tdk/project.json but no generated files yet. Generate them rather than letting
|
|
261
|
+
// Tilt fail on a missing Tiltfile.
|
|
262
|
+
if (foundRoot && !existsSync(join(projectRoot, ".tdk", ".tdk-out", "Tiltfile"))) {
|
|
263
|
+
if (!options.quiet) {
|
|
264
|
+
console.log(chalk.gray("No generated Tiltfile yet; generating .tdk/.tdk-out/ ..."));
|
|
265
|
+
}
|
|
266
|
+
await generateMasterConfigs(projectRoot, { quiet: options.quiet });
|
|
267
|
+
}
|
|
259
268
|
const copiedAssets = ensureProjectRuntimeAssets(projectRoot);
|
|
260
269
|
if (copiedAssets.length > 0 && options.verbose && !options.quiet) {
|
|
261
270
|
console.log(chalk.gray(`Refreshed runtime assets: ${copiedAssets.join(", ")}`));
|
|
@@ -428,7 +428,7 @@ export async function generateMasterConfigs(projectRoot, options = {}) {
|
|
|
428
428
|
throw new Error(`Refusing to generate config from unsafe service.json values:\n${unsafeFields.map((error) => ` ${error}`).join("\n")}`);
|
|
429
429
|
}
|
|
430
430
|
await warnIfSablierUnlicensed(projectRoot);
|
|
431
|
-
if (ensureRootWorkspaceManifest(projectRoot, projectConfig)) {
|
|
431
|
+
if (ensureRootWorkspaceManifest(projectRoot, projectConfig) && !options.quiet) {
|
|
432
432
|
console.log("✓ Generated: package.json (workspace root)");
|
|
433
433
|
}
|
|
434
434
|
// Verdaccio is Premium, but unlike the old posture here, this is NOT the
|
|
@@ -461,14 +461,17 @@ export async function generateMasterConfigs(projectRoot, options = {}) {
|
|
|
461
461
|
const content = files[filename];
|
|
462
462
|
const filePath = assertTdkGeneratedPath(projectRoot, path.join(".tdk", ".tdk-out", filename));
|
|
463
463
|
writeTextFileAtomic(filePath, content);
|
|
464
|
-
|
|
464
|
+
if (!options.quiet)
|
|
465
|
+
console.log(`✓ Generated: .tdk/.tdk-out/${filename}`);
|
|
465
466
|
}
|
|
466
467
|
if (isStackFeatureEnabledInStacks(projectConfig.phases, DATABASE_MANAGEMENT_FEATURE)) {
|
|
467
468
|
const composePath = assertTdkGeneratedPath(projectRoot, path.join("services", "platform", DATABASE_MANAGEMENT_FEATURE, DOCKER_COMPOSE_FILE));
|
|
468
469
|
const composeDir = path.dirname(composePath);
|
|
469
470
|
fs.mkdirSync(composeDir, { recursive: true });
|
|
470
471
|
writeTextFileAtomic(composePath, generateDatabaseManagementCompose(projectConfig));
|
|
471
|
-
|
|
472
|
+
if (!options.quiet) {
|
|
473
|
+
console.log("✓ Generated: services/platform/database-management/docker-compose.yml");
|
|
474
|
+
}
|
|
472
475
|
}
|
|
473
476
|
// Copy .tiltignore to project root so Tilt uses it
|
|
474
477
|
const tiltignoreSource = path.join(outputDir, TILTIGNORE_FILE);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Copyright (c) 2026 TDK Landscape contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { statfsSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
export const DISK_SPACE_CHECK = "Disk Space";
|
|
6
|
+
const GIB = 1024 ** 3;
|
|
7
|
+
// Docker Desktop keeps images and containers in one large disk image. When the host disk fills up, the engine
|
|
8
|
+
// stops answering while the Docker Desktop app keeps running, so `tdk up` waits with no visible error.
|
|
9
|
+
export const DISK_FAIL_GIB = 2;
|
|
10
|
+
export const DISK_WARN_GIB = 10;
|
|
11
|
+
export const DISK_CLEANUP_FIX = [
|
|
12
|
+
"Free disk space, then retry: tdk doctor",
|
|
13
|
+
" docker system prune -af # unused images and build cache (needs Docker running)",
|
|
14
|
+
" npm cache clean --force",
|
|
15
|
+
" bun pm cache rm",
|
|
16
|
+
" brew cleanup -s",
|
|
17
|
+
].join("\n");
|
|
18
|
+
export function checkDiskSpace(statfs = statfsSync, path = homedir()) {
|
|
19
|
+
let freeGib;
|
|
20
|
+
try {
|
|
21
|
+
const stats = statfs(path);
|
|
22
|
+
freeGib = (Number(stats.bavail) * Number(stats.bsize)) / GIB;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return {
|
|
26
|
+
name: DISK_SPACE_CHECK,
|
|
27
|
+
didPass: true,
|
|
28
|
+
isSkipped: true,
|
|
29
|
+
message: "Could not read free disk space",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const shown = `${freeGib.toFixed(1)} GB free`;
|
|
33
|
+
if (freeGib < DISK_FAIL_GIB) {
|
|
34
|
+
return {
|
|
35
|
+
name: DISK_SPACE_CHECK,
|
|
36
|
+
didPass: false,
|
|
37
|
+
message: `${shown}: Docker cannot build or start containers`,
|
|
38
|
+
fix: DISK_CLEANUP_FIX,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (freeGib < DISK_WARN_GIB) {
|
|
42
|
+
return {
|
|
43
|
+
name: DISK_SPACE_CHECK,
|
|
44
|
+
didPass: false,
|
|
45
|
+
isWarning: true,
|
|
46
|
+
message: `${shown}: image builds may fail`,
|
|
47
|
+
fix: DISK_CLEANUP_FIX,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { name: DISK_SPACE_CHECK, didPass: true, message: shown };
|
|
51
|
+
}
|
|
52
|
+
export const DOCKER_DESKTOP_STUCK_FIX = "Quit Docker Desktop completely (whale icon > Quit Docker Desktop), reopen it, and wait until it shows Running. If it keeps getting stuck, check free disk space. Then retry: tdk doctor";
|
|
53
|
+
/**
|
|
54
|
+
* Docker Desktop's app process is up but `docker ps` fails: the engine is stopped or stuck, which needs a different fix
|
|
55
|
+
* than "start Docker". Returns the failing check, or null when that is not the situation.
|
|
56
|
+
*/
|
|
57
|
+
export function dockerDesktopStuck(desktopRunning, platform = process.platform) {
|
|
58
|
+
if (platform !== "darwin" || !desktopRunning)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
name: "Container Runtime",
|
|
62
|
+
didPass: false,
|
|
63
|
+
message: "Docker Desktop is open, but its engine is not responding",
|
|
64
|
+
fix: DOCKER_DESKTOP_STUCK_FIX,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -462,6 +462,66 @@ export function summarizeServiceProbes(probes) {
|
|
|
462
462
|
: "Check container state and routing: docker ps, then tdk networks to compare the advertised URLs against Traefik's routers",
|
|
463
463
|
};
|
|
464
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* Summarizes the read-only smoke checks. A 404 here usually means the service never registered the route its service.json
|
|
467
|
+
* names, which the health ping cannot see because /health answers regardless.
|
|
468
|
+
*/
|
|
469
|
+
export function summarizeSmokeResults(results) {
|
|
470
|
+
if (results.length === 0) {
|
|
471
|
+
return {
|
|
472
|
+
name: "Service Smoke",
|
|
473
|
+
didPass: true,
|
|
474
|
+
isSkipped: true,
|
|
475
|
+
message: "No read-only smoke checks declared",
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const failed = results.filter((result) => !result.ok);
|
|
479
|
+
if (failed.length === 0) {
|
|
480
|
+
return {
|
|
481
|
+
name: "Service Smoke",
|
|
482
|
+
didPass: true,
|
|
483
|
+
message: `All ${formatCount(results.length, "service")} pass their smoke checks`,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const details = failed.map((result) => result.failure ?? `${result.name}: failed`).join("\n ");
|
|
487
|
+
return {
|
|
488
|
+
name: "Service Smoke",
|
|
489
|
+
didPass: false,
|
|
490
|
+
message: `${formatCount(failed.length, "service")} failing a smoke check (${results.length - failed.length}/${results.length} passing):\n ${details}`,
|
|
491
|
+
fix: 'Add the route the smoke step expects, or correct its "path" in the service.json "smoke" block. Then re-run tdk doctor.',
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Flags advertised links that answer 404. Only a 404 counts: a route that answers with any other status is reachable, and many APIs
|
|
496
|
+
* have no root route, so this is a warning and never fails the run on its own.
|
|
497
|
+
*/
|
|
498
|
+
export function summarizeAdvertisedProbes(probes) {
|
|
499
|
+
const answered = probes.filter((probe) => probe.status !== undefined);
|
|
500
|
+
if (answered.length === 0) {
|
|
501
|
+
return {
|
|
502
|
+
name: "Advertised Endpoints",
|
|
503
|
+
didPass: true,
|
|
504
|
+
isSkipped: true,
|
|
505
|
+
message: "No services responded - skipped advertised endpoint check",
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const dead = answered.filter((probe) => probe.status === 404);
|
|
509
|
+
if (dead.length === 0) {
|
|
510
|
+
return {
|
|
511
|
+
name: "Advertised Endpoints",
|
|
512
|
+
didPass: true,
|
|
513
|
+
message: `${formatCount(answered.length, "advertised endpoint")} reachable`,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const details = dead.map((probe) => `${probe.name} (404)\n ${probe.url}`).join("\n ");
|
|
517
|
+
return {
|
|
518
|
+
name: "Advertised Endpoints",
|
|
519
|
+
didPass: true,
|
|
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.',
|
|
523
|
+
};
|
|
524
|
+
}
|
|
465
525
|
/**
|
|
466
526
|
* Catches host ports taken by something other than this project's containers,
|
|
467
527
|
* most often a local Postgres on 5432 or a web server on 80. Docker-held 80/443
|
|
@@ -4,7 +4,7 @@ import { execFileSync } from "node:child_process";
|
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { PROJECT_JSON } from "./constants.js";
|
|
7
|
-
import { createHostPortPlan, isHostPortAvailable } from "./host-port-plan.js";
|
|
7
|
+
import { createHostPortPlan, isHostPortAvailable, PREFERRED_HOST_PORTS, } from "./host-port-plan.js";
|
|
8
8
|
const CONFIG_PATH = join(".tdk", ".tdk-out", "host-ports.json");
|
|
9
9
|
export function isDockerPortOwnedByProject(dockerPs, projectPrefix, port) {
|
|
10
10
|
return dockerPs.split("\n").some((line) => {
|
|
@@ -78,12 +78,21 @@ export async function getHostPortPlan(projectRoot, options = {}) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
const ownedByProject = (port) => isDockerPortOwnedByProject(dockerPs, prefix, port);
|
|
81
|
-
const
|
|
81
|
+
const isPortFree = options.isPortFree ?? isHostPortAvailable;
|
|
82
|
+
const isAvailable = async (port) => (await isPortFree(port)) || ownedByProject(port);
|
|
82
83
|
if (saved && !hasOverride) {
|
|
83
84
|
for (const port of [saved.ingressHttp, saved.ingressHttps, saved.postgres]) {
|
|
84
85
|
if (!(await isAvailable(port)))
|
|
85
86
|
return createHostPortPlan({ isAvailable });
|
|
86
87
|
}
|
|
88
|
+
// A plan saved on a fallback port (8080) moves back to the preferred one (80)
|
|
89
|
+
// once that is free, so URLs lose the port without deleting host-ports.json.
|
|
90
|
+
for (const [key, preferred] of Object.entries(PREFERRED_HOST_PORTS)) {
|
|
91
|
+
const savedPort = saved[key];
|
|
92
|
+
if (preferred !== undefined && savedPort !== preferred && (await isAvailable(preferred))) {
|
|
93
|
+
return createHostPortPlan({ isAvailable });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
87
96
|
return saved;
|
|
88
97
|
}
|
|
89
98
|
return createHostPortPlan({ isAvailable });
|
|
@@ -13,12 +13,28 @@ const ENV_KEYS = {
|
|
|
13
13
|
postgres: "TDK_POSTGRES_PORT",
|
|
14
14
|
};
|
|
15
15
|
const REQUESTED_PORTS = { ingressHttp: 80, ingressHttps: 443, postgres: 5432 };
|
|
16
|
+
// Tried before the fallback range so routed URLs need no port. Postgres keeps
|
|
17
|
+
// its range: a local Postgres on 5432 is common, and no URL carries that port.
|
|
18
|
+
export const PREFERRED_HOST_PORTS = {
|
|
19
|
+
ingressHttp: REQUESTED_PORTS.ingressHttp,
|
|
20
|
+
ingressHttps: REQUESTED_PORTS.ingressHttps,
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Whether a failed bind on 127.0.0.1 means the port is still free for Docker. Only macOS
|
|
24
|
+
* refuses a non-root bind below 1024 while Docker Desktop publishes it through a privileged
|
|
25
|
+
* helper. Callers check for a listener first, so a bind error here is never another process.
|
|
26
|
+
*/
|
|
27
|
+
export function isPrivilegedBindFree(errorCode, platform) {
|
|
28
|
+
return errorCode === "EACCES" && platform === "darwin";
|
|
29
|
+
}
|
|
16
30
|
export async function isHostPortAvailable(port) {
|
|
17
31
|
if (await hasLocalListener(port))
|
|
18
32
|
return false;
|
|
19
33
|
return new Promise((resolve) => {
|
|
20
34
|
const server = createServer();
|
|
21
|
-
server.once("error", () =>
|
|
35
|
+
server.once("error", (error) => {
|
|
36
|
+
resolve(isPrivilegedBindFree(error.code, process.platform));
|
|
37
|
+
});
|
|
22
38
|
// Docker Desktop and Windows publish these host ports through IPv4 loopback.
|
|
23
39
|
// Match that target instead of probing wildcard binds that may differ by OS.
|
|
24
40
|
server.listen(port, "127.0.0.1", () => server.close(() => resolve(true)));
|
|
@@ -42,9 +58,13 @@ export async function createHostPortPlan(options = {}) {
|
|
|
42
58
|
const envValue = env[ENV_KEYS[key]];
|
|
43
59
|
const explicit = envValue !== undefined && envValue.trim() !== "";
|
|
44
60
|
const range = options.ranges?.[key] ?? DEFAULT_HOST_PORT_RANGES[key];
|
|
61
|
+
const preferred = PREFERRED_HOST_PORTS[key];
|
|
45
62
|
const candidates = explicit
|
|
46
63
|
? [parsePort(ENV_KEYS[key], envValue)]
|
|
47
|
-
:
|
|
64
|
+
: [
|
|
65
|
+
...(preferred === undefined ? [] : [preferred]),
|
|
66
|
+
...Array.from({ length: range.end - range.start + 1 }, (_, index) => range.start + index),
|
|
67
|
+
];
|
|
48
68
|
let chosen;
|
|
49
69
|
for (const port of candidates) {
|
|
50
70
|
if (reserved.has(port))
|
|
@@ -65,7 +85,9 @@ export async function createHostPortPlan(options = {}) {
|
|
|
65
85
|
explicitFlags[key] = explicit;
|
|
66
86
|
reasons[key] = explicit
|
|
67
87
|
? "explicit override"
|
|
68
|
-
:
|
|
88
|
+
: chosen === preferred
|
|
89
|
+
? "requested port is free"
|
|
90
|
+
: `selected from fallback range ${range.start}-${range.end}`;
|
|
69
91
|
}
|
|
70
92
|
return {
|
|
71
93
|
...selected,
|
|
@@ -26,7 +26,11 @@ export function resolveSubdomainBases(ingressPort) {
|
|
|
26
26
|
try {
|
|
27
27
|
const u = new URL(raw.includes("://") ? raw : `http://${raw}`);
|
|
28
28
|
const host = u.hostname;
|
|
29
|
-
|
|
29
|
+
// URL drops a default port (80 for http, 443 for https), so u.port is empty
|
|
30
|
+
// for http://x.localhost:80. Keep it empty instead of re-adding ingressPort.
|
|
31
|
+
const defaultPort = u.protocol === "https:" ? "443" : "80";
|
|
32
|
+
const explicitPort = u.port || (ingressPort ? String(ingressPort) : "");
|
|
33
|
+
const port = explicitPort === defaultPort ? "" : explicitPort;
|
|
30
34
|
if (host === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
|
31
35
|
return {
|
|
32
36
|
appBase: `${u.protocol}//${host}${port ? `:${port}` : ""}`,
|
|
@@ -63,6 +67,16 @@ export function resolveServicePath(resource) {
|
|
|
63
67
|
* skipped rather than reported as unreachable.
|
|
64
68
|
*/
|
|
65
69
|
export function buildHealthTargets(resources, ingressPort) {
|
|
70
|
+
return routableTargets(resources, ingressPort, (path) => appendHealthPath(path));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The URL `tdk up` and `tdk networks` advertise for each routable service, with no suffix. A service can answer /health while
|
|
74
|
+
* this URL is a 404 (nothing handles the bare path), which is the dead link users click.
|
|
75
|
+
*/
|
|
76
|
+
export function buildAdvertisedTargets(resources, ingressPort) {
|
|
77
|
+
return routableTargets(resources, ingressPort, (path) => path);
|
|
78
|
+
}
|
|
79
|
+
function routableTargets(resources, ingressPort, toPath) {
|
|
66
80
|
const { appBase, apiBase } = resolveSubdomainBases(ingressPort);
|
|
67
81
|
return resources
|
|
68
82
|
.filter((resource) => resource.config?.appType === "frontend" || isApiServiceType(resource.config?.appType))
|
|
@@ -71,7 +85,7 @@ export function buildHealthTargets(resources, ingressPort) {
|
|
|
71
85
|
return {
|
|
72
86
|
name: resource.name,
|
|
73
87
|
appType: resource.config.appType,
|
|
74
|
-
url: `${base}${
|
|
88
|
+
url: `${base}${toPath(resolveServicePath(resource))}`,
|
|
75
89
|
};
|
|
76
90
|
});
|
|
77
91
|
}
|
package/cli/dist/utils/smoke.js
CHANGED
|
@@ -105,6 +105,34 @@ export function buildSmokePlans(resources, ingressPort) {
|
|
|
105
105
|
}
|
|
106
106
|
return plans;
|
|
107
107
|
}
|
|
108
|
+
/** Caps each read-only smoke check so a doctor run cannot wait the full `tdk up` budget per service. */
|
|
109
|
+
const DOCTOR_SMOKE_TIMEOUT_SECONDS = 10;
|
|
110
|
+
/**
|
|
111
|
+
* The GET-only part of each smoke block, for `tdk doctor`. Doctor must never write to a service, and a step whose path reads
|
|
112
|
+
* a value saved by an earlier write (`{{name}}`) cannot run on its own, so both are dropped. A service with no such steps is left out.
|
|
113
|
+
* `bodyContains` is also cleared: it asserts on data that earlier writes created, so doctor would fail a healthy route whose data was removed.
|
|
114
|
+
* The status check stays, which is what shows a route exists.
|
|
115
|
+
*/
|
|
116
|
+
export function readOnlySmokePlans(plans, options = {}) {
|
|
117
|
+
const cap = options.timeoutSeconds ?? DOCTOR_SMOKE_TIMEOUT_SECONDS;
|
|
118
|
+
return plans.flatMap((plan) => {
|
|
119
|
+
const steps = plan.smoke.steps
|
|
120
|
+
.filter((step) => (step.method ?? "GET").toUpperCase() === "GET" &&
|
|
121
|
+
step.body === undefined &&
|
|
122
|
+
!step.path.includes("{{"))
|
|
123
|
+
.map((step) => ({ ...step, bodyContains: undefined }));
|
|
124
|
+
if (steps.length === 0)
|
|
125
|
+
return [];
|
|
126
|
+
const budget = Math.min(plan.smoke.timeoutSeconds ?? DEFAULT_SMOKE_TIMEOUT_SECONDS, cap);
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
name: plan.name,
|
|
130
|
+
baseUrl: plan.baseUrl,
|
|
131
|
+
smoke: { via: plan.smoke.via, timeoutSeconds: budget, steps },
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
});
|
|
135
|
+
}
|
|
108
136
|
function readPath(value, path) {
|
|
109
137
|
const parts = path.replace(/^\$\.?/, "").match(/[^.[\]]+/g) ?? [];
|
|
110
138
|
let current = value;
|