@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.
- package/CHANGELOG.md +289 -18
- package/README.md +20 -1
- package/cli/dist/commands/doctor.js +3 -127
- package/cli/dist/commands/down.js +8 -1
- package/cli/dist/commands/eject.js +2 -2
- package/cli/dist/commands/logs.js +16 -10
- package/cli/dist/commands/networks.js +10 -10
- package/cli/dist/commands/resource.js +7 -126
- package/cli/dist/commands/ui.js +35 -14
- package/cli/dist/commands/upgrade.js +39 -52
- package/cli/dist/components/TabBar.js +2 -7
- package/cli/dist/generator/resource-templates.js +127 -0
- package/cli/dist/generator/template-engine.js +7 -1
- package/cli/dist/utils/doctor-runtime.js +6 -3
- package/cli/dist/utils/doctor-starlark.js +131 -0
- package/cli/dist/utils/env-validator.js +61 -19
- package/cli/dist/utils/service-manifest.js +145 -0
- package/cli/dist/utils/service-runtime-state.js +2 -2
- package/cli/dist/utils/services.js +8 -1
- package/cli/dist/utils/startup-report.js +10 -6
- package/cli/dist/utils/tilt-resource-state.js +19 -0
- package/cli/dist/utils/tilt.js +2 -1
- package/cli/dist/utils/ui-keymap.js +118 -0
- package/cli/package.json +1 -1
- package/discovery/manifest/loading.star +5 -0
- package/discovery/manifest/normalize.star +6 -0
- package/engine/topologies/tilt/common/utils.star +3 -3
- package/engine/topologies/tilt/common/utils_debug.star +1 -1
- package/engine/topologies/tilt/generators/typescript/frontend_tsconfig.star +4 -6
- package/engine/topologies/tilt/manifest/loader.star +7 -0
- package/engine/topologies/tilt/manifest/parser.star +86 -3
- package/engine/topologies/tilt/manifest/tests/test_loader_parser.star +63 -1
- package/engine/topologies/tilt/resources/infra-loader.star +12 -11
- package/engine/topologies/tilt/resources/orchestrator/apply.star +2 -1
- package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +19 -18
- 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
|
|
108
|
-
const
|
|
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
|
-
*
|
|
184
|
-
*
|
|
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
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
lines.
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
package/cli/dist/utils/tilt.js
CHANGED
|
@@ -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
|
|
53
|
+
throw errorFactories.notInProject();
|
|
53
54
|
}
|
|
54
55
|
return join(projectRoot, ".tdk", ".tdk-out", "Tiltfile");
|
|
55
56
|
}
|