@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.
- package/CHANGELOG.md +6 -0
- package/README.md +59 -13
- package/cli/bin/tdk.js +22 -2
- package/cli/dist/cli.js +9 -2
- package/cli/dist/commands/completion.js +30 -3
- package/cli/dist/commands/config.js +59 -1
- package/cli/dist/commands/doctor.js +295 -98
- package/cli/dist/commands/eject.js +77 -0
- package/cli/dist/commands/networks.js +17 -4
- package/cli/dist/commands/project.js +28 -5
- package/cli/dist/commands/resource.js +118 -10
- package/cli/dist/commands/up.js +93 -21
- package/cli/dist/commands/upgrade.js +163 -68
- package/cli/dist/generator/template-engine.js +77 -13
- package/cli/dist/types/index.js +6 -1
- package/cli/dist/utils/cold-preflight.js +198 -0
- package/cli/dist/utils/constants.js +4 -0
- package/cli/dist/utils/docker.js +15 -1
- package/cli/dist/utils/doctor-runtime.js +48 -16
- package/cli/dist/utils/doctor-wiring.js +269 -0
- package/cli/dist/utils/errors.js +5 -5
- package/cli/dist/utils/exec-async.js +30 -0
- package/cli/dist/utils/generated-paths.js +49 -0
- package/cli/dist/utils/platform.js +53 -0
- package/cli/dist/utils/port-assignment.js +13 -20
- package/cli/dist/utils/project-templates.js +7 -2
- package/cli/dist/utils/resource-features.js +6 -2
- package/cli/dist/utils/service-manifest.js +73 -0
- package/cli/dist/utils/services.js +8 -1
- package/cli/dist/utils/tar.js +27 -5
- package/cli/dist/utils/tilt-process.js +53 -0
- package/cli/dist/utils/tilt.js +3 -1
- package/cli/dist/utils/which.js +26 -0
- package/cli/dist/utils/windows-doctor.js +175 -0
- package/cli/package.json +1 -1
- package/cli/templates/TILT_RESOURCE_DEFAULTS.star.hbs +1 -2
- package/cli/templates/TILT_TECH_STACK.star.hbs +0 -1
- package/cli/templates/Tiltfile.hbs +24 -4
- package/cli/templates/spec.master.hbs +1 -2
- package/discovery/AGENTS.md +1 -1
- package/discovery/constants.star +2 -2
- package/discovery/discovery_orchestrator.star +2 -2
- package/discovery/manifest/constants.star +1 -1
- package/discovery/manifest/loading.star +7 -3
- package/discovery/registry.star +17 -3
- package/engine/README.md +2 -0
- package/engine/schemas/service-schema.json +47 -21
- package/engine/scripts/generate-manifest.sh +2 -1
- package/engine/topologies/platform/docker/compose/compose.star +37 -5
- package/engine/topologies/tilt/common/utils.star +54 -11
- package/engine/topologies/tilt/manifest/AGENTS.md +1 -1
- package/engine/topologies/tilt/manifest/constants.star +3 -0
- package/engine/topologies/tilt/manifest/schema.star +2 -4
- package/engine/topologies/tilt/manifest/validator.star +1 -1
- package/engine/topologies/tilt/resources/infra-loader.star +14 -3
- package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +15 -11
- package/engine/topologies/tilt/resources/orchestrator/generators/manifest_resource.star +15 -6
- package/examples/tdk-example/LICENSE +21 -0
- package/examples/tdk-example/README.md +23 -0
- package/examples/tdk-example/TILT_SERVICE_DEFAULTS.star +42 -0
- package/examples/tdk-example/TILT_TECH_STACK.star +67 -0
- package/examples/tdk-example/Tiltfile +25 -0
- package/examples/tdk-example/services/platform/messaging/docker-compose.yml +26 -0
- package/examples/tdk-example/services/shop/orders-api/package.json +20 -0
- package/examples/tdk-example/services/shop/orders-api/service.json +14 -0
- package/examples/tdk-example/services/shop/orders-api/src/index.ts +81 -0
- package/examples/tdk-example/services/shop/orders-app/index.html +12 -0
- package/examples/tdk-example/services/shop/orders-app/package.json +20 -0
- package/examples/tdk-example/services/shop/orders-app/public/.gitkeep +0 -0
- package/examples/tdk-example/services/shop/orders-app/service.json +9 -0
- package/examples/tdk-example/services/shop/orders-app/src/main.tsx +9 -0
- package/examples/tdk-example/services/shop/orders-app/src/orders.tsx +67 -0
- package/examples/tdk-example/services/shop/orders-app/src/style.css +15 -0
- package/examples/tdk-example/services/shop/orders-worker/package.json +18 -0
- package/examples/tdk-example/services/shop/orders-worker/service.json +13 -0
- package/examples/tdk-example/services/shop/orders-worker/src/index.ts +33 -0
- 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" },
|
package/cli/dist/utils/docker.js
CHANGED
|
@@ -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
|
|
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 =
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|