@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +2 -2
  3. package/Tiltfile +2 -0
  4. package/cli/dist/commands/doctor.js +55 -2
  5. package/cli/dist/commands/status.js +17 -20
  6. package/cli/dist/commands/up.js +10 -1
  7. package/cli/dist/generator/template-engine.js +6 -3
  8. package/cli/dist/utils/doctor-host.js +66 -0
  9. package/cli/dist/utils/doctor-runtime.js +60 -0
  10. package/cli/dist/utils/host-port-config.js +11 -2
  11. package/cli/dist/utils/host-port-plan.js +25 -3
  12. package/cli/dist/utils/service-urls.js +16 -2
  13. package/cli/dist/utils/smoke.js +28 -0
  14. package/cli/dist-bundle/chunk-SWAGCVPQ.js +321 -0
  15. package/cli/dist-bundle/cli.js +206 -206
  16. package/cli/dist-bundle/{cold-preflight-CGPSYGDB.js → cold-preflight-2ZV2PDU2.js} +1 -1
  17. package/cli/package.json +1 -1
  18. package/cli/templates/Tiltfile.hbs +12 -0
  19. package/engine/Tiltfile +2 -0
  20. package/engine/assets/scripts/cleanup-tilt-images.sh +2 -2
  21. package/engine/topologies/platform/docker/build/frontend_build_plan.star +65 -0
  22. package/engine/topologies/platform/docker/build/golden_image_build.star +104 -48
  23. package/engine/topologies/platform/docker/layers/l3_builder_layers.star +3 -1
  24. package/engine/topologies/tilt/resources/build_concurrency.star +56 -0
  25. package/engine/topologies/tilt/resources/infra-loader.star +4 -0
  26. package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +24 -2
  27. package/engine/topologies/tilt/resources/orchestrator/generators/manifest_resource.star +5 -1
  28. package/engine/topologies/tilt/resources/watchers.star +7 -4
  29. package/ext/ide-components/shared/templates/base.html +4 -8
  30. package/package.json +1 -1
  31. 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:8080/api/orders/health
55
+ curl http://api.shop.localhost/api/orders/health
56
56
  ```
57
57
 
58
- `tdk up` prints the host ports it chose. Port 8080 is the default; if it was taken, use the HTTP port `tdk up` reports (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.
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
  ![TDK scaffolding a backend and a frontend, then listing the stack](docs/assets/demo.svg)
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 { getTiltfilePath, isTiltAvailable, runTilt } from "../utils/tilt.js";
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 tiltfilePath = getTiltfilePath();
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("Could not retrieve tilt resource status");
179
+ showDetail(`No Tilt is answering on port ${tiltPort}. Start it with: tdk up`);
183
180
  }
184
181
  }
185
182
  console.log();
@@ -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
- console.log(`✓ Generated: .tdk/.tdk-out/${filename}`);
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
- console.log("✓ Generated: services/platform/database-management/docker-compose.yml");
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 isAvailable = async (port) => (await isHostPortAvailable(port)) || ownedByProject(port);
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", () => resolve(false));
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
- : Array.from({ length: range.end - range.start + 1 }, (_, index) => range.start + index);
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
- : `selected from fallback range ${range.start}-${range.end}`;
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
- const port = u.port || (ingressPort ? String(ingressPort) : "");
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}${appendHealthPath(resolveServicePath(resource))}`,
88
+ url: `${base}${toPath(resolveServicePath(resource))}`,
75
89
  };
76
90
  });
77
91
  }
@@ -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;