@tdk-landscape/tdk-cli-core 1.3.124 → 1.3.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +289 -18
  2. package/README.md +20 -1
  3. package/cli/dist/commands/doctor.js +3 -127
  4. package/cli/dist/commands/down.js +8 -1
  5. package/cli/dist/commands/eject.js +2 -2
  6. package/cli/dist/commands/logs.js +16 -10
  7. package/cli/dist/commands/networks.js +10 -10
  8. package/cli/dist/commands/resource.js +7 -126
  9. package/cli/dist/commands/ui.js +35 -14
  10. package/cli/dist/commands/upgrade.js +39 -52
  11. package/cli/dist/components/TabBar.js +2 -7
  12. package/cli/dist/generator/resource-templates.js +127 -0
  13. package/cli/dist/generator/template-engine.js +7 -1
  14. package/cli/dist/utils/doctor-runtime.js +6 -3
  15. package/cli/dist/utils/doctor-starlark.js +131 -0
  16. package/cli/dist/utils/env-validator.js +61 -19
  17. package/cli/dist/utils/service-manifest.js +145 -0
  18. package/cli/dist/utils/service-runtime-state.js +2 -2
  19. package/cli/dist/utils/services.js +8 -1
  20. package/cli/dist/utils/startup-report.js +10 -6
  21. package/cli/dist/utils/tilt-resource-state.js +19 -0
  22. package/cli/dist/utils/tilt.js +2 -1
  23. package/cli/dist/utils/ui-keymap.js +118 -0
  24. package/cli/package.json +1 -1
  25. package/discovery/manifest/loading.star +5 -0
  26. package/discovery/manifest/normalize.star +6 -0
  27. package/engine/topologies/tilt/common/utils.star +3 -3
  28. package/engine/topologies/tilt/common/utils_debug.star +1 -1
  29. package/engine/topologies/tilt/generators/typescript/frontend_tsconfig.star +4 -6
  30. package/engine/topologies/tilt/manifest/loader.star +7 -0
  31. package/engine/topologies/tilt/manifest/parser.star +86 -3
  32. package/engine/topologies/tilt/manifest/tests/test_loader_parser.star +63 -1
  33. package/engine/topologies/tilt/resources/infra-loader.star +12 -11
  34. package/engine/topologies/tilt/resources/orchestrator/apply.star +2 -1
  35. package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +19 -18
  36. package/package.json +1 -1
@@ -8,7 +8,7 @@ import { findProjectRoot } from "./paths.js";
8
8
  import { isApiServiceType } from "./resource-kind.js";
9
9
  import { getProjectName } from "./service-urls.js";
10
10
  import { discoverResources, discoverResourcesFromRoot } from "./services.js";
11
- import { isTiltResourcePending } from "./tilt-resource-state.js";
11
+ import { effectiveRuntimeStatus, isTiltResourcePending, UNHEALTHY_CONTAINER_MESSAGE, } from "./tilt-resource-state.js";
12
12
  import { findOnPath } from "./which.js";
13
13
  const EXEC_TIMEOUT_MS = 10_000;
14
14
  const REGISTRY_PROBE_TIMEOUT_MS = 3_000;
