@tdk-landscape/tdk-cli-core 1.3.79 → 1.3.81
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 +8 -0
- package/README.md +39 -206
- package/cli/dist/cli.js +2 -0
- package/cli/dist/commands/completion.js +15 -2
- package/cli/dist/commands/config.js +34 -6
- package/cli/dist/commands/doctor.js +103 -30
- package/cli/dist/commands/help.js +8 -2
- package/cli/dist/commands/networks.js +126 -101
- package/cli/dist/commands/resource.js +4 -1
- package/cli/dist/commands/resources.js +40 -9
- package/cli/dist/commands/runtime.js +74 -0
- package/cli/dist/commands/status.js +65 -7
- package/cli/dist/commands/up.js +12 -10
- package/cli/dist/generator/template-engine.js +29 -17
- package/cli/dist/utils/cold-preflight.js +12 -18
- package/cli/dist/utils/doctor-report.js +64 -0
- package/cli/dist/utils/doctor-runtime.js +4 -4
- package/cli/dist/utils/host-port-config.js +88 -0
- package/cli/dist/utils/host-port-plan.js +74 -0
- package/cli/dist/utils/machine-output.js +24 -0
- package/cli/dist/utils/service-manifest.js +1 -0
- package/cli/dist/utils/service-urls.js +10 -8
- package/cli/package.json +1 -1
- package/engine/schemas/project-schema.json +142 -0
- package/engine/topologies/platform/docker/compose/traefik_standalone.star +5 -3
- package/engine/topologies/tilt/resources/infra-loader.star +7 -32
- package/package.json +2 -2
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { findCliAssetRoot, loadTemplate } from "../generator/template-engine.js";
|
|
5
|
+
const REQUIRED_ASSET_PATHS = [
|
|
6
|
+
{ path: "Tiltfile", directory: false },
|
|
7
|
+
{ path: "engine", directory: true },
|
|
8
|
+
{ path: "discovery", directory: true },
|
|
9
|
+
{ path: "specs", directory: true },
|
|
10
|
+
{ path: "ext", directory: true },
|
|
11
|
+
{ path: "cli/templates", directory: true },
|
|
12
|
+
{ path: "shared-platform-engineering/docker-templates", directory: true },
|
|
13
|
+
];
|
|
14
|
+
const REQUIRED_TEMPLATES = [
|
|
15
|
+
".tiltignore.hbs",
|
|
16
|
+
"TILT_RESOURCE_DEFAULTS.star.hbs",
|
|
17
|
+
"TILT_TECH_STACK.star.hbs",
|
|
18
|
+
"Tiltfile.hbs",
|
|
19
|
+
"spec.master.hbs",
|
|
20
|
+
];
|
|
21
|
+
export const runtimeCommand = new Command("runtime")
|
|
22
|
+
.description("Inspect the packaged TDK runtime assets")
|
|
23
|
+
.option("--check-assets", "Check bundled engine and template assets")
|
|
24
|
+
.option("--json", "Print the check result as JSON")
|
|
25
|
+
.action((options) => {
|
|
26
|
+
if (!options.checkAssets) {
|
|
27
|
+
runtimeCommand.outputHelp();
|
|
28
|
+
process.exitCode = 2;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const candidate = findCliAssetRoot();
|
|
32
|
+
const assetRoot = candidate ? resolve(candidate) : null;
|
|
33
|
+
const missing = assetRoot
|
|
34
|
+
? REQUIRED_ASSET_PATHS.filter(({ path, directory }) => {
|
|
35
|
+
try {
|
|
36
|
+
const stats = statSync(join(assetRoot, path));
|
|
37
|
+
return directory ? !stats.isDirectory() : !stats.isFile();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}).map(({ path }) => path)
|
|
43
|
+
: REQUIRED_ASSET_PATHS.map(({ path }) => path);
|
|
44
|
+
const templateErrors = [];
|
|
45
|
+
if (assetRoot && missing.length === 0) {
|
|
46
|
+
for (const template of REQUIRED_TEMPLATES) {
|
|
47
|
+
try {
|
|
48
|
+
loadTemplate(template);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
templateErrors.push(error instanceof Error ? error.message : `Failed to load template ${template}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const ok = Boolean(assetRoot) && missing.length === 0 && templateErrors.length === 0;
|
|
56
|
+
if (options.json) {
|
|
57
|
+
console.log(JSON.stringify({ ok, assetRoot, missing, templateErrors }));
|
|
58
|
+
}
|
|
59
|
+
else if (ok) {
|
|
60
|
+
console.log(`TDK runtime assets found at ${assetRoot}`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
console.error([
|
|
64
|
+
assetRoot
|
|
65
|
+
? `Incomplete TDK runtime assets at ${assetRoot}`
|
|
66
|
+
: "TDK runtime assets not found",
|
|
67
|
+
...missing.map((path) => `Missing: ${path}`),
|
|
68
|
+
...templateErrors,
|
|
69
|
+
].join("\n"));
|
|
70
|
+
}
|
|
71
|
+
if (!ok)
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
});
|
|
74
|
+
//# sourceMappingURL=runtime.js.map
|
|
@@ -3,15 +3,17 @@ import { Command } from "commander";
|
|
|
3
3
|
import { createDiscoveryContext } from "../utils/discovery-context.js";
|
|
4
4
|
import { runCommand } from "../utils/errors.js";
|
|
5
5
|
import { formatCount, showDetail, showStep } from "../utils/formatting.js";
|
|
6
|
+
import { createMachineEnvelope, writeMachineError } from "../utils/machine-output.js";
|
|
6
7
|
import { getTiltfilePath, isTiltAvailable, runTilt } from "../utils/tilt.js";
|
|
7
8
|
export const statusCommand = new Command("status")
|
|
8
9
|
.description("Show status of resources and stacks")
|
|
10
|
+
.option("--json", "Output a versioned JSON status report", false)
|
|
9
11
|
.option("-v, --verbose", "Show detailed information", false)
|
|
10
12
|
.option("--stacks", "Show stack information (default)", true)
|
|
11
13
|
.option("--resources", "Show all discovered resources", false)
|
|
12
14
|
.option("--tilt", "Show tilt resource status", false)
|
|
13
15
|
.action(async (options) => {
|
|
14
|
-
|
|
16
|
+
const action = async () => {
|
|
15
17
|
let tiltAvailable = false;
|
|
16
18
|
try {
|
|
17
19
|
tiltAvailable = await isTiltAvailable();
|
|
@@ -20,13 +22,59 @@ export const statusCommand = new Command("status")
|
|
|
20
22
|
// Tilt not available (spawn error)
|
|
21
23
|
tiltAvailable = false;
|
|
22
24
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
if (!options.json) {
|
|
26
|
+
showStep("TDK Status\n");
|
|
27
|
+
console.log(chalk.bold("Tilt:"), tiltAvailable ? chalk.green("available") : chalk.red("not found"));
|
|
28
|
+
if (!tiltAvailable) {
|
|
29
|
+
showDetail("Install Tilt: https://docs.tilt.dev/install.html");
|
|
30
|
+
}
|
|
31
|
+
console.log();
|
|
27
32
|
}
|
|
28
|
-
console.log();
|
|
29
33
|
const discovery = createDiscoveryContext();
|
|
34
|
+
let tiltResources = null;
|
|
35
|
+
let queryError = null;
|
|
36
|
+
if (options.json && options.tilt && tiltAvailable) {
|
|
37
|
+
const result = await runTilt("get", ["-f", getTiltfilePath(), "resources", "--output=json"], {
|
|
38
|
+
inheritStdio: false,
|
|
39
|
+
});
|
|
40
|
+
if (result.exitCode === 0) {
|
|
41
|
+
try {
|
|
42
|
+
tiltResources = JSON.parse(result.stdout);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
queryError = `Tilt returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
queryError = result.stderr || "Could not retrieve Tilt resource status";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (options.json) {
|
|
53
|
+
const errors = queryError ? [{ code: "TILT_STATUS_UNAVAILABLE", message: queryError }] : [];
|
|
54
|
+
const data = {
|
|
55
|
+
tilt: {
|
|
56
|
+
available: tiltAvailable,
|
|
57
|
+
resourcesQueried: options.tilt && tiltAvailable,
|
|
58
|
+
resources: tiltResources,
|
|
59
|
+
},
|
|
60
|
+
resources: discovery.resources.map((resource) => ({
|
|
61
|
+
name: resource.name,
|
|
62
|
+
stack: resource.stack ?? null,
|
|
63
|
+
type: resource.type ?? "unknown",
|
|
64
|
+
port: resource.port ?? null,
|
|
65
|
+
})),
|
|
66
|
+
stacks: discovery.stacks.map((stack) => ({
|
|
67
|
+
name: stack.name,
|
|
68
|
+
resourceCount: stack.resourceCount,
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
console.log(JSON.stringify(createMachineEnvelope(data, errors)));
|
|
72
|
+
if (errors.length > 0) {
|
|
73
|
+
console.error(queryError);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
30
78
|
console.log(chalk.bold("Resources:"), `${discovery.resources.length} discovered`);
|
|
31
79
|
console.log(chalk.bold("Stacks:"), `${discovery.stacks.length} defined`);
|
|
32
80
|
if (discovery.stacks.length > 0) {
|
|
@@ -66,6 +114,16 @@ export const statusCommand = new Command("status")
|
|
|
66
114
|
console.log();
|
|
67
115
|
showDetail('Run "tdk list-stacks" to see all stacks.');
|
|
68
116
|
showDetail('Run "tdk up <stack-name>" to start a stack.');
|
|
69
|
-
}
|
|
117
|
+
};
|
|
118
|
+
if (options.json) {
|
|
119
|
+
try {
|
|
120
|
+
await action();
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
writeMachineError(error);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await runCommand(action);
|
|
70
128
|
});
|
|
71
129
|
//# sourceMappingURL=status.js.map
|
package/cli/dist/commands/up.js
CHANGED
|
@@ -3,9 +3,10 @@ import chalk from "chalk";
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { ensureProjectRuntimeAssets } from "../generator/template-engine.js";
|
|
5
5
|
import { handleDryRun } from "../utils/command-helpers.js";
|
|
6
|
-
import { checkHostPorts } from "../utils/doctor-runtime.js";
|
|
7
6
|
import { errorFactories, handleTiltFailure, requireProjectRoot, runCommand, showErrorAndExit, withTiltCheck, } from "../utils/errors.js";
|
|
8
7
|
import { formatCount } from "../utils/formatting.js";
|
|
8
|
+
import { exportHostPortPlan, getHostPortPlan, writeSavedHostPortPlan, } from "../utils/host-port-config.js";
|
|
9
|
+
import { formatHostPortPlan } from "../utils/host-port-plan.js";
|
|
9
10
|
import { findProjectRoot } from "../utils/paths.js";
|
|
10
11
|
import { findAvailablePort } from "../utils/port-assignment.js";
|
|
11
12
|
import { appendHealthPath, resolveSubdomainBases } from "../utils/service-urls.js";
|
|
@@ -71,6 +72,9 @@ export const upCommand = new Command("up")
|
|
|
71
72
|
const projectRoot = options.dryRun
|
|
72
73
|
? requireProjectRoot()
|
|
73
74
|
: (findProjectRoot() ?? process.cwd());
|
|
75
|
+
const hostPortPlan = await getHostPortPlan(projectRoot, {
|
|
76
|
+
inspectDocker: !options.dryRun,
|
|
77
|
+
});
|
|
74
78
|
if (!options.dryRun) {
|
|
75
79
|
const copiedAssets = ensureProjectRuntimeAssets(projectRoot);
|
|
76
80
|
if (copiedAssets.length > 0 && options.verbose && !options.quiet) {
|
|
@@ -106,7 +110,7 @@ export const upCommand = new Command("up")
|
|
|
106
110
|
serviceNames.forEach((name) => {
|
|
107
111
|
console.log(chalk.gray(` - ${name}`));
|
|
108
112
|
});
|
|
109
|
-
const { appBase, apiBase } = resolveSubdomainBases();
|
|
113
|
+
const { appBase, apiBase } = resolveSubdomainBases(hostPortPlan.ingressHttp);
|
|
110
114
|
const frontends = servicesToStart.filter((s) => s.config?.appType === "frontend");
|
|
111
115
|
const backends = servicesToStart.filter((s) => s.config?.appType === "backend");
|
|
112
116
|
if (frontends.length > 0) {
|
|
@@ -132,17 +136,15 @@ export const upCommand = new Command("up")
|
|
|
132
136
|
const dryRunCommand = stackName
|
|
133
137
|
? `tilt up -- --focus=${stackName} ${focusServiceNames.join(" ")}`
|
|
134
138
|
: "tilt up";
|
|
139
|
+
if (!options.quiet) {
|
|
140
|
+
console.log(chalk.blue(formatHostPortPlan(hostPortPlan)));
|
|
141
|
+
console.log(chalk.gray("Override with TDK_HTTP_PORT, TDK_HTTPS_PORT, or TDK_POSTGRES_PORT."));
|
|
142
|
+
}
|
|
135
143
|
if (handleDryRun(options, "not starting services", dryRunCommand)) {
|
|
136
144
|
return;
|
|
137
145
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const hostPorts = await checkHostPorts();
|
|
141
|
-
if (!hostPorts.didPass && !options.quiet) {
|
|
142
|
-
console.log(chalk.yellow(`\n⚠️ ${hostPorts.message}`));
|
|
143
|
-
if (hostPorts.fix)
|
|
144
|
-
console.log(chalk.gray(` ${hostPorts.fix}\n`));
|
|
145
|
-
}
|
|
146
|
+
exportHostPortPlan(hostPortPlan);
|
|
147
|
+
writeSavedHostPortPlan(projectRoot, hostPortPlan);
|
|
146
148
|
const basePort = 10350;
|
|
147
149
|
let port = basePort;
|
|
148
150
|
if (process.env.TILT_PORT) {
|
|
@@ -231,7 +231,7 @@ services:
|
|
|
231
231
|
POSTGRES_PASSWORD: \${DB_PASSWORD}
|
|
232
232
|
POSTGRES_DB: postgres
|
|
233
233
|
ports:
|
|
234
|
-
- "
|
|
234
|
+
- "\${TDK_POSTGRES_PORT:-15432}:5432"
|
|
235
235
|
volumes:
|
|
236
236
|
- ${name}_postgres_data:/var/lib/postgresql/data
|
|
237
237
|
networks:
|
|
@@ -494,7 +494,7 @@ function findSelfContainedEngineRoot() {
|
|
|
494
494
|
}
|
|
495
495
|
return null;
|
|
496
496
|
}
|
|
497
|
-
function findCliAssetRoot() {
|
|
497
|
+
export function findCliAssetRoot() {
|
|
498
498
|
let dir = import.meta.dirname || process.cwd();
|
|
499
499
|
for (let i = 0; i < 8; i++) {
|
|
500
500
|
if (fs.existsSync(path.join(dir, "shared-platform-engineering", "docker-templates"))) {
|
|
@@ -603,6 +603,16 @@ async function vendorTdkExtension(projectRoot) {
|
|
|
603
603
|
export function verifyMasterConfigs(projectRoot) {
|
|
604
604
|
const errors = [];
|
|
605
605
|
const warnings = [];
|
|
606
|
+
const diffs = [];
|
|
607
|
+
const addDrift = (displayPath, actual, expected) => {
|
|
608
|
+
if (actual === expected)
|
|
609
|
+
return;
|
|
610
|
+
errors.push(`${actual === undefined ? "Missing file" : "Out of sync"}: ${displayPath} (run 'tdk config regenerate')`);
|
|
611
|
+
diffs.push({
|
|
612
|
+
file: displayPath,
|
|
613
|
+
diff: createUnifiedDiff(displayPath, actual ?? "", expected),
|
|
614
|
+
});
|
|
615
|
+
};
|
|
606
616
|
const projectConfig = readProjectConfig(projectRoot);
|
|
607
617
|
const engine = new TemplateEngine();
|
|
608
618
|
const expectedFiles = engine.generateAll(projectConfig);
|
|
@@ -610,22 +620,10 @@ export function verifyMasterConfigs(projectRoot) {
|
|
|
610
620
|
for (const filename of ALL_GENERATED_FILES) {
|
|
611
621
|
const expectedContent = expectedFiles[filename];
|
|
612
622
|
const filePath = path.join(outputDir, filename);
|
|
613
|
-
|
|
614
|
-
errors.push(`Missing file: .tdk/.tdk-out/${filename}`);
|
|
615
|
-
continue;
|
|
616
|
-
}
|
|
617
|
-
const actualContent = fs.readFileSync(filePath, "utf-8");
|
|
618
|
-
if (actualContent !== expectedContent) {
|
|
619
|
-
errors.push(`Out of sync: .tdk/.tdk-out/${filename} (run 'tdk config regenerate')`);
|
|
620
|
-
}
|
|
623
|
+
addDrift(`.tdk/.tdk-out/${filename}`, fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : undefined, expectedContent);
|
|
621
624
|
}
|
|
622
625
|
const rootTiltIgnore = path.join(projectRoot, ".tiltignore");
|
|
623
|
-
|
|
624
|
-
errors.push("Missing file: .tiltignore");
|
|
625
|
-
}
|
|
626
|
-
else if (fs.readFileSync(rootTiltIgnore, "utf-8") !== expectedFiles[".tiltignore"]) {
|
|
627
|
-
errors.push("Out of sync: .tiltignore (run 'tdk config regenerate')");
|
|
628
|
-
}
|
|
626
|
+
addDrift(".tiltignore", fs.existsSync(rootTiltIgnore) ? fs.readFileSync(rootTiltIgnore, "utf-8") : undefined, expectedFiles[".tiltignore"]);
|
|
629
627
|
for (const manifestPath of discoverServiceManifestPaths(projectRoot)) {
|
|
630
628
|
const displayPath = path.relative(projectRoot, manifestPath).split(path.sep).join("/");
|
|
631
629
|
const validation = validateServiceManifestFile(manifestPath, displayPath);
|
|
@@ -633,7 +631,21 @@ export function verifyMasterConfigs(projectRoot) {
|
|
|
633
631
|
warnings.push(...validation.warnings);
|
|
634
632
|
errors.push(...verifyGeneratedResourceFiles(projectRoot, manifestPath));
|
|
635
633
|
}
|
|
636
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
634
|
+
return { valid: errors.length === 0, errors, warnings, diffs };
|
|
635
|
+
}
|
|
636
|
+
function createUnifiedDiff(file, actual, expected) {
|
|
637
|
+
const oldLines = actual.replace(/\n$/, "").split("\n");
|
|
638
|
+
const newLines = expected.replace(/\n$/, "").split("\n");
|
|
639
|
+
const lines = [
|
|
640
|
+
`--- a/${file}`,
|
|
641
|
+
`+++ b/${file}`,
|
|
642
|
+
`@@ -1,${oldLines.length} +1,${newLines.length} @@`,
|
|
643
|
+
];
|
|
644
|
+
for (const line of oldLines)
|
|
645
|
+
lines.push(`-${line}`);
|
|
646
|
+
for (const line of newLines)
|
|
647
|
+
lines.push(`+${line}`);
|
|
648
|
+
return lines.join("\n");
|
|
637
649
|
}
|
|
638
650
|
function verifyGeneratedResourceFiles(projectRoot, serviceJsonPath) {
|
|
639
651
|
const serviceDir = path.dirname(serviceJsonPath);
|
|
@@ -2,8 +2,8 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { checkDockerCompose, checkDockerRuntime, checkDockerVersions, checkTilt, } from "../commands/doctor.js";
|
|
5
|
-
import { checkHostPorts, checkIngressPorts } from "./doctor-runtime.js";
|
|
6
5
|
import { checkNatsBroker } from "./doctor-wiring.js";
|
|
6
|
+
import { getHostPortPlan } from "./host-port-config.js";
|
|
7
7
|
import { findProjectRoot } from "./paths.js";
|
|
8
8
|
import { discoverResourcesFromRoot } from "./services.js";
|
|
9
9
|
import { findOnPath } from "./which.js";
|
|
@@ -99,26 +99,20 @@ export async function runColdPreflight(opts = {}) {
|
|
|
99
99
|
message: bun ? "Bun 1.2+ is available on PATH" : "Bun 1.2+ not found on PATH",
|
|
100
100
|
fix: bun ? undefined : "curl -fsSL https://bun.sh/install | bash",
|
|
101
101
|
});
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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));
|
|
102
|
+
try {
|
|
103
|
+
const plan = await getHostPortPlan(projectRoot ?? cwd);
|
|
104
|
+
items.push({
|
|
105
|
+
id: "ports",
|
|
106
|
+
ok: true,
|
|
107
|
+
message: `HTTP ${plan.ingressHttp}, HTTPS ${plan.ingressHttps}, Postgres ${plan.postgres}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
117
111
|
items.push({
|
|
118
112
|
id: "ports",
|
|
119
113
|
ok: false,
|
|
120
|
-
message:
|
|
121
|
-
fix:
|
|
114
|
+
message: error instanceof Error ? error.message : String(error),
|
|
115
|
+
fix: "Set TDK_HTTP_PORT, TDK_HTTPS_PORT, or TDK_POSTGRES_PORT to available host ports.",
|
|
122
116
|
});
|
|
123
117
|
}
|
|
124
118
|
if (projectRoot) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Wait for concurrent probes to finish even if one throws, so they can release resources. */
|
|
2
|
+
export async function collectDoctorChecks(machineChecks, projectChecks) {
|
|
3
|
+
const checks = [];
|
|
4
|
+
const errors = [];
|
|
5
|
+
const append = (result) => {
|
|
6
|
+
if (result.status === "fulfilled")
|
|
7
|
+
checks.push(result.value);
|
|
8
|
+
else
|
|
9
|
+
errors.push({
|
|
10
|
+
code: "INTERNAL",
|
|
11
|
+
message: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
for (const result of await Promise.allSettled(machineChecks.map((check) => Promise.resolve().then(check)))) {
|
|
15
|
+
append(result);
|
|
16
|
+
}
|
|
17
|
+
for (const check of projectChecks) {
|
|
18
|
+
for (const result of await Promise.allSettled([Promise.resolve().then(check)]))
|
|
19
|
+
append(result);
|
|
20
|
+
}
|
|
21
|
+
return { checks, errors };
|
|
22
|
+
}
|
|
23
|
+
/** Produce the same readiness decision for human and machine consumers. */
|
|
24
|
+
export function createDoctorReport(checks, inProject, errors = [], portPlan) {
|
|
25
|
+
return {
|
|
26
|
+
schemaVersion: 1,
|
|
27
|
+
data: {
|
|
28
|
+
ready: errors.length === 0 &&
|
|
29
|
+
checks.every((check) => check.didPass || check.isSkipped || check.isWarning),
|
|
30
|
+
inProject,
|
|
31
|
+
checks,
|
|
32
|
+
...(portPlan !== undefined
|
|
33
|
+
? {
|
|
34
|
+
ports: {
|
|
35
|
+
http: {
|
|
36
|
+
requested: portPlan?.requested.ingressHttp ?? 80,
|
|
37
|
+
chosen: portPlan?.ingressHttp ?? null,
|
|
38
|
+
explicit: portPlan?.explicit.ingressHttp ?? Boolean(process.env.TDK_HTTP_PORT),
|
|
39
|
+
reason: portPlan?.reason.ingressHttp ?? "port planning failed",
|
|
40
|
+
},
|
|
41
|
+
https: {
|
|
42
|
+
requested: portPlan?.requested.ingressHttps ?? 443,
|
|
43
|
+
chosen: portPlan?.ingressHttps ?? null,
|
|
44
|
+
explicit: portPlan?.explicit.ingressHttps ?? Boolean(process.env.TDK_HTTPS_PORT),
|
|
45
|
+
reason: portPlan?.reason.ingressHttps ?? "port planning failed",
|
|
46
|
+
},
|
|
47
|
+
postgres: {
|
|
48
|
+
requested: portPlan?.requested.postgres ?? 5432,
|
|
49
|
+
chosen: portPlan?.postgres ?? null,
|
|
50
|
+
explicit: portPlan?.explicit.postgres ?? Boolean(process.env.TDK_POSTGRES_PORT),
|
|
51
|
+
reason: portPlan?.reason.postgres ?? "port planning failed",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
: {}),
|
|
56
|
+
},
|
|
57
|
+
errors,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** 0: ready (warnings permitted), 1: blocking findings, 2: usage/internal failure. */
|
|
61
|
+
export function getDoctorExitCode(report) {
|
|
62
|
+
return report.errors.length > 0 ? 2 : report.data.ready ? 0 : 1;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=doctor-report.js.map
|
|
@@ -305,7 +305,7 @@ export function checkPrivateNpmRegistry(exec = execSync, projectRoot = findProje
|
|
|
305
305
|
* This is the exact failure mode that leaves apps never scheduled while doctor
|
|
306
306
|
* previously reported "Environment ready".
|
|
307
307
|
*/
|
|
308
|
-
export function checkIngressPorts(exec = execSync, projectName = getProjectName()) {
|
|
308
|
+
export function checkIngressPorts(exec = execSync, projectName = getProjectName(), ports = INGRESS_PORTS) {
|
|
309
309
|
const projectPrefix = toComposeProjectPrefix(projectName);
|
|
310
310
|
let dockerPs = "";
|
|
311
311
|
try {
|
|
@@ -323,7 +323,7 @@ export function checkIngressPorts(exec = execSync, projectName = getProjectName(
|
|
|
323
323
|
message: "Docker not available - skipped ingress port check",
|
|
324
324
|
};
|
|
325
325
|
}
|
|
326
|
-
const holders = parsePublishedPortHolders(dockerPs,
|
|
326
|
+
const holders = parsePublishedPortHolders(dockerPs, ports);
|
|
327
327
|
const foreign = findForeignIngressHolders(holders, projectPrefix);
|
|
328
328
|
if (foreign.length === 0) {
|
|
329
329
|
const own = holders.filter((holder) => isOwnIngressContainer(holder.name, projectPrefix));
|
|
@@ -331,7 +331,7 @@ export function checkIngressPorts(exec = execSync, projectName = getProjectName(
|
|
|
331
331
|
return {
|
|
332
332
|
name: "Ingress Ports",
|
|
333
333
|
didPass: true,
|
|
334
|
-
message: `Ingress ports ${
|
|
334
|
+
message: `Ingress ports ${ports.join("/")} held by this project's Traefik (${own
|
|
335
335
|
.map((h) => h.name)
|
|
336
336
|
.join(", ")})`,
|
|
337
337
|
};
|
|
@@ -339,7 +339,7 @@ export function checkIngressPorts(exec = execSync, projectName = getProjectName(
|
|
|
339
339
|
return {
|
|
340
340
|
name: "Ingress Ports",
|
|
341
341
|
didPass: true,
|
|
342
|
-
message: `Ingress ports ${
|
|
342
|
+
message: `Ingress ports ${ports.join("/")} are free for Traefik`,
|
|
343
343
|
};
|
|
344
344
|
}
|
|
345
345
|
const details = foreign
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createHostPortPlan, isHostPortAvailable } from "./host-port-plan.js";
|
|
5
|
+
const CONFIG_PATH = join(".tdk", ".tdk-out", "host-ports.json");
|
|
6
|
+
export function isDockerPortOwnedByProject(dockerPs, projectPrefix, port) {
|
|
7
|
+
return dockerPs.split("\n").some((line) => {
|
|
8
|
+
const [name = "", ports = ""] = line.split("|");
|
|
9
|
+
const containerName = name.toLowerCase();
|
|
10
|
+
const prefix = projectPrefix.toLowerCase();
|
|
11
|
+
return ((containerName.startsWith(`${prefix}_`) || containerName.startsWith(`${prefix}-`)) &&
|
|
12
|
+
new RegExp(`:${port}->`).test(ports));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function readSavedHostPortPlan(projectRoot) {
|
|
16
|
+
const path = join(projectRoot, CONFIG_PATH);
|
|
17
|
+
if (!existsSync(path))
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
const value = JSON.parse(readFileSync(path, "utf-8"));
|
|
21
|
+
if (!value || typeof value !== "object")
|
|
22
|
+
return null;
|
|
23
|
+
const plan = value;
|
|
24
|
+
if (!Number.isInteger(plan.ingressHttp) ||
|
|
25
|
+
!Number.isInteger(plan.ingressHttps) ||
|
|
26
|
+
!Number.isInteger(plan.postgres) ||
|
|
27
|
+
!plan.requested ||
|
|
28
|
+
!plan.explicit ||
|
|
29
|
+
!plan.reason)
|
|
30
|
+
return null;
|
|
31
|
+
return plan;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function writeSavedHostPortPlan(projectRoot, plan) {
|
|
38
|
+
const path = join(projectRoot, CONFIG_PATH);
|
|
39
|
+
mkdirSync(join(projectRoot, ".tdk", ".tdk-out"), { recursive: true });
|
|
40
|
+
writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
export function exportHostPortPlan(plan) {
|
|
43
|
+
process.env.TDK_HTTP_PORT = String(plan.ingressHttp);
|
|
44
|
+
process.env.TDK_HTTPS_PORT = String(plan.ingressHttps);
|
|
45
|
+
process.env.TDK_POSTGRES_PORT = String(plan.postgres);
|
|
46
|
+
}
|
|
47
|
+
/** Reuse this project's last selection when its ports are still free or held by its own containers. */
|
|
48
|
+
export async function getHostPortPlan(projectRoot, options = {}) {
|
|
49
|
+
const saved = readSavedHostPortPlan(projectRoot);
|
|
50
|
+
const hasOverride = ["TDK_HTTP_PORT", "TDK_HTTPS_PORT", "TDK_POSTGRES_PORT"].some((key) => process.env[key]);
|
|
51
|
+
// Dry-run must not call Docker. A saved plan is the best representation of
|
|
52
|
+
// the current landscape in that mode; explicit overrides are still planned.
|
|
53
|
+
if (options.inspectDocker === false && saved && !hasOverride)
|
|
54
|
+
return saved;
|
|
55
|
+
let prefix = "";
|
|
56
|
+
try {
|
|
57
|
+
const project = JSON.parse(readFileSync(join(projectRoot, ".tdk", "project.json"), "utf-8"));
|
|
58
|
+
prefix = String(project?.project?.name ?? "tdk-project")
|
|
59
|
+
.replace(/-/g, "_")
|
|
60
|
+
.toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
prefix = "tdk_project";
|
|
64
|
+
}
|
|
65
|
+
let dockerPs = "";
|
|
66
|
+
if (options.inspectDocker !== false) {
|
|
67
|
+
try {
|
|
68
|
+
dockerPs = execFileSync("docker", ["ps", "--format", "{{.Names}}|{{.Ports}}"], {
|
|
69
|
+
encoding: "utf-8",
|
|
70
|
+
timeout: 5000,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// If Docker is down, reuse only when host binds are still available.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const ownedByProject = (port) => isDockerPortOwnedByProject(dockerPs, prefix, port);
|
|
78
|
+
const isAvailable = async (port) => (await isHostPortAvailable(port)) || ownedByProject(port);
|
|
79
|
+
if (saved && !hasOverride) {
|
|
80
|
+
for (const port of [saved.ingressHttp, saved.ingressHttps, saved.postgres]) {
|
|
81
|
+
if (!(await isAvailable(port)))
|
|
82
|
+
return createHostPortPlan({ isAvailable });
|
|
83
|
+
}
|
|
84
|
+
return saved;
|
|
85
|
+
}
|
|
86
|
+
return createHostPortPlan({ isAvailable });
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=host-port-config.js.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
export const DEFAULT_HOST_PORT_RANGES = {
|
|
3
|
+
ingressHttp: { start: 8080, end: 8180 },
|
|
4
|
+
ingressHttps: { start: 8443, end: 8543 },
|
|
5
|
+
postgres: { start: 15432, end: 15532 },
|
|
6
|
+
};
|
|
7
|
+
const ENV_KEYS = {
|
|
8
|
+
ingressHttp: "TDK_HTTP_PORT",
|
|
9
|
+
ingressHttps: "TDK_HTTPS_PORT",
|
|
10
|
+
postgres: "TDK_POSTGRES_PORT",
|
|
11
|
+
};
|
|
12
|
+
const REQUESTED_PORTS = { ingressHttp: 80, ingressHttps: 443, postgres: 5432 };
|
|
13
|
+
export async function isHostPortAvailable(port) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const server = createServer();
|
|
16
|
+
server.once("error", () => resolve(false));
|
|
17
|
+
// Docker Desktop and Windows publish these host ports through IPv4 loopback.
|
|
18
|
+
// Match that target instead of probing wildcard binds that may differ by OS.
|
|
19
|
+
server.listen(port, "127.0.0.1", () => server.close(() => resolve(true)));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function parsePort(name, value) {
|
|
23
|
+
const port = Number(value);
|
|
24
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
25
|
+
throw new Error(`${name} must be an integer between 1 and 65535 (received ${JSON.stringify(value)})`);
|
|
26
|
+
}
|
|
27
|
+
return port;
|
|
28
|
+
}
|
|
29
|
+
export async function createHostPortPlan(options = {}) {
|
|
30
|
+
const env = options.env ?? process.env;
|
|
31
|
+
const isAvailable = options.isAvailable ?? isHostPortAvailable;
|
|
32
|
+
const explicitFlags = { ingressHttp: false, ingressHttps: false, postgres: false };
|
|
33
|
+
const reasons = { ingressHttp: "", ingressHttps: "", postgres: "" };
|
|
34
|
+
const selected = { explicit: explicitFlags };
|
|
35
|
+
const reserved = new Set();
|
|
36
|
+
for (const key of ["ingressHttp", "ingressHttps", "postgres"]) {
|
|
37
|
+
const envValue = env[ENV_KEYS[key]];
|
|
38
|
+
const explicit = envValue !== undefined && envValue.trim() !== "";
|
|
39
|
+
const range = options.ranges?.[key] ?? DEFAULT_HOST_PORT_RANGES[key];
|
|
40
|
+
const candidates = explicit
|
|
41
|
+
? [parsePort(ENV_KEYS[key], envValue)]
|
|
42
|
+
: Array.from({ length: range.end - range.start + 1 }, (_, index) => range.start + index);
|
|
43
|
+
let chosen;
|
|
44
|
+
for (const port of candidates) {
|
|
45
|
+
if (reserved.has(port))
|
|
46
|
+
continue;
|
|
47
|
+
if (await isAvailable(port)) {
|
|
48
|
+
chosen = port;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
if (explicit) {
|
|
52
|
+
throw new Error(`${ENV_KEYS[key]}=${port} is already in use. Choose a free host port and retry.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (chosen === undefined) {
|
|
56
|
+
throw new Error(`No available host port in ${range.start}-${range.end} for ${key}. Set ${ENV_KEYS[key]} to a free port and retry.`);
|
|
57
|
+
}
|
|
58
|
+
reserved.add(chosen);
|
|
59
|
+
selected[key] = chosen;
|
|
60
|
+
explicitFlags[key] = explicit;
|
|
61
|
+
reasons[key] = explicit
|
|
62
|
+
? "explicit override"
|
|
63
|
+
: `selected from fallback range ${range.start}-${range.end}`;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
...selected,
|
|
67
|
+
requested: { ...REQUESTED_PORTS },
|
|
68
|
+
reason: reasons,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function formatHostPortPlan(plan) {
|
|
72
|
+
return `Host ports: HTTP ${plan.ingressHttp}, HTTPS ${plan.ingressHttps}, Postgres ${plan.postgres}`;
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=host-port-plan.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { TdkError } from "./errors.js";
|
|
2
|
+
export function createMachineEnvelope(data, errors = []) {
|
|
3
|
+
return { schemaVersion: 1, data, errors };
|
|
4
|
+
}
|
|
5
|
+
export function toMachineError(error) {
|
|
6
|
+
if (error instanceof TdkError) {
|
|
7
|
+
return { error: { code: "COMMAND_FAILED", message: error.message }, exitCode: 1 };
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
error: {
|
|
11
|
+
code: "INTERNAL",
|
|
12
|
+
message: error instanceof Error ? error.message : String(error),
|
|
13
|
+
},
|
|
14
|
+
exitCode: 2,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Keep machine stdout parseable when a command fails before it has data to return. */
|
|
18
|
+
export function writeMachineError(error) {
|
|
19
|
+
const result = toMachineError(error);
|
|
20
|
+
console.log(JSON.stringify(createMachineEnvelope(null, [result.error])));
|
|
21
|
+
console.error(result.error.message);
|
|
22
|
+
process.exit(result.exitCode);
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=machine-output.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
export const SERVICE_MANIFEST_SCHEMA_VERSION = 1;
|
|
3
|
+
export const SERVICE_MANIFEST_SCHEMA_URL = "https://tdk-landscape.github.io/schema.service.json";
|
|
3
4
|
const KNOWN_SERVICE_FIELDS = new Set([
|
|
4
5
|
"$schema",
|
|
5
6
|
"apiBasePath",
|