@tdk-landscape/tdk-cli-core 1.3.75 → 1.3.77

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 (77) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +59 -13
  3. package/cli/bin/tdk.js +22 -2
  4. package/cli/dist/cli.js +9 -2
  5. package/cli/dist/commands/completion.js +30 -3
  6. package/cli/dist/commands/config.js +59 -1
  7. package/cli/dist/commands/doctor.js +295 -98
  8. package/cli/dist/commands/eject.js +77 -0
  9. package/cli/dist/commands/networks.js +17 -4
  10. package/cli/dist/commands/project.js +28 -5
  11. package/cli/dist/commands/resource.js +118 -10
  12. package/cli/dist/commands/up.js +93 -21
  13. package/cli/dist/commands/upgrade.js +163 -68
  14. package/cli/dist/generator/template-engine.js +77 -13
  15. package/cli/dist/types/index.js +6 -1
  16. package/cli/dist/utils/cold-preflight.js +198 -0
  17. package/cli/dist/utils/constants.js +4 -0
  18. package/cli/dist/utils/docker.js +15 -1
  19. package/cli/dist/utils/doctor-runtime.js +48 -16
  20. package/cli/dist/utils/doctor-wiring.js +269 -0
  21. package/cli/dist/utils/errors.js +5 -5
  22. package/cli/dist/utils/exec-async.js +30 -0
  23. package/cli/dist/utils/generated-paths.js +49 -0
  24. package/cli/dist/utils/platform.js +53 -0
  25. package/cli/dist/utils/port-assignment.js +13 -20
  26. package/cli/dist/utils/project-templates.js +7 -2
  27. package/cli/dist/utils/resource-features.js +6 -2
  28. package/cli/dist/utils/service-manifest.js +73 -0
  29. package/cli/dist/utils/services.js +8 -1
  30. package/cli/dist/utils/tar.js +27 -5
  31. package/cli/dist/utils/tilt-process.js +53 -0
  32. package/cli/dist/utils/tilt.js +3 -1
  33. package/cli/dist/utils/which.js +26 -0
  34. package/cli/dist/utils/windows-doctor.js +175 -0
  35. package/cli/package.json +1 -1
  36. package/cli/templates/TILT_RESOURCE_DEFAULTS.star.hbs +1 -2
  37. package/cli/templates/TILT_TECH_STACK.star.hbs +0 -1
  38. package/cli/templates/Tiltfile.hbs +24 -4
  39. package/cli/templates/spec.master.hbs +1 -2
  40. package/discovery/AGENTS.md +1 -1
  41. package/discovery/constants.star +2 -2
  42. package/discovery/discovery_orchestrator.star +2 -2
  43. package/discovery/manifest/constants.star +1 -1
  44. package/discovery/manifest/loading.star +7 -3
  45. package/discovery/registry.star +17 -3
  46. package/engine/README.md +2 -0
  47. package/engine/schemas/service-schema.json +47 -21
  48. package/engine/scripts/generate-manifest.sh +2 -1
  49. package/engine/topologies/platform/docker/compose/compose.star +37 -5
  50. package/engine/topologies/tilt/common/utils.star +54 -11
  51. package/engine/topologies/tilt/manifest/AGENTS.md +1 -1
  52. package/engine/topologies/tilt/manifest/constants.star +3 -0
  53. package/engine/topologies/tilt/manifest/schema.star +2 -4
  54. package/engine/topologies/tilt/manifest/validator.star +1 -1
  55. package/engine/topologies/tilt/resources/infra-loader.star +14 -3
  56. package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +15 -11
  57. package/engine/topologies/tilt/resources/orchestrator/generators/manifest_resource.star +15 -6
  58. package/examples/tdk-example/LICENSE +21 -0
  59. package/examples/tdk-example/README.md +23 -0
  60. package/examples/tdk-example/TILT_SERVICE_DEFAULTS.star +42 -0
  61. package/examples/tdk-example/TILT_TECH_STACK.star +67 -0
  62. package/examples/tdk-example/Tiltfile +25 -0
  63. package/examples/tdk-example/services/platform/messaging/docker-compose.yml +26 -0
  64. package/examples/tdk-example/services/shop/orders-api/package.json +20 -0
  65. package/examples/tdk-example/services/shop/orders-api/service.json +14 -0
  66. package/examples/tdk-example/services/shop/orders-api/src/index.ts +81 -0
  67. package/examples/tdk-example/services/shop/orders-app/index.html +12 -0
  68. package/examples/tdk-example/services/shop/orders-app/package.json +20 -0
  69. package/examples/tdk-example/services/shop/orders-app/public/.gitkeep +0 -0
  70. package/examples/tdk-example/services/shop/orders-app/service.json +9 -0
  71. package/examples/tdk-example/services/shop/orders-app/src/main.tsx +9 -0
  72. package/examples/tdk-example/services/shop/orders-app/src/orders.tsx +67 -0
  73. package/examples/tdk-example/services/shop/orders-app/src/style.css +15 -0
  74. package/examples/tdk-example/services/shop/orders-worker/package.json +18 -0
  75. package/examples/tdk-example/services/shop/orders-worker/service.json +13 -0
  76. package/examples/tdk-example/services/shop/orders-worker/src/index.ts +33 -0
  77. package/package.json +2 -1