@@ -104,8 +104,11 @@ export function parseTiltResourceFailures(jsonText, deferredNames = new Set()) {
104
104
  for (const item of items) {
105
105
  const name = item.metadata?.name ?? "unknown";
106
106
  const updateStatus = item.status?.updateStatus ?? "";
107
- const runtimeStatus = item.status?.runtimeStatus ?? "";
108
- const error = (item.status?.buildHistory?.[0]?.error ?? "").trim();
107
+ const runtimeStatus = effectiveRuntimeStatus(item.status);
108
+ const unhealthy = runtimeStatus === "error" && item.status?.runtimeStatus === "ok";
109
+ // A failed build's own error is the cause to show; the health message only fills in when there is none.
110
+ const buildError = (item.status?.buildHistory?.[0]?.error ?? "").trim();
111
+ const error = buildError || (unhealthy ? UNHEALTHY_CONTAINER_MESSAGE : "");
109
112
  if (updateStatus === "error" || runtimeStatus === "error") {
110
113
  failures.push({
111
114
  name,
@@ -0,0 +1,131 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, normalize } from "node:path";
3
+ import { formatCount } from "./formatting.js";
4
+ import { findProjectRoot } from "./paths.js";
5
+ export function findPrivateStarlarkLoadExports(content) {
6
+ const privateExports = new Set();
7
+ const loadCallPattern = /^load\s*\(([\s\S]*?)\)/gm;
8
+ for (const loadMatch of content.matchAll(loadCallPattern)) {
9
+ const args = loadMatch[1] ?? "";
10
+ const quotedValuePattern = /["']([^"']+)["']/g;
11
+ const quotedValues = Array.from(args.matchAll(quotedValuePattern), (match) => match[1]);
12
+ // First quoted arg is the module path. Later quoted args are exported names
13
+ // or alias targets, and Starlark refuses to export names beginning with "_".
14
+ for (const value of quotedValues.slice(1)) {
15
+ if (value.startsWith("_")) {
16
+ privateExports.add(value);
17
+ }
18
+ }
19
+ }
20
+ return Array.from(privateExports).sort();
21
+ }
22
+ export function resolveStarlarkLoadTarget(file, modulePath, projectRoot) {
23
+ if (modulePath === "ext://tdk-cli") {
24
+ return join(projectRoot, ".tdk", ".tdk-out", "tdk-cli-ext", "Tiltfile");
25
+ }
26
+ if (modulePath.startsWith(".") || (!modulePath.includes("://") && !modulePath.startsWith("@"))) {
27
+ return normalize(join(dirname(file), modulePath));
28
+ }
29
+ return null;
30
+ }
31
+ export function findMissingRelativeStarlarkLoads(file, content, projectRoot) {
32
+ const missing = [];
33
+ const loadCallPattern = /^load\s*\(([\s\S]*?)\)/gm;
34
+ for (const loadMatch of content.matchAll(loadCallPattern)) {
35
+ const args = loadMatch[1] ?? "";
36
+ const moduleMatch = args.match(/["']([^"']+)["']/);
37
+ const modulePath = moduleMatch?.[1];
38
+ if (!modulePath) {
39
+ continue;
40
+ }
41
+ const resolved = resolveStarlarkLoadTarget(file, modulePath, projectRoot);
42
+ if (!resolved) {
43
+ continue;
44
+ }
45
+ if (!existsSync(resolved)) {
46
+ missing.push(`${file}: ${modulePath} -> ${resolved}`);
47
+ }
48
+ }
49
+ return missing;
50
+ }
51
+ export function findRelativeStarlarkLoadTargets(file, content, projectRoot) {
52
+ const targets = [];
53
+ const loadCallPattern = /^load\s*\(([\s\S]*?)\)/gm;
54
+ for (const loadMatch of content.matchAll(loadCallPattern)) {
55
+ const args = loadMatch[1] ?? "";
56
+ const moduleMatch = args.match(/["']([^"']+)["']/);
57
+ const modulePath = moduleMatch?.[1];
58
+ if (modulePath) {
59
+ const target = resolveStarlarkLoadTarget(file, modulePath, projectRoot);
60
+ if (target) {
61
+ targets.push(target);
62
+ }
63
+ }
64
+ }
65
+ return targets;
66
+ }
67
+ export function collectReachableStarlarkFiles(entryFile, projectRoot) {
68
+ const visited = new Set();
69
+ const pending = [entryFile];
70
+ while (pending.length > 0) {
71
+ const file = pending.pop();
72
+ if (!file || visited.has(file) || !existsSync(file)) {
73
+ continue;
74
+ }
75
+ visited.add(file);
76
+ const content = readFileSync(file, "utf-8");
77
+ for (const target of findRelativeStarlarkLoadTargets(file, content, projectRoot)) {
78
+ if (!visited.has(target)) {
79
+ pending.push(target);
80
+ }
81
+ }
82
+ }
83
+ return Array.from(visited).sort();
84
+ }
85
+ export function checkStarlarkLoadExports() {
86
+ const projectRoot = findProjectRoot() ?? process.cwd();
87
+ const entryFile = join(projectRoot, ".tdk", ".tdk-out", "Tiltfile");
88
+ if (!existsSync(entryFile)) {
89
+ return {
90
+ name: "Starlark Load Exports",
91
+ didPass: true,
92
+ isSkipped: true,
93
+ message: "No generated Tiltfile to check",
94
+ };
95
+ }
96
+ const privateExportProblems = [];
97
+ const missingLoadProblems = [];
98
+ const reachableFiles = collectReachableStarlarkFiles(entryFile, projectRoot);
99
+ for (const file of reachableFiles) {
100
+ const content = readFileSync(file, "utf-8");
101
+ const privateExports = findPrivateStarlarkLoadExports(content);
102
+ if (privateExports.length > 0) {
103
+ privateExportProblems.push(`${file}: ${privateExports.join(", ")}`);
104
+ }
105
+ for (const missingLoad of findMissingRelativeStarlarkLoads(file, content, projectRoot)) {
106
+ missingLoadProblems.push(missingLoad);
107
+ }
108
+ }
109
+ if (privateExportProblems.length === 0 && missingLoadProblems.length === 0) {
110
+ const fileCount = formatCount(reachableFiles.length, "reachable Starlark file");
111
+ return {
112
+ name: "Starlark Load Exports",
113
+ didPass: true,
114
+ message: `${fileCount} ${reachableFiles.length === 1 ? "has" : "have"} valid load exports and paths`,
115
+ };
116
+ }
117
+ const sections = [];
118
+ if (privateExportProblems.length > 0) {
119
+ sections.push(`Private exported symbols:\n ${privateExportProblems.join("\n ")}`);
120
+ }
121
+ if (missingLoadProblems.length > 0) {
122
+ sections.push(`Missing relative load targets:\n ${missingLoadProblems.join("\n ")}`);
123
+ }
124
+ return {
125
+ name: "Starlark Load Exports",
126
+ didPass: false,
127
+ message: `Starlark load() issues will stop Tilt before services start:\n ${sections.join("\n\n ")}`,
128
+ fix: "Regenerate with a current TDK (`tdk config regenerate`). If the issue remains, rename loaded symbols without leading underscores and update stale load paths.",
129
+ };
130
+ }
131
+ //# sourceMappingURL=doctor-starlark.js.map
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { writeTextFileAtomic } from "./atomic-write.js";
4
5
  const REQUIRED_ENV_VARS = [
5
6
  {
6
7
  name: "VERDACCIO_URL_DOCKER",
@@ -180,10 +181,26 @@ export function validateEnvFile(projectRoot) {
180
181
  return result;
181
182
  }
182
183
  /**
183
- * Appends the keys a newer CLI expects to an existing .env, without touching or rotating anything already there (an existing
184
- * DB_PASSWORD must survive, or the database volume stops accepting it). Creates the file when it is missing.
184
+ * Replaces the value in one parsed assignment while preserving its prefix, inline comment, and line ending.
185
+ * The caller only uses this for assignments whose parsed value is empty or whitespace.
186
+ */
187
+ function replaceEmptyEnvValue(line, name, value) {
188
+ const parsed = parseEnv(line);
189
+ if (!parsed.has(name))
190
+ return line;
191
+ const equalsIndex = line.indexOf("=");
192
+ if (equalsIndex < 0)
193
+ return line;
194
+ const rawValue = line.slice(equalsIndex + 1);
195
+ const commentIndex = rawValue.search(/\s#/);
196
+ const comment = commentIndex < 0 ? "" : rawValue.slice(commentIndex);
197
+ return line.slice(0, equalsIndex + 1) + value + comment;
198
+ }
199
+ /**
200
+ * Appends missing keys a newer CLI expects and fills empty generated keys in place.
201
+ * Non-empty values are not changed. Creates the file when it is missing.
185
202
  *
186
- * @returns the names that were added, empty when the file was already complete
203
+ * @returns the names that were added or filled in, empty when the file was already complete
187
204
  */
188
205
  export function completeEnvFile(projectRoot) {
189
206
  const envPath = join(projectRoot, ".env");
@@ -193,24 +210,49 @@ export function completeEnvFile(projectRoot) {
193
210
  }
194
211
  const existing = readFileSync(envPath, "utf-8");
195
212
  const env = parseEnv(existing);
196
- const toAdd = REQUIRED_ENV_VARS.filter((v) => v.complete && !env.has(v.name));
197
- if (toAdd.length === 0)
213
+ const toComplete = REQUIRED_ENV_VARS.filter((v) => {
214
+ if (!v.complete)
215
+ return false;
216
+ if (!env.has(v.name))
217
+ return true;
218
+ return !(env.get(v.name) ?? "").trim();
219
+ });
220
+ if (toComplete.length === 0)
198
221
  return [];
199
- // Anything generated from the password must use the project's existing one.
200
- const password = env.get("DB_PASSWORD") || generateDbPassword();
201
- const lines = [
202
- "",
203
- "# Added by TDK: keys this version expects. Existing values above were not changed.",
204
- "",
205
- ];
206
- for (const envVar of toAdd) {
207
- lines.push(...describe(envVar));
208
- lines.push(`${envVar.name}=${generatedValue(envVar.name, password)}`);
209
- lines.push("");
222
+ const toAdd = toComplete.filter((v) => !env.has(v.name));
223
+ const toRepair = toComplete.filter((v) => env.has(v.name));
224
+ // Anything generated from the password must use the project's existing non-empty value.
225
+ const existingPassword = env.get("DB_PASSWORD");
226
+ const password = existingPassword?.trim() ? existingPassword : generateDbPassword();
227
+ const lines = existing.split(/(\r\n|\n|\r)/);
228
+ for (const envVar of toRepair) {
229
+ let lineIndex = -1;
230
+ for (let index = 0; index < lines.length; index += 2) {
231
+ if (parseEnv(lines[index]).has(envVar.name))
232
+ lineIndex = index;
233
+ }
234
+ if (lineIndex < 0) {
235
+ throw new Error(`Could not locate empty .env assignment for ${envVar.name}`);
236
+ }
237
+ lines[lineIndex] = replaceEmptyEnvValue(lines[lineIndex], envVar.name, generatedValue(envVar.name, password));
238
+ }
239
+ let updated = lines.join("");
240
+ if (toAdd.length > 0) {
241
+ const addedLines = [
242
+ "",
243
+ "# Added by TDK: keys this version expects. Existing values above were not changed.",
244
+ "",
245
+ ];
246
+ for (const envVar of toAdd) {
247
+ addedLines.push(...describe(envVar));
248
+ addedLines.push(`${envVar.name}=${generatedValue(envVar.name, password)}`);
249
+ addedLines.push("");
250
+ }
251
+ const separator = updated.endsWith("\n") || updated.endsWith("\r") ? "" : "\n";
252
+ updated += separator + addedLines.join("\n");
210
253
  }
211
- const separator = existing.endsWith("\n") ? "" : "\n";
212
- writeFileSync(envPath, `${existing}${separator}${lines.join("\n")}`);
213
- return toAdd.map((v) => v.name);
254
+ writeTextFileAtomic(envPath, updated);
255
+ return toComplete.map((v) => v.name);
214
256
  }
215
257
  export function ensureEnvFile(projectRoot) {
216
258
  const envPath = join(projectRoot, ".env");
@@ -14,6 +14,7 @@ const DEPRECATED_FIELDS = {
14
14
  const KNOWN_SERVICE_FIELDS = new Set([
15
15
  "$schema",
16
16
  "apiBasePath",
17
+ "apiPath",
17
18
  "appName",
18
19
  "appType",
19
20
  "backendName",
@@ -49,6 +50,129 @@ const KNOWN_SERVICE_FIELDS = new Set([
49
50
  "traefik",
50
51
  "type",
51
52
  ]);
53
+ /**
54
+ * String fields written into the generated Docker Compose, Traefik and NATS config as they are. A newline or YAML syntax in one of
55
+ * them adds keys to the generated service (GHSA-phgf-pww4-7jxc), so each must match its pattern. Empty values are treated as unset.
56
+ * `tilt up` reads service.json without this CLI, so engine/topologies/tilt/manifest/parser.star enforces the same rules.
57
+ */
58
+ const GENERATED_STRING_FIELDS = [
59
+ { path: ["healthCheckPath"], pattern: /^\/[a-z0-9/-]*$/, expected: "a path such as /health" },
60
+ {
61
+ path: ["traefik", "host"],
62
+ pattern: /^[A-Za-z0-9.-]+$/,
63
+ expected: "a hostname such as shop.backend.myproject.local",
64
+ },
65
+ {
66
+ path: ["traefik", "pathPrefix"],
67
+ pattern: /^\/[a-z0-9/-]*$/,
68
+ expected: "a path such as /api/v1/shop-management",
69
+ },
70
+ {
71
+ path: ["traefik", "healthCheck"],
72
+ pattern: /^\/[a-z0-9/-]*$/,
73
+ expected: "a path such as /health",
74
+ },
75
+ {
76
+ path: ["nats", "queueGroup"],
77
+ pattern: /^[A-Za-z0-9_.-]+$/,
78
+ expected: "letters, digits, '_', '.' or '-'",
79
+ },
80
+ { path: ["databaseName"], pattern: /^[A-Za-z0-9_-]+$/, expected: "letters, digits, '_' or '-'" },
81
+ { path: ["stack"], pattern: /^[A-Za-z0-9_-]+$/, expected: "letters, digits, '_' or '-'" },
82
+ {
83
+ path: ["image"],
84
+ pattern: /^[A-Za-z0-9._/:@-]+$/,
85
+ expected: "an image reference such as ghcr.io/acme/app:1.0",
86
+ },
87
+ ];
88
+ /**
89
+ * Errors for generated-output fields whose values could change the generated YAML. The value itself is not echoed, because it may
90
+ * contain the newline that makes it dangerous.
91
+ */
92
+ export function validateGeneratedStringFields(manifest, displayPath) {
93
+ const errors = [];
94
+ const reportedFields = new Set();
95
+ for (const { path, pattern, expected } of GENERATED_STRING_FIELDS) {
96
+ let value = manifest;
97
+ for (const key of path) {
98
+ value =
99
+ value && typeof value === "object" && !Array.isArray(value)
100
+ ? value[key]
101
+ : undefined;
102
+ }
103
+ if (value === undefined || value === null || value === "")
104
+ continue;
105
+ const field = `${displayPath}.${path.join(".")}`;
106
+ if (typeof value !== "string" || !pattern.test(value))
107
+ reportedFields.add(field);
108
+ if (typeof value !== "string") {
109
+ errors.push(`${field}: expected a string`);
110
+ }
111
+ else if (!pattern.test(value)) {
112
+ errors.push(`${field}: must be ${expected} (no spaces, line breaks or YAML syntax)`);
113
+ }
114
+ }
115
+ for (const [field, message] of lineBreakErrors(manifest, displayPath)) {
116
+ if (!reportedFields.has(field))
117
+ errors.push(`${field}: ${message}`);
118
+ }
119
+ return errors;
120
+ }
121
+ /** Line breaks (including the Unicode ones YAML 1.1 honours) and other control characters. */
122
+ function hasLineBreakOrControl(text) {
123
+ for (let index = 0; index < text.length; index++) {
124
+ const code = text.charCodeAt(index);
125
+ if (code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029) {
126
+ return true;
127
+ }
128
+ }
129
+ return false;
130
+ }
131
+ /**
132
+ * Many more service.json values than GENERATED_STRING_FIELDS reach the generated YAML (port, appName, dockerfile, buildContext,
133
+ * basePath, envVars, ...). A key can only be added through a line break, so no string key or value may contain one. `smoke` is
134
+ * skipped: the CLI sends it over HTTP and it is never written into generated config.
135
+ */
136
+ function lineBreakErrors(manifest, displayPath) {
137
+ const errors = [];
138
+ const pending = Object.entries(manifest)
139
+ .filter(([key]) => key !== "smoke")
140
+ .map(([key, value]) => [value, `${displayPath}.${key}`]);
141
+ for (const key of Object.keys(manifest)) {
142
+ if (hasLineBreakOrControl(key))
143
+ errors.push([displayPath, "a key contains a line break"]);
144
+ }
145
+ while (pending.length > 0) {
146
+ const [value, field] = pending.pop();
147
+ if (typeof value === "string") {
148
+ if (hasLineBreakOrControl(value)) {
149
+ errors.push([field, "must not contain line breaks or control characters"]);
150
+ }
151
+ }
152
+ else if (Array.isArray(value)) {
153
+ for (const [index, item] of value.entries())
154
+ pending.push([item, `${field}[${index}]`]);
155
+ }
156
+ else if (value && typeof value === "object") {
157
+ for (const [key, item] of Object.entries(value)) {
158
+ if (hasLineBreakOrControl(key)) {
159
+ errors.push([field, "a key contains a line break"]);
160
+ }
161
+ else {
162
+ pending.push([item, `${field}.${key}`]);
163
+ }
164
+ }
165
+ }
166
+ }
167
+ return errors.sort(([a], [b]) => a.localeCompare(b));
168
+ }
169
+ const SERVICE_ROUTE_PATH_FIELDS = ["apiPath", "basePath"];
170
+ export function getInvalidServiceRoutePathFields(value) {
171
+ if (!value || typeof value !== "object" || Array.isArray(value))
172
+ return [];
173
+ const manifest = value;
174
+ return SERVICE_ROUTE_PATH_FIELDS.filter((field) => field in manifest && typeof manifest[field] !== "string");
175
+ }
52
176
  export function validateServiceManifest(value, displayPath) {
53
177
  if (!value || typeof value !== "object" || Array.isArray(value)) {
54
178
  return { errors: [`${displayPath}: expected a JSON object`], warnings: [] };
@@ -67,6 +191,9 @@ export function validateServiceManifest(value, displayPath) {
67
191
  if ("schemaVersion" in manifest && manifest.schemaVersion !== SERVICE_MANIFEST_SCHEMA_VERSION) {
68
192
  errors.push(`${displayPath}.schemaVersion: unsupported version ${String(manifest.schemaVersion)} (supported: ${SERVICE_MANIFEST_SCHEMA_VERSION})`);
69
193
  }
194
+ for (const field of getInvalidServiceRoutePathFields(manifest)) {
195
+ errors.push(`${displayPath}.${field}: expected a string`);
196
+ }
70
197
  const dev = manifest.dev;
71
198
  const liveReload = dev && typeof dev === "object" && !Array.isArray(dev)
72
199
  ? dev.liveReload
@@ -74,6 +201,7 @@ export function validateServiceManifest(value, displayPath) {
74
201
  if (liveReload !== undefined && typeof liveReload !== "boolean") {
75
202
  errors.push(`${displayPath}.dev.liveReload: expected true or false`);
76
203
  }
204
+ errors.push(...validateGeneratedStringFields(manifest, displayPath));
77
205
  if ("smoke" in manifest) {
78
206
  for (const message of validateSmoke(manifest.smoke))
79
207
  errors.push(`${displayPath}.${message}`);
@@ -91,6 +219,23 @@ export function validateServiceManifest(value, displayPath) {
91
219
  }
92
220
  return { errors, warnings, manifest };
93
221
  }
222
+ /**
223
+ * Generated-output errors for one service.json. A file that cannot be read or parsed returns none here, because
224
+ * validateServiceManifestFile reports that case.
225
+ */
226
+ export function generatedFieldErrorsForFile(filePath, displayPath) {
227
+ if (!existsSync(filePath))
228
+ return [];
229
+ try {
230
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
231
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
232
+ return [];
233
+ return validateGeneratedStringFields(parsed, displayPath);
234
+ }
235
+ catch {
236
+ return [];
237
+ }
238
+ }
94
239
  export function validateServiceManifestFile(filePath, displayPath) {
95
240
  if (!existsSync(filePath)) {
96
241
  return { errors: [`${displayPath}: file is missing`], warnings: [] };
@@ -1,5 +1,5 @@
1
1
  import { buildStartupReport, transitiveDependencies } from "./startup-report.js";
2
- import { isTiltResourcePending } from "./tilt-resource-state.js";
2
+ import { effectiveRuntimeStatus, isTiltResourcePending } from "./tilt-resource-state.js";
3
3
  import { onlyEnabledResources } from "./up-readiness.js";
4
4
  /**
5
5
  * The one answer to "is this service ready?" for `tdk status` and `tdk ui`, from Tilt's resources and the `dependsOn`
@@ -32,7 +32,7 @@ export function deriveServiceStates(jsonText, dependsOn, deferred = new Set()) {
32
32
  direct.set(name, { status: "pending" });
33
33
  }
34
34
  else if (deferred.has(name) &&
35
- isTiltResourcePending(item.status?.updateStatus ?? "", item.status?.runtimeStatus ?? "")) {
35
+ isTiltResourcePending(item.status?.updateStatus ?? "", effectiveRuntimeStatus(item.status))) {
36
36
  direct.set(name, { status: "unknown", reason: "starts on its first request" });
37
37
  }
38
38
  else {
@@ -5,7 +5,7 @@ import { AUTOGENERATED_FILE_PATTERNS, SKIP_DIRECTORIES } from "./constants.js";
5
5
  import { errorFactories } from "./errors.js";
6
6
  import { formatCount } from "./formatting.js";
7
7
  import { findProjectRoot } from "./paths.js";
8
- import { validateServiceManifest } from "./service-manifest.js";
8
+ import { getInvalidServiceRoutePathFields, validateGeneratedStringFields, validateServiceManifest, } from "./service-manifest.js";
9
9
  import { deriveStackStatus } from "./stack-status.js";
10
10
  import { includes } from "./validation.js";
11
11
  const RESOURCE_JSON_FILENAME = "service.json";
@@ -79,6 +79,13 @@ function parseResource(serviceJsonPath) {
79
79
  if (!isValidResourceConfig(parsed)) {
80
80
  throw new InvalidServiceJsonError("missing required fields (appName, runtime or appType)");
81
81
  }
82
+ const invalidRoutePathFields = getInvalidServiceRoutePathFields(parsed);
83
+ if (invalidRoutePathFields.length > 0) {
84
+ throw new InvalidServiceJsonError(invalidRoutePathFields.map((field) => `${field}: expected a string`).join("; "));
85
+ }
86
+ const unsafeFields = validateGeneratedStringFields(parsed, serviceJsonPath);
87
+ if (unsafeFields.length > 0)
88
+ throw new InvalidServiceJsonError(unsafeFields.join("; "));
82
89
  const parsedConfig = parsed;
83
90
  for (const warning of validateServiceManifest(parsed, serviceJsonPath).warnings) {
84
91
  // The warning names the file and field and says what is wrong (unknown, deprecated, ignored), so do not prefix it.
@@ -1,6 +1,6 @@
1
1
  import { parseTiltResourceFailures, summarizeTiltBuildError } from "./doctor-runtime.js";
2
2
  import { portConflictFix } from "./port-conflict-fix.js";
3
- import { isTiltResourcePending } from "./tilt-resource-state.js";
3
+ import { effectiveRuntimeStatus, isTiltResourcePending } from "./tilt-resource-state.js";
4
4
  import { onlyEnabledResources } from "./up-readiness.js";
5
5
  export function transitiveDependencies(name, dependsOn) {
6
6
  const seen = new Set();
@@ -16,7 +16,7 @@ export function transitiveDependencies(name, dependsOn) {
16
16
  return seen;
17
17
  }
18
18
  function isPending(item) {
19
- return isTiltResourcePending(item.status?.updateStatus ?? "", item.status?.runtimeStatus ?? "");
19
+ return isTiltResourcePending(item.status?.updateStatus ?? "", effectiveRuntimeStatus(item.status));
20
20
  }
21
21
  export function buildStartupReport(jsonText, dependsOn, deferred = new Set()) {
22
22
  const enabledJson = onlyEnabledResources(jsonText);
@@ -33,15 +33,14 @@ export function buildStartupReport(jsonText, dependsOn, deferred = new Set()) {
33
33
  const starting = [];
34
34
  for (const item of items) {
35
35
  const name = item.metadata?.name ?? "unknown";
36
- if (name === "(Tiltfile)" || failedNames.has(name) || deferred.has(name) || !isPending(item)) {
36
+ if (name === "(Tiltfile)" || failedNames.has(name) || deferred.has(name))
37
37
  continue;
38
- }
39
38
  const because = [...transitiveDependencies(name, dependsOn)]
40
39
  .filter((dep) => failedNames.has(dep))
41
40
  .sort();
42
41
  if (because.length > 0)
43
42
  blocked.push({ name, because });
44
- else
43
+ else if (isPending(item))
45
44
  starting.push(name);
46
45
  }
47
46
  return { failed, blocked, starting };
@@ -66,7 +65,12 @@ export function formatStartupReport(report, timedOut) {
66
65
  lines.push(`${timedOut ? "Timed out waiting for" : "Still starting:"} ${report.starting.join(", ")}`);
67
66
  }
68
67
  if (lines.length > 0) {
69
- lines.push("The environment is not ready. Tilt is still running; inspect it or run: tdk down");
68
+ const causes = [...new Set(report.blocked.flatMap((entry) => entry.because))].sort();
69
+ const count = report.blocked.length;
70
+ const summary = count > 0
71
+ ? `: ${causes.join(", ")} failed, so ${count} dependent ${count === 1 ? "service is" : "services are"} not ready`
72
+ : "";
73
+ lines.push(`The environment is not ready${summary}. Tilt is still running; inspect it or run: tdk down`);
70
74
  }
71
75
  return lines;
72
76
  }
@@ -9,4 +9,23 @@ export function isTiltResourcePending(updateStatus, runtimeStatus) {
9
9
  runtimeStatus === "pending" ||
10
10
  runtimeStatus === "none");
11
11
  }
12
+ /** What a Tilt failure says when the container runs but Docker reports its health check as failing. */
13
+ export const UNHEALTHY_CONTAINER_MESSAGE = "container is running, but its health check is failing";
14
+ /**
15
+ * Tilt's runtime status, corrected for Docker health. Tilt reports a running compose container as `ok` even while its
16
+ * health check is still `starting` or has turned `unhealthy` (Tilt 0.37), so a Postgres that never accepts connections
17
+ * would read as ready. Health `starting` is pending and `unhealthy` is an error; a container without a health check
18
+ * keeps Tilt's status.
19
+ */
20
+ export function effectiveRuntimeStatus(status) {
21
+ const runtimeStatus = status?.runtimeStatus ?? "";
22
+ if (runtimeStatus !== "ok")
23
+ return runtimeStatus;
24
+ const health = status?.composeResourceInfo?.healthStatus;
25
+ if (health === "unhealthy")
26
+ return "error";
27
+ if (health === "starting")
28
+ return "pending";
29
+ return runtimeStatus;
30
+ }
12
31
  //# sourceMappingURL=tilt-resource-state.js.map
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { join } from "node:path";
3
+ import { errorFactories } from "./errors.js";
3
4
  import { findProjectRoot } from "./paths.js";
4
5
  import { findOnPath } from "./which.js";
5
6
  export function runTilt(command, args = [], options = {}) {
@@ -49,7 +50,7 @@ export async function isTiltAvailable() {
49
50
  export function getTiltfilePath() {
50
51
  const projectRoot = findProjectRoot();
51
52
  if (!projectRoot) {
52
- throw new Error("Not in a TDK project (no .tdk/project.json found)");
53
+ throw errorFactories.notInProject();
53
54
  }
54
55
  return join(projectRoot, ".tdk", ".tdk-out", "Tiltfile");
55
56
  }