@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.
@@ -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
- await runCommand(async () => {
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
- showStep("TDK Status\n");
24
- console.log(chalk.bold("Tilt:"), tiltAvailable ? chalk.green("available") : chalk.red("not found"));
25
- if (!tiltAvailable) {
26
- showDetail("Install Tilt: https://docs.tilt.dev/install.html");
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
@@ -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
- // Warn rather than block: a stale local Postgres on 5432 makes Tilt fail
139
- // halfway through startup with a bind error that's hard to read.
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
- - "5432:5432"
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
- if (!fs.existsSync(filePath)) {
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
- if (!fs.existsSync(rootTiltIgnore)) {
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
- 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));
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: messages.join("\n"),
121
- fix: fixes.join(" "),
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, INGRESS_PORTS);
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 ${INGRESS_PORTS.join("/")} held by this project's Traefik (${own
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 ${INGRESS_PORTS.join("/")} are free for Traefik`,
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",