@@ -0,0 +1,198 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { checkDockerCompose, checkDockerRuntime, checkDockerVersions, checkTilt, } from "../commands/doctor.js";
5
+ import { checkHostPorts, checkIngressPorts } from "./doctor-runtime.js";
6
+ import { checkNatsBroker } from "./doctor-wiring.js";
7
+ import { findProjectRoot } from "./paths.js";
8
+ import { discoverResourcesFromRoot } from "./services.js";
9
+ import { findOnPath } from "./which.js";
10
+ /** Converts a doctor check result into the preflight's compact display item. */
11
+ function item(id, result) {
12
+ return {
13
+ id,
14
+ ok: result.didPass || Boolean(result.isSkipped),
15
+ message: result.message,
16
+ fix: result.fix,
17
+ };
18
+ }
19
+ /** Returns whether a Node version is below the CLI's 22.12 minimum. */
20
+ export function nodeTooOld(version) {
21
+ const [major = 0, minor = 0] = String(version)
22
+ .split(".")
23
+ .map((part) => Number.parseInt(part, 10));
24
+ return (!Number.isFinite(major) || !Number.isFinite(minor) || major < 22 || (major === 22 && minor < 12));
25
+ }
26
+ async function safeCheck(id, check, fallback) {
27
+ try {
28
+ return item(id, await check());
29
+ }
30
+ catch {
31
+ return { id, ok: false, message: fallback };
32
+ }
33
+ }
34
+ /** Requires a Docker engine and Compose version check to complete successfully. */
35
+ export function requireVerifiedDockerVersions(result) {
36
+ if (!result.isSkipped)
37
+ return result;
38
+ return {
39
+ ...result,
40
+ didPass: false,
41
+ isSkipped: false,
42
+ message: `Could not verify Docker Engine/Compose minimum versions: ${result.message}`,
43
+ fix: result.fix ?? "Start Docker and ensure Docker Engine 25+ and Compose 2.20.2+ are installed.",
44
+ };
45
+ }
46
+ function usesPrisma(projectRoot) {
47
+ try {
48
+ return discoverResourcesFromRoot(projectRoot).some((resource) => {
49
+ return resource.config?.featuresEnabled?.includes("prisma") ?? false;
50
+ });
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ export async function runColdPreflight(opts = {}) {
57
+ const cwd = opts.cwd ?? process.cwd();
58
+ const projectRoot = findProjectRoot(cwd);
59
+ const inProject = Boolean(projectRoot);
60
+ const items = [];
61
+ const nodeVersion = process.versions.node;
62
+ items.push({
63
+ id: "node",
64
+ ok: !nodeTooOld(nodeVersion),
65
+ message: nodeVersion,
66
+ fix: nodeTooOld(nodeVersion)
67
+ ? `Install Node.js 22.12+ (current: ${nodeVersion}). https://nodejs.org`
68
+ : undefined,
69
+ });
70
+ items.push(await safeCheck("docker", checkDockerRuntime, "Docker daemon is not running"));
71
+ items.push(await safeCheck("compose", async () => {
72
+ const [compose, versions] = await Promise.all([
73
+ checkDockerCompose(),
74
+ checkDockerVersions(),
75
+ ]);
76
+ if (!compose.didPass)
77
+ return compose;
78
+ return requireVerifiedDockerVersions(versions);
79
+ }, "Docker Engine or Compose version check failed"));
80
+ items.push(await safeCheck("tilt", checkTilt, "Tilt CLI not found"));
81
+ let bun = false;
82
+ const bunPath = findOnPath("bun");
83
+ if (bunPath) {
84
+ try {
85
+ const version = execFileSync(bunPath, ["--version"], {
86
+ encoding: "utf-8",
87
+ timeout: 3000,
88
+ }).trim();
89
+ const [major = 0, minor = 0] = version.split(".").map((part) => Number.parseInt(part, 10));
90
+ bun = major > 1 || (major === 1 && minor >= 2);
91
+ }
92
+ catch {
93
+ /* reported as a failed runtime check */
94
+ }
95
+ }
96
+ items.push({
97
+ id: "bun",
98
+ ok: bun,
99
+ message: bun ? "Bun 1.2+ is available on PATH" : "Bun 1.2+ not found on PATH",
100
+ fix: bun ? undefined : "curl -fsSL https://bun.sh/install | bash",
101
+ });
102
+ const [hostPorts, ingressPorts] = await Promise.all([
103
+ safeCheck("ports", checkHostPorts, "Could not check required host ports"),
104
+ safeCheck("ports", checkIngressPorts, "Could not check ingress ports"),
105
+ ]);
106
+ if (!hostPorts.ok || !ingressPorts.ok) {
107
+ const port5432 = !hostPorts.ok && /(?:^|\n)\s*5432\s*\(/.test(hostPorts.message);
108
+ const messages = [
109
+ !hostPorts.ok
110
+ ? port5432
111
+ ? "Port 5432 is taken (usually local Postgres). Stop it or TDK cannot bind the bundled Postgres."
112
+ : hostPorts.message
113
+ : undefined,
114
+ !ingressPorts.ok ? ingressPorts.message : undefined,
115
+ ].filter((message) => Boolean(message));
116
+ const fixes = [hostPorts.fix, ingressPorts.fix].filter((fix) => Boolean(fix));
117
+ items.push({
118
+ id: "ports",
119
+ ok: false,
120
+ message: messages.join("\n"),
121
+ fix: fixes.join(" "),
122
+ });
123
+ }
124
+ if (projectRoot) {
125
+ const nats = await safeCheck("nats", () => checkNatsBroker(projectRoot), "NATS broker check failed");
126
+ if (!nats.ok)
127
+ items.push(nats);
128
+ if (usesPrisma(projectRoot)) {
129
+ let dbUp = false;
130
+ try {
131
+ const config = JSON.parse(readFileSync(join(projectRoot, ".tdk", "project.json"), "utf-8"));
132
+ const projectName = String(config?.project?.name ?? "").replace(/-/g, "_");
133
+ if (projectName) {
134
+ const running = execFileSync(findOnPath("docker") ?? "docker", [
135
+ "ps",
136
+ "--filter",
137
+ `label=com.docker.compose.project=${projectName}`,
138
+ "--filter",
139
+ "label=com.docker.compose.service=postgres",
140
+ "--filter",
141
+ "health=healthy",
142
+ "--format",
143
+ "{{.Names}}",
144
+ ], { encoding: "utf-8", timeout: 3000 }).trim();
145
+ dbUp = running.length > 0;
146
+ }
147
+ }
148
+ catch {
149
+ /* Docker unavailable is already represented by the machine checks. */
150
+ }
151
+ if (!dbUp)
152
+ items.push({
153
+ id: "prisma",
154
+ ok: false,
155
+ message: "A Prisma resource is configured, but the project Postgres service is not healthy",
156
+ fix: "Run `tdk up` to start the bundled Postgres, then check `tdk doctor`.",
157
+ });
158
+ }
159
+ }
160
+ const failures = items.filter((check) => !check.ok).slice(0, 8);
161
+ const machineFailed = failures.some((check) => ["node", "docker", "compose", "tilt", "bun", "ports"].includes(check.id));
162
+ const bunFailed = failures.some((check) => check.id === "bun");
163
+ const header = failures.length
164
+ ? machineFailed
165
+ ? bunFailed
166
+ ? "Cold start blocked. Bun 1.2+ must be installed on PATH.\nPrisma/NATS configuration is project-specific and generated after `tdk project`. Fix the machine checks below."
167
+ : "Cold start blocked. Bun/Prisma/NATS are not the first failure.\nThey are generated after `tdk project`. Fix the machine checks below."
168
+ : "Machine is ready. Project wiring is not."
169
+ : "";
170
+ const footer = failures.length
171
+ ? ""
172
+ : inProject
173
+ ? "Environment ready for TDK"
174
+ : "This machine is ready for TDK.\nNext: tdk project --yes";
175
+ return { ok: failures.length === 0, inProject, items: failures, header, footer };
176
+ }
177
+ /** Formats only failed preflight checks and the applicable readiness message. */
178
+ export function formatColdPreflight(result, options = {}) {
179
+ const lines = [result.header];
180
+ for (const check of result.items) {
181
+ lines.push(`FAIL ${check.id.padEnd(7)} ${check.message}`);
182
+ if (check.fix)
183
+ lines.push(` ${check.fix}`);
184
+ }
185
+ if (result.footer && (options.includeSuccessFooter ?? true))
186
+ lines.push(result.footer);
187
+ return lines.filter(Boolean).join("\n");
188
+ }
189
+ /** Exits before machine-dependent command work when any machine check fails. */
190
+ export async function assertMachineReadyOrExit() {
191
+ const result = await runColdPreflight();
192
+ const machineFailure = result.items.some((check) => ["node", "docker", "compose", "tilt", "bun", "ports"].includes(check.id));
193
+ if (machineFailure) {
194
+ console.error(formatColdPreflight(result));
195
+ process.exit(1);
196
+ }
197
+ }
198
+ //# sourceMappingURL=cold-preflight.js.map
@@ -10,10 +10,12 @@ export const VALID_RESOURCE_TYPES = [
10
10
  "sdk",
11
11
  "worker",
12
12
  "migrator",
13
+ "bring-your-own",
13
14
  ];
14
15
  // Frontend/library/sdk services are served statically or consumed as
15
16
  // packages, so they never need a "start" script - only backend/worker/
16
17
  // migrator processes are run in production via `<runtime> run start`.
18
+ // bring-your-own doesn't generate application code, so no scripts required.
17
19
  export const REQUIRED_PACKAGE_SCRIPTS = {
18
20
  backend: ["dev", "build", "start"],
19
21
  worker: ["dev", "build", "start"],
@@ -21,6 +23,7 @@ export const REQUIRED_PACKAGE_SCRIPTS = {
21
23
  frontend: ["dev", "build"],
22
24
  library: ["dev", "build"],
23
25
  sdk: ["dev", "build"],
26
+ "bring-your-own": [],
24
27
  };
25
28
  // Version specifiers that pin nothing at all - a fresh `bun install`/`npm install`
26
29
  // can pull a different (possibly breaking) version on every run.
@@ -35,6 +38,7 @@ export const OPTIONAL_INFRA_SERVICES = [
35
38
  export const PORT_RANGES = {
36
39
  frontend: { base: 3000, min: 3000, max: 3999, range: "3000-3999" },
37
40
  backend: { base: 4000, min: 4000, max: 4999, range: "4000-4999" },
41
+ "bring-your-own": { base: 4000, min: 4000, max: 5999, range: "4000-5999" },
38
42
  worker: { base: 6000, min: 6000, max: 6999, range: "6000-6999" },
39
43
  health: { base: 5000, min: 5000, max: 5999, range: "5000-5999" },
40
44
  migrator: { base: 7000, min: 7000, max: 7999, range: "7000-7999" },
@@ -1,9 +1,23 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync, execSync } from "node:child_process";
2
+ import { findOnPath } from "./which.js";
2
3
  // A wedged daemon makes `docker ps` block forever instead of failing.
3
4
  const RUNTIME_CHECK_TIMEOUT_MS = 10_000;
4
5
  /** Whether Docker, Colima, or Podman is reachable, hung, or absent. */
5
6
  export function getContainerRuntimeStatus() {
6
7
  let sawTimeout = false;
8
+ if (process.platform === "win32") {
9
+ try {
10
+ execFileSync(findOnPath("docker") ?? "docker", ["ps"], {
11
+ stdio: "ignore",
12
+ timeout: RUNTIME_CHECK_TIMEOUT_MS,
13
+ windowsHide: true,
14
+ });
15
+ return "running";
16
+ }
17
+ catch (err) {
18
+ return err.code === "ETIMEDOUT" ? "unresponsive" : "missing";
19
+ }
20
+ }
7
21
  for (const command of ["docker ps", "colima status", "podman ps"]) {
8
22
  try {
9
23
  execSync(command, { stdio: "ignore", timeout: RUNTIME_CHECK_TIMEOUT_MS });
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync, execSync } from "node:child_process";
2
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
3
  import { createServer } from "node:net";
4
4
  import { join } from "node:path";
@@ -6,9 +6,23 @@ import { formatCount } from "./formatting.js";
6
6
  import { findProjectRoot } from "./paths.js";
7
7
  import { getProjectName } from "./service-urls.js";
8
8
  import { discoverResources, discoverResourcesFromRoot } from "./services.js";
9
+ import { findOnPath } from "./which.js";
9
10
  const EXEC_TIMEOUT_MS = 10_000;
10
11
  const REGISTRY_PROBE_TIMEOUT_MS = 3_000;
11
12
  const DEFAULT_VERDACCIO_URL = "http://localhost:4873";
13
+ export const HOST_PORT_FIXES = {
14
+ port80: "Stop the process bound to port 80, or stop local nginx/caddy. Then: tdk doctor",
15
+ port5432: "Stop local Postgres or change the host port. Then: tdk doctor",
16
+ };
17
+ function runHostCommand(exec, command, args, shellCommand, options) {
18
+ if (process.platform === "win32") {
19
+ return execFileSync(findOnPath(command) ?? command, args, {
20
+ ...options,
21
+ windowsHide: true,
22
+ });
23
+ }
24
+ return exec(shellCommand, options);
25
+ }
12
26
  /** Host ports Traefik publishes for local ingress. Without these, app routes never come up. */
13
27
  export const INGRESS_PORTS = [80, 443];
14
28
  export function toComposeProjectPrefix(projectName) {
@@ -232,7 +246,7 @@ export function checkPrivateNpmRegistry(exec = execSync, projectRoot = findProje
232
246
  }
233
247
  let containerRunning = false;
234
248
  try {
235
- const names = exec("docker ps --format '{{.Names}}'", {
249
+ const names = runHostCommand(exec, "docker", ["ps", "--format", "{{.Names}}"], "docker ps --format '{{.Names}}'", {
236
250
  stdio: "pipe",
237
251
  encoding: "utf-8",
238
252
  timeout: EXEC_TIMEOUT_MS,
@@ -246,11 +260,18 @@ export function checkPrivateNpmRegistry(exec = execSync, projectRoot = findProje
246
260
  let httpDetail = "";
247
261
  try {
248
262
  // curl is more reliable than fetch in the compiled CLI binary environments.
249
- const probe = exec(`curl -fsS -o /dev/null -w '%{http_code}' --max-time 2 ${JSON.stringify(registryUrl)}`, {
250
- stdio: "pipe",
251
- encoding: "utf-8",
252
- timeout: REGISTRY_PROBE_TIMEOUT_MS + 1_000,
253
- }).trim();
263
+ const probe = process.platform === "win32"
264
+ ? execFileSync("curl.exe", ["-fsS", "-o", "NUL", "-w", "%{http_code}", "--max-time", "2", registryUrl], {
265
+ stdio: "pipe",
266
+ encoding: "utf-8",
267
+ timeout: REGISTRY_PROBE_TIMEOUT_MS + 1_000,
268
+ windowsHide: true,
269
+ }).trim()
270
+ : exec(`curl -fsS -o /dev/null -w '%{http_code}' --max-time 2 ${JSON.stringify(registryUrl)}`, {
271
+ stdio: "pipe",
272
+ encoding: "utf-8",
273
+ timeout: REGISTRY_PROBE_TIMEOUT_MS + 1_000,
274
+ }).trim();
254
275
  httpOk = /^[23]\d\d$/.test(probe);
255
276
  httpDetail = `HTTP ${probe}`;
256
277
  }
@@ -288,7 +309,7 @@ export function checkIngressPorts(exec = execSync, projectName = getProjectName(
288
309
  const projectPrefix = toComposeProjectPrefix(projectName);
289
310
  let dockerPs = "";
290
311
  try {
291
- dockerPs = exec("docker ps --format '{{.Names}}\\t{{.Ports}}'", {
312
+ dockerPs = runHostCommand(exec, "docker", ["ps", "--format", "{{.Names}}\\t{{.Ports}}"], "docker ps --format '{{.Names}}\\t{{.Ports}}'", {
292
313
  stdio: "pipe",
293
314
  encoding: "utf-8",
294
315
  timeout: EXEC_TIMEOUT_MS,
@@ -422,7 +443,8 @@ export function summarizeServiceProbes(probes) {
422
443
  * containers here.
423
444
  */
424
445
  export async function checkHostPorts(exec = execSync, projectName = getProjectName(), probe = probeHostPort) {
425
- const ports = Object.keys(HOST_PORTS).map(Number);
446
+ // Postgres is the most common environment blocker and must be surfaced first.
447
+ const ports = [5432, 80, 443];
426
448
  const inUse = [];
427
449
  for (const port of ports) {
428
450
  if ((await probe(port)) === "in-use")
@@ -437,7 +459,7 @@ export async function checkHostPorts(exec = execSync, projectName = getProjectNa
437
459
  }
438
460
  let dockerPs = "";
439
461
  try {
440
- dockerPs = exec("docker ps --format '{{.Names}}\\t{{.Ports}}'", {
462
+ dockerPs = runHostCommand(exec, "docker", ["ps", "--format", "{{.Names}}\\t{{.Ports}}"], "docker ps --format '{{.Names}}\\t{{.Ports}}'", {
441
463
  stdio: "pipe",
442
464
  encoding: "utf-8",
443
465
  timeout: EXEC_TIMEOUT_MS,
@@ -456,7 +478,8 @@ export async function checkHostPorts(exec = execSync, projectName = getProjectNa
456
478
  for (const port of inUse) {
457
479
  const holder = holders.find((h) => h.publishedPorts.includes(port));
458
480
  if (!holder) {
459
- problems.push(`${port} (${HOST_PORTS[port]}) is used by a program on this machine`);
481
+ const detail = port === 5432 ? "; inspect it with lsof -nP -iTCP:5432" : "";
482
+ problems.push(`${port} (${HOST_PORTS[port]}) is used by a program on this machine${detail}`);
460
483
  }
461
484
  else if (port === 5432 && !isOwn(holder.name)) {
462
485
  problems.push(`${port} (${HOST_PORTS[port]}) is published by container ${holder.name}`);
@@ -466,12 +489,21 @@ export async function checkHostPorts(exec = execSync, projectName = getProjectNa
466
489
  return { name: "Host Ports", didPass: true, message: "Host ports are held by this project" };
467
490
  }
468
491
  const first = inUse.find((port) => problems.some((p) => p.startsWith(`${port} `))) ?? inUse[0];
492
+ let fix;
493
+ if (first === 80) {
494
+ fix = HOST_PORT_FIXES.port80;
495
+ }
496
+ else if (first === 5432) {
497
+ fix = HOST_PORT_FIXES.port5432;
498
+ }
499
+ else {
500
+ fix = `Stop the process bound to port ${first}. Then: tdk doctor`;
501
+ }
469
502
  return {
470
503
  name: "Host Ports",
471
504
  didPass: false,
472
505
  message: `Ports TDK needs are taken:\n ${problems.join("\n ")}`,
473
- fix: `Find what holds a port with \`lsof -nP -iTCP:${first} -sTCP:LISTEN\` and stop it ` +
474
- "(for a local Postgres: `brew services stop postgresql` or quit Postgres.app), then run `tdk up`.",
506
+ fix,
475
507
  };
476
508
  }
477
509
  /**
@@ -482,7 +514,7 @@ export async function checkHostPorts(exec = execSync, projectName = getProjectNa
482
514
  export function checkTiltResourceHealth(exec = execSync) {
483
515
  let jsonText = "";
484
516
  try {
485
- jsonText = exec("tilt get uiresources -o json", {
517
+ jsonText = runHostCommand(exec, "tilt", ["get", "uiresources", "-o", "json"], "tilt get uiresources -o json", {
486
518
  stdio: "pipe",
487
519
  encoding: "utf-8",
488
520
  timeout: EXEC_TIMEOUT_MS,
@@ -588,7 +620,7 @@ export function orderTiltFailures(failures) {
588
620
  */
589
621
  export function probeContainerRuntimeError(resourceName, exec = execSync) {
590
622
  try {
591
- const names = exec("docker ps -a --format '{{.Names}}'", {
623
+ const names = runHostCommand(exec, "docker", ["ps", "-a", "--format", "{{.Names}}"], "docker ps -a --format '{{.Names}}'", {
592
624
  stdio: "pipe",
593
625
  encoding: "utf-8",
594
626
  timeout: EXEC_TIMEOUT_MS,
@@ -600,7 +632,7 @@ export function probeContainerRuntimeError(resourceName, exec = execSync) {
600
632
  names.find((name) => name.includes(resourceName));
601
633
  if (!match)
602
634
  return null;
603
- const logs = exec(`docker logs --tail 120 ${JSON.stringify(match)} 2>&1`, {
635
+ const logs = runHostCommand(exec, "docker", ["logs", "--tail", "120", match], `docker logs --tail 120 ${JSON.stringify(match)} 2>&1`, {
604
636
  stdio: "pipe",
605
637
  encoding: "utf-8",
606
638
  timeout: EXEC_TIMEOUT_MS,
@@ -0,0 +1,269 @@
1
+ import { execFileSync, execSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { execAsync } from "./exec-async.js";
5
+ import { formatCount } from "./formatting.js";
6
+ import { findProjectRoot } from "./paths.js";
7
+ import { discoverResourcesFromRoot } from "./services.js";
8
+ /**
9
+ * Checks for wiring mistakes that only show up minutes into `tdk up`: an image
10
+ * build that cannot find package.json, a token check that cannot reach its
11
+ * identity provider, a frontend calling a port nothing publishes, a queue
12
+ * feature with no broker. Each one is decidable from the files on disk.
13
+ */
14
+ const EXEC_TIMEOUT_MS = 10_000;
15
+ const SOURCE_EXTENSIONS = /\.(?:[cm]?[jt]sx?|vue)$/;
16
+ const TEST_FILE = /(?:\.test\.|\.spec\.|\/tests?\/|\/__tests__\/)/;
17
+ const SKIPPED_DIRS = new Set(["node_modules", ".autogenerated", "dist", "tests", "__tests__"]);
18
+ function projectName(projectRoot) {
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(join(projectRoot, ".tdk", "project.json"), "utf-8"));
21
+ return typeof parsed?.project?.name === "string" ? parsed.project.name : undefined;
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ function readParams(configPath) {
28
+ try {
29
+ const params = JSON.parse(readFileSync(configPath, "utf-8")).params;
30
+ return params && typeof params === "object" ? params : {};
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ /** Backends are the only resources that listen on their `port`. */
37
+ function backendPorts(resources) {
38
+ const ports = new Map();
39
+ for (const resource of resources) {
40
+ const port = resource.config?.port;
41
+ if (resource.config?.appType === "backend" && typeof port === "number") {
42
+ ports.set(resource.name, port);
43
+ }
44
+ }
45
+ return ports;
46
+ }
47
+ export function checkResourcePackageJson(projectRoot = findProjectRoot() ?? process.cwd()) {
48
+ const missing = discoverResourcesFromRoot(projectRoot)
49
+ .filter((resource) => !existsSync(join(resource.path, "package.json")))
50
+ .map((resource) => resource.name);
51
+ if (missing.length === 0) {
52
+ return {
53
+ name: "Resource package.json",
54
+ didPass: true,
55
+ message: "Every resource has a package.json",
56
+ };
57
+ }
58
+ return {
59
+ name: "Resource package.json",
60
+ didPass: false,
61
+ message: `${formatCount(missing.length, "resource")} without a package.json: ${missing.join(", ")}. The image build stops at "COPY .../package.json: not found"`,
62
+ fix: "Add a package.json next to each service.json (copy one from a working resource), or delete the folder's service.json if it is not a service",
63
+ };
64
+ }
65
+ const CONTAINER_URL = /https?:\/\/([a-z][a-z0-9-]*):(\d+)/g;
66
+ export function checkServiceUrlPorts(projectRoot = findProjectRoot() ?? process.cwd()) {
67
+ const resources = discoverResourcesFromRoot(projectRoot);
68
+ const ports = backendPorts(resources);
69
+ const byPort = new Map([...ports].map(([name, port]) => [port, name]));
70
+ const problems = [];
71
+ for (const resource of resources) {
72
+ for (const [key, value] of Object.entries(readParams(resource.configPath))) {
73
+ for (const [, host, portText] of String(value).matchAll(CONTAINER_URL)) {
74
+ const port = Number(portText);
75
+ const expected = ports.get(host);
76
+ if (expected !== undefined && expected !== port) {
77
+ problems.push(`${resource.name} param ${key}: ${host}:${port}, but ${host} listens on ${expected}`);
78
+ }
79
+ else if ((host === "localhost" || host === "127.0.0.1") &&
80
+ byPort.has(port) &&
81
+ byPort.get(port) !== resource.name) {
82
+ problems.push(`${resource.name} param ${key}: localhost:${port} is ${resource.name} itself inside its container; ${byPort.get(port)} is reached as ${byPort.get(port)}:${port}`);
83
+ }
84
+ }
85
+ }
86
+ }
87
+ if (problems.length === 0) {
88
+ return {
89
+ name: "Service URL ports",
90
+ didPass: true,
91
+ message: "Service URLs in params point at the ports the services listen on",
92
+ };
93
+ }
94
+ return {
95
+ name: "Service URL ports",
96
+ didPass: false,
97
+ message: `${formatCount(problems.length, "service URL")} in params that cannot connect:\n ${problems.join("\n ")}`,
98
+ fix: 'TDK runs each backend on the "port" in its service.json, and containers reach each other by resource name (http://<resource>:<port>). Use that port in the URL',
99
+ };
100
+ }
101
+ function listSourceFiles(dir) {
102
+ const files = [];
103
+ let entries;
104
+ try {
105
+ entries = readdirSync(dir, { withFileTypes: true });
106
+ }
107
+ catch {
108
+ return files;
109
+ }
110
+ for (const entry of entries) {
111
+ const path = join(dir, entry.name);
112
+ if (entry.isDirectory()) {
113
+ if (!SKIPPED_DIRS.has(entry.name))
114
+ files.push(...listSourceFiles(path));
115
+ }
116
+ else if (SOURCE_EXTENSIONS.test(entry.name) && !TEST_FILE.test(path)) {
117
+ files.push(path);
118
+ }
119
+ }
120
+ return files;
121
+ }
122
+ const LOCALHOST_PORT = /(?:localhost|127\.0\.0\.1):(\d{2,5})\b/g;
123
+ const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*|<!--|#)/;
124
+ export function checkFrontendBackendUrls(projectRoot = findProjectRoot() ?? process.cwd()) {
125
+ const resources = discoverResourcesFromRoot(projectRoot);
126
+ const byPort = new Map([...backendPorts(resources)].map(([name, port]) => [port, name]));
127
+ const problems = [];
128
+ for (const resource of resources.filter((r) => r.config?.appType === "frontend")) {
129
+ for (const file of listSourceFiles(join(resource.path, "src"))) {
130
+ readFileSync(file, "utf-8")
131
+ .split("\n")
132
+ .forEach((line, index) => {
133
+ if (COMMENT_LINE.test(line))
134
+ return;
135
+ for (const [, portText] of line.matchAll(LOCALHOST_PORT)) {
136
+ const backend = byPort.get(Number(portText));
137
+ if (backend) {
138
+ problems.push(`${relative(projectRoot, file)}:${index + 1} calls localhost:${portText} (${backend})`);
139
+ }
140
+ }
141
+ });
142
+ }
143
+ }
144
+ if (problems.length === 0) {
145
+ return {
146
+ name: "Frontend backend URLs",
147
+ didPass: true,
148
+ message: "No frontend calls a backend through a localhost port",
149
+ };
150
+ }
151
+ const project = projectName(projectRoot) ?? "<project>";
152
+ return {
153
+ name: "Frontend backend URLs",
154
+ didPass: false,
155
+ message: `${formatCount(problems.length, "frontend call")} to a backend port that TDK does not publish on localhost:\n ${problems.join("\n ")}`,
156
+ fix: `Backends are only reachable through Traefik: http://api.${project}.localhost/api/<name without -api>/... (for example catalog-api is /api/catalog). Use that URL, or the generated .autogenerated/env.ts`,
157
+ };
158
+ }
159
+ export function checkNatsBroker(projectRoot = findProjectRoot() ?? process.cwd()) {
160
+ const users = discoverResourcesFromRoot(projectRoot)
161
+ .filter((resource) => resource.config?.featuresEnabled?.includes("nats"))
162
+ .map((resource) => resource.name);
163
+ if (users.length === 0) {
164
+ return {
165
+ name: "NATS broker",
166
+ didPass: true,
167
+ message: "No resource enables the nats feature",
168
+ isSkipped: true,
169
+ };
170
+ }
171
+ if (existsSync(join(projectRoot, "services", "platform", "messaging", "docker-compose.yml"))) {
172
+ return {
173
+ name: "NATS broker",
174
+ didPass: true,
175
+ message: `${formatCount(users.length, "resource")} enable nats and services/platform/messaging/docker-compose.yml is present`,
176
+ };
177
+ }
178
+ const name = (projectName(projectRoot) ?? "project").replace(/-/g, "_");
179
+ return {
180
+ name: "NATS broker",
181
+ didPass: false,
182
+ message: `${users.join(", ")} enable the nats feature, but no NATS server will start: TDK only starts one when services/platform/messaging/docker-compose.yml exists, and it does not generate that file`,
183
+ fix: `Create it with a "nats" service (image nats with -js, container_name ${name}_nats, network ${name}_backend, external) and a "redis" service, which TDK registers alongside it`,
184
+ };
185
+ }
186
+ export function parseTiltProcesses(psOutput) {
187
+ const processes = [];
188
+ for (const line of psOutput.split("\n")) {
189
+ const match = line.match(/^\s*(\d+)\s+.*\btilt up\b.*?\s-f\s+(\S+)/i);
190
+ if (!match)
191
+ continue;
192
+ processes.push({ pid: Number(match[1]), root: match[2].split(/[\\/]\.tdk[\\/]/i)[0] });
193
+ }
194
+ return processes;
195
+ }
196
+ export function checkTiltInstances(projectRoot = findProjectRoot() ?? process.cwd(), exec = execSync) {
197
+ let output;
198
+ try {
199
+ output =
200
+ process.platform === "win32"
201
+ ? execFileSync("powershell.exe", [
202
+ "-NoProfile",
203
+ "-Command",
204
+ "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'tilt.exe' } | ForEach-Object { '{0} {1}' -f $_.ProcessId, $_.CommandLine }",
205
+ ], { encoding: "utf-8", timeout: EXEC_TIMEOUT_MS, windowsHide: true })
206
+ : exec("ps -Ao pid=,command=", { encoding: "utf-8", timeout: EXEC_TIMEOUT_MS });
207
+ }
208
+ catch {
209
+ return {
210
+ name: "Tilt processes",
211
+ didPass: true,
212
+ isSkipped: true,
213
+ message: "Could not list processes",
214
+ };
215
+ }
216
+ const running = parseTiltProcesses(output);
217
+ const mine = running.filter((p) => p.root === projectRoot);
218
+ const others = running.filter((p) => p.root !== projectRoot);
219
+ if (mine.length > 1) {
220
+ return {
221
+ name: "Tilt processes",
222
+ didPass: false,
223
+ message: `${mine.length} Tilt processes are running for this project (pids ${mine.map((p) => p.pid).join(", ")}). They fight over ports and containers`,
224
+ fix: `Stop them all, then start one: tdk down && kill ${mine.map((p) => p.pid).join(" ")} && tdk up`,
225
+ };
226
+ }
227
+ if (others.length > 0) {
228
+ const list = others.map((p) => `${p.root} (pid ${p.pid})`).join(", ");
229
+ return {
230
+ name: "Tilt processes",
231
+ didPass: true,
232
+ isSkipped: true,
233
+ message: `Another project's Tilt is running: ${list}`,
234
+ fix: "tdk up will use the next free Tilt UI port (10351, ...). If it is a leftover, run tdk down in that project",
235
+ };
236
+ }
237
+ return { name: "Tilt processes", didPass: true, message: "No stray Tilt processes" };
238
+ }
239
+ export async function checkDockerNetworkCapacity(exec = execAsync) {
240
+ const probe = `tdk_doctor_probe_${process.pid}`;
241
+ try {
242
+ await exec(`docker network create ${probe}`, EXEC_TIMEOUT_MS);
243
+ }
244
+ catch (error) {
245
+ const stderr = String(error.stderr ?? error);
246
+ if (/address pools/i.test(stderr)) {
247
+ return {
248
+ name: "Docker networks",
249
+ didPass: false,
250
+ message: "Docker cannot create another network: all predefined address pools are in use. Each TDK project needs several",
251
+ fix: "docker network prune (removes unused networks), or run tdk down in projects you are not using",
252
+ };
253
+ }
254
+ return {
255
+ name: "Docker networks",
256
+ didPass: true,
257
+ isSkipped: true,
258
+ message: "Could not test network creation",
259
+ };
260
+ }
261
+ try {
262
+ await exec(`docker network rm ${probe}`, EXEC_TIMEOUT_MS);
263
+ }
264
+ catch {
265
+ // A leftover probe network is harmless and tdk_doctor_probe_* is easy to prune.
266
+ }
267
+ return { name: "Docker networks", didPass: true, message: "Docker can create networks" };
268
+ }
269
+ //# sourceMappingURL=doctor-wiring.js.map