@tdk-landscape/tdk-cli-core 1.3.125 → 1.3.127
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 +9 -0
- package/README.md +3 -0
- package/cli/dist/generator/template-engine.js +7 -1
- package/cli/dist/utils/doctor-runtime.js +15 -5
- package/cli/dist/utils/service-manifest.js +134 -0
- package/cli/dist/utils/service-runtime-state.js +2 -2
- package/cli/dist/utils/services.js +4 -1
- package/cli/dist/utils/startup-report.js +10 -6
- package/cli/dist/utils/tilt-resource-state.js +19 -0
- package/cli/package.json +2 -1
- package/discovery/manifest/loading.star +5 -0
- package/discovery/manifest/normalize.star +6 -0
- 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/ext/ide-components/README.md +2 -0
- package/package.json +2 -1
- package/ext/ide-components/code_executor/server.py +0 -268
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ Notable changes to the `tdk` CLI. Versions match [npm](https://www.npmjs.com/pac
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 1.3.127 (2026-10-08)
|
|
8
|
+
|
|
9
|
+
- Security hardening flagged by Socket (#826). The npm package no longer includes `ext/ide-components/code_executor/server.py`, a browser terminal that ran client-supplied commands through a shell. `tdk` and the Tilt engine never started it. The other IDE components are unchanged. `tdk doctor` now single-quotes the registry URL and container name it passes to `curl` and `docker logs`, so a value such as `$(...)` can no longer run a command.
|
|
10
|
+
|
|
11
|
+
## 1.3.126 (2026-10-08)
|
|
12
|
+
|
|
13
|
+
- Security: fixes [GHSA-phgf-pww4-7jxc](https://github.com/tdk-landscape/tdk-cli-core/security/advisories/GHSA-phgf-pww4-7jxc) (all earlier versions). A line break in a `service.json` value was written into the generated Docker Compose file and could add keys such as `privileged: true` to a service when you ran `tdk up` or `tilt up` on a repository you did not write. `tdk` and the Tilt engine now refuse any `service.json` string key or value that contains a line break (including U+0085, U+2028 and U+2029) or another control character, and `healthCheckPath`, `traefik.host`, `traefik.pathPrefix`, `traefik.healthCheck`, `nats.queueGroup`, `databaseName`, `stack` and `image` must also match a pattern. `smoke` is exempt. `tdk up` and `tdk config regenerate` stop with an error that names the field and does not print the value, and `tilt up` stops the same way. Upgrade, and check `service.json` files from repositories you did not write.
|
|
14
|
+
- `tdk up`, `tdk status` and `tdk ui` now treat a container as ready only once its Docker health check passes. Tilt reports a running container as ready even while its health check is starting or failing, so a Postgres that never accepted connections counted as up and its dependents looked ready. A health check that is still starting now counts as starting (`waiting for postgres` on dependents), and a failing one makes `tdk up` stop with `✗ postgres failed: container is running, but its health check is failing`, the services it blocks, and how many dependents are not ready. Containers without a health check (BYO images) are unchanged. Part of #683.
|
|
15
|
+
|
|
7
16
|
## 1.3.125 (2026-10-08)
|
|
8
17
|
|
|
9
18
|
- `tdk upgrade` no longer falls back to installing from the repository's moving GitHub default branch when the npm or Bun registry install fails. It reports the original install error, exits non-zero and prints the published-package command (`npm install -g @tdk-landscape/tdk-cli-core@latest` or `bun install -g @tdk-landscape/tdk-cli-core@latest`). npm `EACCES`/`EPERM` failures also point at npm's global-prefix docs and the official installer. Fixes #702.
|
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
[](https://github.com/tdk-landscape/tdk-cli-core/actions/workflows/quickstart-e2e.yml)
|
|
8
8
|
[](https://github.com/tdk-landscape/tdk-cli-core/actions/workflows/erp-scale-e2e.yml)
|
|
9
9
|
[](https://github.com/tdk-landscape/tdk-cli-core/actions/workflows/examples-e2e.yml)
|
|
10
|
+
[](https://github.com/tdk-landscape/tdk-cli-core/security/code-scanning)
|
|
11
|
+
[](https://scorecard.dev/viewer/?uri=github.com/tdk-landscape/tdk-cli-core)
|
|
10
12
|
[](https://snyk.io/test/github/tdk-landscape/tdk-cli-core)
|
|
11
13
|
[](https://socket.dev/npm/package/@tdk-landscape/tdk-cli-core/overview)
|
|
12
14
|
[](LICENSE)
|
|
@@ -111,6 +113,7 @@ Set `TDK_DEBUG=1` before running `tdk up --verbose` to include Starlark debug lo
|
|
|
111
113
|
## Docs
|
|
112
114
|
|
|
113
115
|
- [CLI command cheat sheet](cli/README.md#cheat-sheet)
|
|
116
|
+
- [Mega cheat sheet: every command and flag](docs/cheat-sheet.md)
|
|
114
117
|
- [Documentation index](docs/README.md)
|
|
115
118
|
- [Pilot TDK on a real repository](docs/adopt-tdk.md)
|
|
116
119
|
- [FAQ for agencies and multi-client teams](docs/faq-teams.md)
|
|
@@ -7,7 +7,7 @@ import { writeTextFileAtomic } from "../utils/atomic-write.js";
|
|
|
7
7
|
import { writeTextFile } from "../utils/file-helpers.js";
|
|
8
8
|
import { assertTdkGeneratedPath } from "../utils/generated-paths.js";
|
|
9
9
|
import { DEFAULT_ALWAYS_ENABLED_INFRA } from "../utils/project-config-defaults.js";
|
|
10
|
-
import { validateServiceManifestFile } from "../utils/service-manifest.js";
|
|
10
|
+
import { generatedFieldErrorsForFile, validateServiceManifestFile, } from "../utils/service-manifest.js";
|
|
11
11
|
import { discoverResourcesFromRoot, discoverServiceManifestPaths } from "../utils/services.js";
|
|
12
12
|
import { isStackFeatureEnabledInStacks } from "../utils/stack-features.js";
|
|
13
13
|
import { hasSablierLicense, hasVerdaccioLicense } from "./extension-fetch.js";
|
|
@@ -412,6 +412,12 @@ function ensureRootWorkspaceManifest(projectRoot, projectConfig) {
|
|
|
412
412
|
}
|
|
413
413
|
export async function generateMasterConfigs(projectRoot, options = {}) {
|
|
414
414
|
const projectConfig = readProjectConfig(projectRoot);
|
|
415
|
+
// Refuse before writing anything: a service.json value with a line break or YAML syntax would add keys to the generated compose
|
|
416
|
+
// file (GHSA-phgf-pww4-7jxc).
|
|
417
|
+
const unsafeFields = discoverServiceManifestPaths(projectRoot).flatMap((manifestPath) => generatedFieldErrorsForFile(manifestPath, path.relative(projectRoot, manifestPath).split(path.sep).join("/")));
|
|
418
|
+
if (unsafeFields.length > 0) {
|
|
419
|
+
throw new Error(`Refusing to generate config from unsafe service.json values:\n${unsafeFields.map((error) => ` ${error}`).join("\n")}`);
|
|
420
|
+
}
|
|
415
421
|
await warnIfSablierUnlicensed(projectRoot);
|
|
416
422
|
if (ensureRootWorkspaceManifest(projectRoot, projectConfig)) {
|
|
417
423
|
console.log("✓ Generated: package.json (workspace root)");
|
|
@@ -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;
|
|
@@ -17,6 +17,13 @@ export const HOST_PORT_FIXES = {
|
|
|
17
17
|
port80: "Stop the process bound to port 80, or stop local nginx/caddy. Then: tdk doctor",
|
|
18
18
|
port5432: "Stop local Postgres or change the host port. Then: tdk doctor",
|
|
19
19
|
};
|
|
20
|
+
/**
|
|
21
|
+
* Quote a value for a POSIX shell command line. JSON.stringify is not enough:
|
|
22
|
+
* inside double quotes `$(...)`, backticks and `$VAR` are still expanded.
|
|
23
|
+
*/
|
|
24
|
+
export function shellQuote(value) {
|
|
25
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
26
|
+
}
|
|
20
27
|
function runHostCommand(exec, command, args, shellCommand, options) {
|
|
21
28
|
if (process.platform === "win32") {
|
|
22
29
|
return execFileSync(findOnPath(command) ?? command, args, {
|
|
@@ -104,8 +111,11 @@ export function parseTiltResourceFailures(jsonText, deferredNames = new Set()) {
|
|
|
104
111
|
for (const item of items) {
|
|
105
112
|
const name = item.metadata?.name ?? "unknown";
|
|
106
113
|
const updateStatus = item.status?.updateStatus ?? "";
|
|
107
|
-
const runtimeStatus = item.status
|
|
108
|
-
const
|
|
114
|
+
const runtimeStatus = effectiveRuntimeStatus(item.status);
|
|
115
|
+
const unhealthy = runtimeStatus === "error" && item.status?.runtimeStatus === "ok";
|
|
116
|
+
// A failed build's own error is the cause to show; the health message only fills in when there is none.
|
|
117
|
+
const buildError = (item.status?.buildHistory?.[0]?.error ?? "").trim();
|
|
118
|
+
const error = buildError || (unhealthy ? UNHEALTHY_CONTAINER_MESSAGE : "");
|
|
109
119
|
if (updateStatus === "error" || runtimeStatus === "error") {
|
|
110
120
|
failures.push({
|
|
111
121
|
name,
|
|
@@ -266,7 +276,7 @@ export function checkPrivateNpmRegistry(exec = execSync, projectRoot = findProje
|
|
|
266
276
|
timeout: REGISTRY_PROBE_TIMEOUT_MS + 1_000,
|
|
267
277
|
windowsHide: true,
|
|
268
278
|
}).trim()
|
|
269
|
-
: exec(`curl -fsS -o /dev/null -w '%{http_code}' --max-time 2 ${
|
|
279
|
+
: exec(`curl -fsS -o /dev/null -w '%{http_code}' --max-time 2 ${shellQuote(registryUrl)}`, {
|
|
270
280
|
stdio: "pipe",
|
|
271
281
|
encoding: "utf-8",
|
|
272
282
|
timeout: REGISTRY_PROBE_TIMEOUT_MS + 1_000,
|
|
@@ -645,7 +655,7 @@ export function probeContainerRuntimeError(resourceName, exec = execSync) {
|
|
|
645
655
|
names.find((name) => name.includes(resourceName));
|
|
646
656
|
if (!match)
|
|
647
657
|
return null;
|
|
648
|
-
const logs = runHostCommand(exec, "docker", ["logs", "--tail", "120", match], `docker logs --tail 120 ${
|
|
658
|
+
const logs = runHostCommand(exec, "docker", ["logs", "--tail", "120", match], `docker logs --tail 120 ${shellQuote(match)} 2>&1`, {
|
|
649
659
|
stdio: "pipe",
|
|
650
660
|
encoding: "utf-8",
|
|
651
661
|
timeout: EXEC_TIMEOUT_MS,
|
|
@@ -50,6 +50,122 @@ const KNOWN_SERVICE_FIELDS = new Set([
|
|
|
50
50
|
"traefik",
|
|
51
51
|
"type",
|
|
52
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
|
+
}
|
|
53
169
|
const SERVICE_ROUTE_PATH_FIELDS = ["apiPath", "basePath"];
|
|
54
170
|
export function getInvalidServiceRoutePathFields(value) {
|
|
55
171
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
@@ -85,6 +201,7 @@ export function validateServiceManifest(value, displayPath) {
|
|
|
85
201
|
if (liveReload !== undefined && typeof liveReload !== "boolean") {
|
|
86
202
|
errors.push(`${displayPath}.dev.liveReload: expected true or false`);
|
|
87
203
|
}
|
|
204
|
+
errors.push(...validateGeneratedStringFields(manifest, displayPath));
|
|
88
205
|
if ("smoke" in manifest) {
|
|
89
206
|
for (const message of validateSmoke(manifest.smoke))
|
|
90
207
|
errors.push(`${displayPath}.${message}`);
|
|
@@ -102,6 +219,23 @@ export function validateServiceManifest(value, displayPath) {
|
|
|
102
219
|
}
|
|
103
220
|
return { errors, warnings, manifest };
|
|
104
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
|
+
}
|
|
105
239
|
export function validateServiceManifestFile(filePath, displayPath) {
|
|
106
240
|
if (!existsSync(filePath)) {
|
|
107
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 { getInvalidServiceRoutePathFields, 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";
|
|
@@ -83,6 +83,9 @@ function parseResource(serviceJsonPath) {
|
|
|
83
83
|
if (invalidRoutePathFields.length > 0) {
|
|
84
84
|
throw new InvalidServiceJsonError(invalidRoutePathFields.map((field) => `${field}: expected a string`).join("; "));
|
|
85
85
|
}
|
|
86
|
+
const unsafeFields = validateGeneratedStringFields(parsed, serviceJsonPath);
|
|
87
|
+
if (unsafeFields.length > 0)
|
|
88
|
+
throw new InvalidServiceJsonError(unsafeFields.join("; "));
|
|
86
89
|
const parsedConfig = parsed;
|
|
87
90
|
for (const warning of validateServiceManifest(parsed, serviceJsonPath).warnings) {
|
|
88
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tdk-cli-internal",
|
|
3
3
|
"private": true,
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.127",
|
|
5
5
|
"description": "TDK CLI — start services on your laptop.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"tdk",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"discovery",
|
|
39
39
|
"specs",
|
|
40
40
|
"ext",
|
|
41
|
+
"!ext/ide-components/code_executor",
|
|
41
42
|
"shared-platform-engineering/docker-templates"
|
|
42
43
|
],
|
|
43
44
|
"scripts": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
# Priority: service.json → synthesize from path
|
|
9
9
|
# =============================================================================
|
|
10
10
|
|
|
11
|
+
load('../../engine/topologies/tilt/manifest/parser.star', 'generated_string_error')
|
|
11
12
|
load('../../engine/topologies/tilt/manifest/constants.star',
|
|
12
13
|
'MANIFEST_DEFAULTS',
|
|
13
14
|
'DEFAULT_SYNCS',
|
|
@@ -100,6 +101,10 @@ def load_manifest(resource_path, persist_to_disk=False):
|
|
|
100
101
|
|
|
101
102
|
if content and str(content).strip():
|
|
102
103
|
manifest = decode_json(content)
|
|
104
|
+
# Refuse service.json values that would add keys to generated config (GHSA-phgf-pww4-7jxc)
|
|
105
|
+
unsafe_field_error = generated_string_error(manifest, manifest_full_path) if type(manifest) == "dict" else None
|
|
106
|
+
if unsafe_field_error:
|
|
107
|
+
fail(unsafe_field_error)
|
|
103
108
|
manifest_source = 'service.json'
|
|
104
109
|
manifest_path = manifest_full_path
|
|
105
110
|
else:
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# Purpose: Normalize manifests and provide helper accessors
|
|
6
6
|
# =============================================================================
|
|
7
7
|
|
|
8
|
+
load('../../engine/topologies/tilt/manifest/parser.star', 'generated_string_error')
|
|
8
9
|
load('./constants.star', 'MANIFEST_DEFAULTS', 'MANIFEST_FILENAME')
|
|
9
10
|
load('./loading.star', 'apply_manifest_defaults', 'check_prisma_folder', 'get_default_syncs_for_type')
|
|
10
11
|
load('./validation.star', 'validate_manifest')
|
|
@@ -56,6 +57,11 @@ def _load_and_normalize(manifest_path, warn_only=True):
|
|
|
56
57
|
print(" ⚠️ Invalid JSON in manifest: " + manifest_path)
|
|
57
58
|
return None
|
|
58
59
|
fail("Invalid JSON in manifest: " + manifest_path)
|
|
60
|
+
|
|
61
|
+
# Refuse service.json values that would add keys to generated config (GHSA-phgf-pww4-7jxc)
|
|
62
|
+
unsafe_field_error = generated_string_error(manifest, manifest_path) if type(manifest) == "dict" else None
|
|
63
|
+
if unsafe_field_error:
|
|
64
|
+
fail(unsafe_field_error)
|
|
59
65
|
|
|
60
66
|
# Validate
|
|
61
67
|
issues = validate_manifest(manifest)
|
|
@@ -19,6 +19,7 @@ DEFAULTS = {}
|
|
|
19
19
|
# === END INLINED CONSTANTS ===
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
load("./parser.star", "generated_string_error")
|
|
22
23
|
load("./constants.star",
|
|
23
24
|
"MANIFEST_FILENAME",
|
|
24
25
|
"MANIFEST_DEFAULTS",
|
|
@@ -63,6 +64,12 @@ def load_from_file(path):
|
|
|
63
64
|
|
|
64
65
|
if manifest == None:
|
|
65
66
|
return struct(manifest=None, error="Invalid manifest: " + path)
|
|
67
|
+
|
|
68
|
+
# Refuse service.json values that would add keys to generated config (GHSA-phgf-pww4-7jxc). Every Tilt discovery path loads service.json here.
|
|
69
|
+
if type(manifest) == "dict":
|
|
70
|
+
unsafe_field_error = generated_string_error(manifest, path)
|
|
71
|
+
if unsafe_field_error:
|
|
72
|
+
fail(unsafe_field_error)
|
|
66
73
|
|
|
67
74
|
# An `mcp` resource is a Model Context Protocol server: an HTTP service on Bun that is built, routed and health-checked
|
|
68
75
|
# exactly like a backend. Every backend code path keys off appType == 'backend', so normalise here, once, at the single
|
|
@@ -73,24 +73,106 @@ def parse(content, path=""):
|
|
|
73
73
|
warnings=warnings,
|
|
74
74
|
)
|
|
75
75
|
|
|
76
|
+
# Characters allowed in the string fields that are written into generated Compose, Traefik and NATS config as they are. A line break
|
|
77
|
+
# or YAML syntax in one of them adds keys to the generated service (GHSA-phgf-pww4-7jxc). Keep in step with GENERATED_STRING_FIELDS
|
|
78
|
+
# in cli/src/utils/service-manifest.ts.
|
|
79
|
+
_ALPHANUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
80
|
+
_PATH_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789/-"
|
|
81
|
+
_GENERATED_STRING_RULES = [
|
|
82
|
+
# (field path, allowed characters, required first character or "")
|
|
83
|
+
(['healthCheckPath'], _PATH_CHARS, '/'),
|
|
84
|
+
(['traefik', 'host'], _ALPHANUM + '.-', ''),
|
|
85
|
+
(['traefik', 'pathPrefix'], _PATH_CHARS, '/'),
|
|
86
|
+
(['traefik', 'healthCheck'], _PATH_CHARS, '/'),
|
|
87
|
+
(['nats', 'queueGroup'], _ALPHANUM + '_.-', ''),
|
|
88
|
+
(['databaseName'], _ALPHANUM + '_-', ''),
|
|
89
|
+
(['stack'], _ALPHANUM + '_-', ''),
|
|
90
|
+
(['image'], _ALPHANUM + '._/:@-', ''),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
def generated_string_error(manifest, resource_path=""):
|
|
94
|
+
"""
|
|
95
|
+
Return an error message if a value that is written into generated config has unsafe characters, else None.
|
|
96
|
+
|
|
97
|
+
The value is not echoed, because it may contain the line break that makes it dangerous.
|
|
98
|
+
"""
|
|
99
|
+
for keys, allowed, first in _GENERATED_STRING_RULES:
|
|
100
|
+
value = manifest
|
|
101
|
+
for key in keys:
|
|
102
|
+
value = value.get(key) if type(value) == "dict" else None
|
|
103
|
+
if value == None or value == "":
|
|
104
|
+
continue
|
|
105
|
+
field = '.'.join(keys)
|
|
106
|
+
where = " in " + resource_path if resource_path else ""
|
|
107
|
+
if type(value) != "string":
|
|
108
|
+
return "MANIFEST ERROR: '{}' must be a string{}".format(field, where)
|
|
109
|
+
if first and not value.startswith(first):
|
|
110
|
+
return "MANIFEST ERROR: '{}' must start with '{}'{}".format(field, first, where)
|
|
111
|
+
for c in value.elems():
|
|
112
|
+
if c not in allowed:
|
|
113
|
+
return "MANIFEST ERROR: '{}' has characters that are not allowed (no spaces, line breaks or YAML syntax){}".format(field, where)
|
|
114
|
+
return _line_break_error(manifest, resource_path)
|
|
115
|
+
|
|
116
|
+
# Line breaks (including the Unicode ones YAML 1.1 honours) and other control characters.
|
|
117
|
+
_LINE_BREAK_OR_CONTROL = [chr(i) for i in range(32)] + [chr(127), "\u0085", "
", "
"]
|
|
118
|
+
|
|
119
|
+
def _has_line_break(text):
|
|
120
|
+
for bad in _LINE_BREAK_OR_CONTROL:
|
|
121
|
+
if bad in text:
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def _line_break_error(manifest, resource_path):
|
|
126
|
+
"""
|
|
127
|
+
Many more values than _GENERATED_STRING_RULES reach generated config (port, appName, dockerfile, buildContext, basePath, envVars,
|
|
128
|
+
...). A key can only be added through a line break, so no string key or value may contain one. `smoke` is skipped: it is never
|
|
129
|
+
written into generated config. Keep in step with lineBreakErrors in cli/src/utils/service-manifest.ts.
|
|
130
|
+
"""
|
|
131
|
+
where = " in " + resource_path if resource_path else ""
|
|
132
|
+
pending = [(value, key) for key, value in manifest.items() if key != 'smoke']
|
|
133
|
+
for key in manifest.keys():
|
|
134
|
+
if type(key) == "string" and _has_line_break(key):
|
|
135
|
+
return "MANIFEST ERROR: a key contains a line break{}".format(where)
|
|
136
|
+
# Starlark has no recursion or while loops; a service.json never nests anywhere near this many values.
|
|
137
|
+
for _ in range(100000):
|
|
138
|
+
if not pending:
|
|
139
|
+
return None
|
|
140
|
+
value, field = pending.pop()
|
|
141
|
+
if type(value) == "string":
|
|
142
|
+
if _has_line_break(value):
|
|
143
|
+
return "MANIFEST ERROR: '{}' must not contain line breaks or control characters{}".format(field, where)
|
|
144
|
+
elif type(value) == "list":
|
|
145
|
+
for index, item in enumerate(value):
|
|
146
|
+
pending.append((item, "{}[{}]".format(field, index)))
|
|
147
|
+
elif type(value) == "dict":
|
|
148
|
+
for key, item in value.items():
|
|
149
|
+
if type(key) == "string" and _has_line_break(key):
|
|
150
|
+
return "MANIFEST ERROR: a key in '{}' contains a line break{}".format(field, where)
|
|
151
|
+
pending.append((item, "{}.{}".format(field, key)))
|
|
152
|
+
return "MANIFEST ERROR: service.json is nested too deeply{}".format(where)
|
|
153
|
+
|
|
76
154
|
def normalize(manifest, resource_path=""):
|
|
77
155
|
"""
|
|
78
156
|
Normalize manifest with default values and computed fields.
|
|
79
|
-
|
|
157
|
+
|
|
80
158
|
Args:
|
|
81
159
|
manifest: Raw parsed manifest dict
|
|
82
160
|
resource_path: Resource directory path for context
|
|
83
|
-
|
|
161
|
+
|
|
84
162
|
Returns:
|
|
85
163
|
Normalized manifest dict with all fields populated
|
|
86
164
|
"""
|
|
87
165
|
normalized = {}
|
|
88
166
|
warnings = []
|
|
89
|
-
|
|
167
|
+
|
|
90
168
|
# Copy all existing fields
|
|
91
169
|
for key, value in manifest.items():
|
|
92
170
|
normalized[key] = value
|
|
93
171
|
|
|
172
|
+
unsafe_field_error = generated_string_error(normalized, resource_path)
|
|
173
|
+
if unsafe_field_error:
|
|
174
|
+
fail(unsafe_field_error)
|
|
175
|
+
|
|
94
176
|
# 1. Apply defaults for missing fields
|
|
95
177
|
for field, default_value in MANIFEST_DEFAULTS.items():
|
|
96
178
|
if field not in normalized or normalized[field] == None:
|
|
@@ -391,6 +473,7 @@ def get_resource_type_description(manifest):
|
|
|
391
473
|
ManifestParser = struct(
|
|
392
474
|
parse=parse,
|
|
393
475
|
normalize=normalize,
|
|
476
|
+
generated_string_error=generated_string_error,
|
|
394
477
|
extract_resource_path=extract_resource_path,
|
|
395
478
|
extract_stack=extract_stack_from_path,
|
|
396
479
|
parse_traefik=parse_traefik_config,
|
|
@@ -306,6 +306,67 @@ def _test_manifest_merging():
|
|
|
306
306
|
|
|
307
307
|
print("✅ Manifest merging tests complete\n")
|
|
308
308
|
|
|
309
|
+
# =============================================================================
|
|
310
|
+
# TEST SUITE: Values written into generated config (GHSA-phgf-pww4-7jxc)
|
|
311
|
+
# =============================================================================
|
|
312
|
+
|
|
313
|
+
def _test_generated_string_fields():
|
|
314
|
+
"""A line break or YAML syntax in a field that reaches generated compose output must be rejected."""
|
|
315
|
+
base = {'appName': 'api', 'stack': 'shop', 'traefik': {'pathPrefix': '/api/v1/shop-management'}}
|
|
316
|
+
|
|
317
|
+
assert_equal(None, ManifestParser.generated_string_error(base), "Clean manifest has no generated-field error")
|
|
318
|
+
|
|
319
|
+
safe = {
|
|
320
|
+
'appName': 'api',
|
|
321
|
+
'stack': 'shop-1',
|
|
322
|
+
'healthCheckPath': '/api/v1/health',
|
|
323
|
+
'databaseName': 'TDK_shop',
|
|
324
|
+
'image': 'ghcr.io/acme/app:1.0',
|
|
325
|
+
'traefik': {'host': 'shop.backend.my-project.local', 'pathPrefix': '/api/v1/shop', 'healthCheck': '/health'},
|
|
326
|
+
'nats': {'queueGroup': 'shop_backend_svc'},
|
|
327
|
+
}
|
|
328
|
+
assert_equal(None, ManifestParser.generated_string_error(safe), "Safe values are accepted")
|
|
329
|
+
|
|
330
|
+
hostile = [
|
|
331
|
+
{'healthCheckPath': '/health\n privileged: true'},
|
|
332
|
+
{'healthCheckPath': '/health"'},
|
|
333
|
+
{'traefik': {'host': 'a.local\n privileged: true', 'pathPrefix': '/api'}},
|
|
334
|
+
{'traefik': {'pathPrefix': '/api/v1/x\n network_mode: host'}},
|
|
335
|
+
{'traefik': {'pathPrefix': '/api', 'healthCheck': '/h\n volumes: []'}},
|
|
336
|
+
{'traefik': {'pathPrefix': '/api'}, 'nats': {'queueGroup': 'g: true'}},
|
|
337
|
+
{'traefik': {'pathPrefix': '/api'}, 'databaseName': 'TDK_x\n privileged: true'},
|
|
338
|
+
{'traefik': {'pathPrefix': '/api'}, 'stack': 'shop # comment'},
|
|
339
|
+
{'traefik': {'pathPrefix': '/api'}, 'image': 'app\n privileged: true'},
|
|
340
|
+
{'traefik': {'pathPrefix': '/api'}, 'healthCheckPath': 'health'},
|
|
341
|
+
{'traefik': {'pathPrefix': '/api'}, 'healthCheckPath': 42},
|
|
342
|
+
]
|
|
343
|
+
for manifest in hostile:
|
|
344
|
+
assert_not_none(ManifestParser.generated_string_error(manifest), "Rejects unsafe value: " + str(manifest))
|
|
345
|
+
|
|
346
|
+
# The message names the field and never echoes the value
|
|
347
|
+
message = ManifestParser.generated_string_error(hostile[0], 'services/shop/api')
|
|
348
|
+
assert_true("healthCheckPath" in message, "Error names the field")
|
|
349
|
+
assert_true("\n" not in message, "Error does not echo the value")
|
|
350
|
+
|
|
351
|
+
# Fields outside the allowlist also reach generated config, so a line break anywhere is refused
|
|
352
|
+
clean = {'appName': 'api', 'stack': 'shop', 'traefik': {'pathPrefix': '/api'}}
|
|
353
|
+
line_breaks = [
|
|
354
|
+
{'port': '4000\n privileged: true'},
|
|
355
|
+
{'appName': 'api\n privileged: true'},
|
|
356
|
+
{'dockerfile': 'Dockerfile\r privileged: true'},
|
|
357
|
+
{'buildContext': 'src\u2028privileged: true'},
|
|
358
|
+
{'envVars': [{'name': 'A', 'value': 'v\n privileged: true'}]},
|
|
359
|
+
{'params': {'a\n privileged': 'x'}},
|
|
360
|
+
]
|
|
361
|
+
for extra in line_breaks:
|
|
362
|
+
manifest = dict(clean)
|
|
363
|
+
manifest.update(extra)
|
|
364
|
+
assert_not_none(ManifestParser.generated_string_error(manifest), "Rejects a line break: " + repr(extra))
|
|
365
|
+
|
|
366
|
+
smoke = dict(clean)
|
|
367
|
+
smoke['smoke'] = {'via': '/api', 'create': {'body': '{\n}'}}
|
|
368
|
+
assert_equal(None, ManifestParser.generated_string_error(smoke), "smoke is never written into generated config")
|
|
369
|
+
|
|
309
370
|
# =============================================================================
|
|
310
371
|
# TEST SUITE: Get Normalized Convenience Function
|
|
311
372
|
# =============================================================================
|
|
@@ -366,12 +427,13 @@ def run_tests():
|
|
|
366
427
|
_test_loader_functions()
|
|
367
428
|
_test_parser_functions()
|
|
368
429
|
_test_parse_and_normalize()
|
|
369
|
-
|
|
430
|
+
_test_stack_extraction()
|
|
370
431
|
_test_frontend_normalization()
|
|
371
432
|
_test_traefik_config()
|
|
372
433
|
_test_manifest_merging()
|
|
373
434
|
_test_get_normalized()
|
|
374
435
|
_test_resource_description()
|
|
436
|
+
_test_generated_string_fields()
|
|
375
437
|
|
|
376
438
|
# Print results
|
|
377
439
|
print("\n" + "="*70)
|
|
@@ -13,6 +13,8 @@ The Tilt IDE provides browser-based tools for browsing, viewing, and executing c
|
|
|
13
13
|
| **Config Inspector** | 9767 | http://configs.localhost | http://localhost:9767 |
|
|
14
14
|
| **Code Executor** | 9768 | http://terminal.localhost | http://localhost:9768 |
|
|
15
15
|
|
|
16
|
+
> **Note:** `code_executor` is not shipped in the npm package or started by `tilt up` (see #826). Its source stays in the repository.
|
|
17
|
+
|
|
16
18
|
## Quick Start
|
|
17
19
|
|
|
18
20
|
All components start automatically when you run `tilt up`:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tdk-landscape/tdk-cli-core",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.127",
|
|
4
4
|
"description": "TDK CLI — start services on your laptop.",
|
|
5
5
|
"homepage": "https://tdk-landscape.github.io/tdk-website",
|
|
6
6
|
"repository": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"discovery",
|
|
26
26
|
"specs",
|
|
27
27
|
"ext",
|
|
28
|
+
"!ext/ide-components/code_executor",
|
|
28
29
|
"shared-platform-engineering/docker-templates",
|
|
29
30
|
"examples/tdk-example",
|
|
30
31
|
"cli/bin",
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Tilt IDE Code Executor
|
|
4
|
-
Execute commands from web UI at http://localhost:9768
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import subprocess
|
|
10
|
-
import sys
|
|
11
|
-
import urllib.parse
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
# Add shared modules
|
|
15
|
-
sys.path.insert(0, str(Path(__file__).parent.parent / 'shared'))
|
|
16
|
-
from file_utils import PROJECT_ROOT
|
|
17
|
-
from http_utils import BaseIDEHandler, run_server
|
|
18
|
-
|
|
19
|
-
PORT = 9768
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class CodeExecutorHandler(BaseIDEHandler):
|
|
23
|
-
"""HTTP request handler for code executor"""
|
|
24
|
-
|
|
25
|
-
# Command validation constants
|
|
26
|
-
DANGEROUS_COMMANDS = [
|
|
27
|
-
'rm -rf /', 'rm -rf /*', ':(){ :|:& };:', '> /dev/sda',
|
|
28
|
-
'mv / /dev/null', 'dd if=/dev/zero', 'mkfs.', 'chmod -R 777 /',
|
|
29
|
-
'sudo', 'su -', 'passwd', 'deluser', 'userdel'
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
ALLOWED_PREFIXES = [
|
|
33
|
-
'bun ', 'ls ', 'pwd', 'echo ', 'cat ', 'head ', 'tail ',
|
|
34
|
-
'grep ', 'find ', 'cd ', 'mkdir ', 'touch ', 'clear', 'exit',
|
|
35
|
-
'npm ', 'yarn ', 'npx ', 'pnpm ', 'git ', 'curl ', 'wget '
|
|
36
|
-
]
|
|
37
|
-
|
|
38
|
-
def validate_command(self, cmd: str) -> tuple:
|
|
39
|
-
"""
|
|
40
|
-
Validate command for safety.
|
|
41
|
-
Returns (is_valid, error_message)
|
|
42
|
-
"""
|
|
43
|
-
# Block dangerous commands
|
|
44
|
-
for dangerous in self.DANGEROUS_COMMANDS:
|
|
45
|
-
if dangerous in cmd.lower():
|
|
46
|
-
return False, f"Command blocked for safety: {dangerous}"
|
|
47
|
-
|
|
48
|
-
# Only allow bun commands and safe system commands
|
|
49
|
-
cmd_stripped = cmd.strip()
|
|
50
|
-
is_allowed = any(cmd_stripped.startswith(prefix) for prefix in self.ALLOWED_PREFIXES)
|
|
51
|
-
|
|
52
|
-
if not is_allowed:
|
|
53
|
-
return False, "Only bun, npm, git, and safe system commands are allowed"
|
|
54
|
-
|
|
55
|
-
return True, None
|
|
56
|
-
|
|
57
|
-
def execute_command(self, cmd: str, cwd: str = None) -> dict:
|
|
58
|
-
"""Execute command and return result"""
|
|
59
|
-
# Validate
|
|
60
|
-
is_valid, error = self.validate_command(cmd)
|
|
61
|
-
if not is_valid:
|
|
62
|
-
return {'success': False, 'stdout': '', 'stderr': error, 'exit_code': 1}
|
|
63
|
-
|
|
64
|
-
# Determine working directory
|
|
65
|
-
work_dir = cwd or str(PROJECT_ROOT)
|
|
66
|
-
|
|
67
|
-
try:
|
|
68
|
-
# Execute with timeout
|
|
69
|
-
result = subprocess.run(
|
|
70
|
-
cmd,
|
|
71
|
-
shell=True,
|
|
72
|
-
cwd=work_dir,
|
|
73
|
-
capture_output=True,
|
|
74
|
-
text=True,
|
|
75
|
-
timeout=60, # 60 second timeout
|
|
76
|
-
env={**os.environ, 'TERM': 'xterm-color'}
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
return {
|
|
80
|
-
'success': result.returncode == 0,
|
|
81
|
-
'stdout': result.stdout,
|
|
82
|
-
'stderr': result.stderr,
|
|
83
|
-
'exit_code': result.returncode
|
|
84
|
-
}
|
|
85
|
-
except subprocess.TimeoutExpired:
|
|
86
|
-
return {'success': False, 'stdout': '', 'stderr': 'Command timed out (60s limit)', 'exit_code': -1}
|
|
87
|
-
except Exception as e:
|
|
88
|
-
return {'success': False, 'stdout': '', 'stderr': str(e), 'exit_code': -1}
|
|
89
|
-
|
|
90
|
-
def _get_template_path(self) -> Path:
|
|
91
|
-
"""Override to use shared template"""
|
|
92
|
-
return Path(__file__).parent.parent / 'shared' / 'templates' / 'base.html'
|
|
93
|
-
|
|
94
|
-
def do_GET(self):
|
|
95
|
-
"""Handle GET requests"""
|
|
96
|
-
parsed = urllib.parse.urlparse(self.path)
|
|
97
|
-
path = parsed.path
|
|
98
|
-
query = urllib.parse.parse_qs(parsed.query)
|
|
99
|
-
|
|
100
|
-
# Health check
|
|
101
|
-
if path == '/health':
|
|
102
|
-
self.handle_health_check('code-executor')
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
# Static files
|
|
106
|
-
if path.startswith('/static/'):
|
|
107
|
-
static_path = Path(__file__).parent.parent / 'shared' / path[1:]
|
|
108
|
-
if static_path.exists():
|
|
109
|
-
with open(static_path, 'r') as f:
|
|
110
|
-
content = f.read()
|
|
111
|
-
self.send_css_response(content)
|
|
112
|
-
else:
|
|
113
|
-
self.send_error_response(404, 'Not found')
|
|
114
|
-
return
|
|
115
|
-
|
|
116
|
-
if path in ('/', '/terminal'):
|
|
117
|
-
cwd = query.get('cwd', [str(PROJECT_ROOT)])[0]
|
|
118
|
-
cwd_display = self.escape_html(cwd.replace(str(PROJECT_ROOT), '~'))
|
|
119
|
-
|
|
120
|
-
content = f'''
|
|
121
|
-
<div class="terminal" style="height: calc(100vh - 200px);">
|
|
122
|
-
<div class="terminal-header">
|
|
123
|
-
<div>
|
|
124
|
-
<span class="panel-title">💻 Terminal</span>
|
|
125
|
-
<span class="text-muted" style="margin-left: 1rem; font-size: 0.85rem;">Working directory: {cwd_display}</span>
|
|
126
|
-
</div>
|
|
127
|
-
<div>
|
|
128
|
-
<span class="text-muted" style="font-size: 0.8rem;">Supports: bun, npm, git, basic shell</span>
|
|
129
|
-
</div>
|
|
130
|
-
</div>
|
|
131
|
-
<div class="terminal-content" id="terminal-output" style="white-space: pre-wrap; font-family: var(--font-mono);">
|
|
132
|
-
<div class="text-muted">TDK Landscape Tilt IDE Terminal</div>
|
|
133
|
-
<div class="text-muted">Type a command and press Enter to execute</div>
|
|
134
|
-
<div class="text-muted">─────────────────────────────────────────</div>
|
|
135
|
-
</div>
|
|
136
|
-
<div class="terminal-input-line">
|
|
137
|
-
<span class="terminal-prompt">$</span>
|
|
138
|
-
<input type="text" id="terminal-input" class="terminal-input"
|
|
139
|
-
placeholder="Enter command (e.g., bun test, ls -la)..."
|
|
140
|
-
autocomplete="off" spellcheck="false">
|
|
141
|
-
</div>
|
|
142
|
-
</div>
|
|
143
|
-
|
|
144
|
-
<script>
|
|
145
|
-
const terminalOutput = document.getElementById('terminal-output');
|
|
146
|
-
const terminalInput = document.getElementById('terminal-input');
|
|
147
|
-
const cwd = {json.dumps(cwd)};
|
|
148
|
-
let commandHistory = [];
|
|
149
|
-
let historyIndex = -1;
|
|
150
|
-
|
|
151
|
-
terminalInput.addEventListener('keydown', async function(e) {{
|
|
152
|
-
if (e.key === 'Enter') {{
|
|
153
|
-
const cmd = this.value.trim();
|
|
154
|
-
if (!cmd) return;
|
|
155
|
-
|
|
156
|
-
// Add to history
|
|
157
|
-
commandHistory.push(cmd);
|
|
158
|
-
historyIndex = commandHistory.length;
|
|
159
|
-
|
|
160
|
-
// Display command
|
|
161
|
-
terminalOutput.innerHTML += `<div><span style="color: var(--accent-primary);">$</span> ${{self.escape_html(cmd)}}</div>`;
|
|
162
|
-
terminalInput.value = '';
|
|
163
|
-
|
|
164
|
-
// Execute
|
|
165
|
-
try {{
|
|
166
|
-
const response = await fetch('/execute', {{
|
|
167
|
-
method: 'POST',
|
|
168
|
-
headers: {{'Content-Type': 'application/json'}},
|
|
169
|
-
body: JSON.stringify({{command: cmd, cwd: cwd}})
|
|
170
|
-
}});
|
|
171
|
-
const result = await response.json();
|
|
172
|
-
|
|
173
|
-
// Display output
|
|
174
|
-
if (result.stdout) {{
|
|
175
|
-
terminalOutput.innerHTML += `<div style="color: var(--text-primary);">${{self.escape_html(result.stdout)}}</div>`;
|
|
176
|
-
}}
|
|
177
|
-
if (result.stderr) {{
|
|
178
|
-
terminalOutput.innerHTML += `<div style="color: var(--error);">${{self.escape_html(result.stderr)}}</div>`;
|
|
179
|
-
}}
|
|
180
|
-
|
|
181
|
-
// Status
|
|
182
|
-
const statusColor = result.success ? 'var(--success)' : 'var(--error)';
|
|
183
|
-
terminalOutput.innerHTML += `<div style="color: ${{statusColor}}; font-size: 0.8rem;">Exit code: ${{result.exit_code}}</div>`;
|
|
184
|
-
|
|
185
|
-
}} catch (err) {{
|
|
186
|
-
terminalOutput.innerHTML += `<div style="color: var(--error);">Error: ${{err.message}}</div>`;
|
|
187
|
-
}}
|
|
188
|
-
|
|
189
|
-
// Scroll to bottom
|
|
190
|
-
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
|
191
|
-
}}
|
|
192
|
-
|
|
193
|
-
// History navigation
|
|
194
|
-
if (e.key === 'ArrowUp') {{
|
|
195
|
-
e.preventDefault();
|
|
196
|
-
if (historyIndex > 0) {{
|
|
197
|
-
historyIndex--;
|
|
198
|
-
terminalInput.value = commandHistory[historyIndex];
|
|
199
|
-
}}
|
|
200
|
-
}}
|
|
201
|
-
if (e.key === 'ArrowDown') {{
|
|
202
|
-
e.preventDefault();
|
|
203
|
-
if (historyIndex < commandHistory.length - 1) {{
|
|
204
|
-
historyIndex++;
|
|
205
|
-
terminalInput.value = commandHistory[historyIndex];
|
|
206
|
-
}} else {{
|
|
207
|
-
historyIndex = commandHistory.length;
|
|
208
|
-
terminalInput.value = '';
|
|
209
|
-
}}
|
|
210
|
-
}}
|
|
211
|
-
}});
|
|
212
|
-
|
|
213
|
-
terminalInput.focus();
|
|
214
|
-
</script>
|
|
215
|
-
'''
|
|
216
|
-
|
|
217
|
-
self.send_html_response(self.render_page('Terminal', content, icon='💻'))
|
|
218
|
-
return
|
|
219
|
-
|
|
220
|
-
# 404
|
|
221
|
-
self.send_html_response(self.render_page(
|
|
222
|
-
'Not Found',
|
|
223
|
-
'<div class="empty-state"><div class="empty-state-icon">❓</div>Page not found</div>',
|
|
224
|
-
icon='❓'
|
|
225
|
-
), 404)
|
|
226
|
-
|
|
227
|
-
def do_POST(self):
|
|
228
|
-
"""Handle POST requests"""
|
|
229
|
-
parsed = urllib.parse.urlparse(self.path)
|
|
230
|
-
|
|
231
|
-
if parsed.path == '/execute':
|
|
232
|
-
# Read request body
|
|
233
|
-
content_length = int(self.headers.get('Content-Length', 0))
|
|
234
|
-
body = self.rfile.read(content_length).decode()
|
|
235
|
-
|
|
236
|
-
try:
|
|
237
|
-
data = json.loads(body)
|
|
238
|
-
cmd = data.get('command', '').strip()
|
|
239
|
-
cwd = data.get('cwd', str(PROJECT_ROOT))
|
|
240
|
-
|
|
241
|
-
if not cmd:
|
|
242
|
-
self.send_json_response({'success': False, 'error': 'No command provided'}, 400)
|
|
243
|
-
return
|
|
244
|
-
|
|
245
|
-
result = self.execute_command(cmd, cwd)
|
|
246
|
-
self.send_json_response(result)
|
|
247
|
-
|
|
248
|
-
except json.JSONDecodeError:
|
|
249
|
-
self.send_json_response({'success': False, 'error': 'Invalid JSON'}, 400)
|
|
250
|
-
except Exception as e:
|
|
251
|
-
self.send_json_response({'success': False, 'error': str(e)}, 500)
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
self.send_json_response({'error': 'Not found'}, 404)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def main():
|
|
258
|
-
"""Run the code executor server"""
|
|
259
|
-
run_server(
|
|
260
|
-
CodeExecutorHandler,
|
|
261
|
-
PORT,
|
|
262
|
-
'💻 Tilt IDE Code Executor',
|
|
263
|
-
'🚀 Execute bun commands from the browser\n ⚠️ Safety: Only bun, npm, git, and safe system commands allowed'
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if __name__ == '__main__':
|
|
268
|
-
main()
|