@tdk-landscape/tdk-cli-core 1.3.104 → 1.3.106
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 +1 -0
- package/cli/dist/cli.js +4 -0
- package/cli/dist/commands/doctor.js +28 -3
- package/cli/dist/commands/down.js +13 -4
- package/cli/dist/commands/logs.js +85 -0
- package/cli/dist/commands/mcp.js +24 -0
- package/cli/dist/commands/projects.js +37 -6
- package/cli/dist/commands/resources.js +3 -2
- package/cli/dist/commands/stacks.js +30 -3
- package/cli/dist/commands/status.js +31 -0
- package/cli/dist/commands/ui.js +1 -1
- package/cli/dist/commands/up.js +149 -17
- package/cli/dist/mcp/server.js +100 -0
- package/cli/dist/mcp/tools.js +273 -0
- package/cli/dist/utils/agent-host.js +35 -0
- package/cli/dist/utils/discovery-context.js +9 -3
- package/cli/dist/utils/doctor-report.js +14 -1
- package/cli/dist/utils/doctor-wiring.js +88 -0
- package/cli/dist/utils/json-output.js +27 -0
- package/cli/dist/utils/services.js +65 -7
- package/cli/dist/utils/status-ports.js +32 -0
- package/cli/dist/utils/tilt-logs.js +62 -0
- package/cli/dist/utils/up-only.js +29 -0
- package/cli/dist/utils/up-readiness.js +115 -0
- package/cli/package.json +1 -1
- package/package.json +1 -1
package/cli/dist/commands/up.js
CHANGED
|
@@ -3,20 +3,25 @@ import { join } from "node:path";
|
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { ensureProjectRuntimeAssets } from "../generator/template-engine.js";
|
|
6
|
+
import { DEVCONTAINER_DOCKER_FIX, detectHost, isContainerHost, WEBCONTAINER_UP_MESSAGE, } from "../utils/agent-host.js";
|
|
6
7
|
import { handleDryRun } from "../utils/command-helpers.js";
|
|
8
|
+
import { getDeferredResourceNames } from "../utils/doctor-runtime.js";
|
|
7
9
|
import { completeEnvFile } from "../utils/env-validator.js";
|
|
8
10
|
import { errorFactories, handleTiltFailure, requireProjectRoot, runCommand, showErrorAndExit, withTiltCheck, } from "../utils/errors.js";
|
|
9
11
|
import { formatCount } from "../utils/formatting.js";
|
|
10
12
|
import { exportHostPortPlan, getHostPortPlan, writeSavedHostPortPlan, } from "../utils/host-port-config.js";
|
|
11
13
|
import { formatHostPortPlan } from "../utils/host-port-plan.js";
|
|
14
|
+
import { createJsonEmitter } from "../utils/json-output.js";
|
|
12
15
|
import { findProjectRoot } from "../utils/paths.js";
|
|
13
16
|
import { findAvailablePort } from "../utils/port-assignment.js";
|
|
14
17
|
import { isApiServiceType } from "../utils/resource-kind.js";
|
|
15
18
|
import { appendHealthPath, resolveSubdomainBases } from "../utils/service-urls.js";
|
|
16
|
-
import { discoverResources,
|
|
19
|
+
import { discoverResources, discoverResourcesStrict, discoverStacks, stackExists, } from "../utils/services.js";
|
|
17
20
|
import { buildSmokePlans, formatSmokeFailure, runSmokePlans } from "../utils/smoke.js";
|
|
18
21
|
import { buildTiltUpArgs, runTilt } from "../utils/tilt.js";
|
|
19
22
|
import { stopTiltOnPort } from "../utils/tilt-process.js";
|
|
23
|
+
import { findUnknownServices, resolveOnlySelection } from "../utils/up-only.js";
|
|
24
|
+
import { tiltGetUiResources, waitForTiltResourcesReady } from "../utils/up-readiness.js";
|
|
20
25
|
import { enableDiscoveredStacks } from "./project.js";
|
|
21
26
|
export function formatUpSuccess(port, appUrls = []) {
|
|
22
27
|
return [
|
|
@@ -34,6 +39,20 @@ export function nativeWindowsUpRefusal(platform, allowNativeWindows) {
|
|
|
34
39
|
return null;
|
|
35
40
|
return "Landscape startup needs Ubuntu on WSL2. Native Windows is inspect-only.";
|
|
36
41
|
}
|
|
42
|
+
/** Why `tdk up` cannot work on this host, or null when it may proceed. Docker reachability is only probed in container hosts. */
|
|
43
|
+
export async function hostUpRefusal(host = detectHost(), dockerReachable = defaultDockerReachable) {
|
|
44
|
+
if (host.kind === "webcontainer")
|
|
45
|
+
return WEBCONTAINER_UP_MESSAGE;
|
|
46
|
+
if (isContainerHost(host.kind) && !(await dockerReachable()))
|
|
47
|
+
return DEVCONTAINER_DOCKER_FIX;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
async function defaultDockerReachable() {
|
|
51
|
+
const { execFile } = await import("node:child_process");
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
execFile("docker", ["ps"], { timeout: 10_000 }, (error) => resolve(!error));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
37
56
|
function waitForTiltUi(port, timeoutMs = 30_000) {
|
|
38
57
|
return new Promise((resolve) => {
|
|
39
58
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -64,7 +83,17 @@ export const upCommand = new Command("up")
|
|
|
64
83
|
.option("-q, --quiet", "Suppress non-essential output", false)
|
|
65
84
|
.option("--dry-run", "Show what would be started without starting", false)
|
|
66
85
|
.option("-f, --force", "Kill existing Tilt process before starting", false)
|
|
86
|
+
.option("--only <services...>", "Start only these services plus what the Tiltfile enables for them (their dependsOn services and shared infrastructure). With a stack, names must belong to that stack; dependencies may cross stacks")
|
|
87
|
+
.option("--json", "Print one JSON object on stdout when the stack is ready or the command fails; implies --quiet", false)
|
|
67
88
|
.action(async (stackName, options) => {
|
|
89
|
+
const emit = options.json ? createJsonEmitter("UP_FAILED", "tdk up") : undefined;
|
|
90
|
+
if (options.json)
|
|
91
|
+
options.quiet = true;
|
|
92
|
+
const hostRefusal = options.dryRun ? null : await hostUpRefusal();
|
|
93
|
+
if (hostRefusal) {
|
|
94
|
+
emit?.({ ok: false }, [{ code: "HOST_UNSUPPORTED", message: hostRefusal }]);
|
|
95
|
+
showErrorAndExit(hostRefusal);
|
|
96
|
+
}
|
|
68
97
|
const platformRefusal = nativeWindowsUpRefusal(process.platform, process.env.TDK_ALLOW_NATIVE_WINDOWS);
|
|
69
98
|
if (platformRefusal)
|
|
70
99
|
showErrorAndExit(platformRefusal);
|
|
@@ -76,6 +105,21 @@ export const upCommand = new Command("up")
|
|
|
76
105
|
const projectRoot = options.dryRun
|
|
77
106
|
? requireProjectRoot()
|
|
78
107
|
: (findProjectRoot() ?? process.cwd());
|
|
108
|
+
const discoveredResources = discoverResourcesStrict();
|
|
109
|
+
// Reject a bad request before anything below can write to the project (.env, runtime assets, .tdk/project.json).
|
|
110
|
+
if (options.only) {
|
|
111
|
+
if (stackName && !stackExists(stackName))
|
|
112
|
+
errorFactories.stackNotFound(stackName).exit();
|
|
113
|
+
const candidates = stackName
|
|
114
|
+
? discoveredResources.filter((resource) => resource.stack === stackName)
|
|
115
|
+
: discoveredResources;
|
|
116
|
+
const unknown = findUnknownServices(options.only, candidates);
|
|
117
|
+
if (unknown.length > 0) {
|
|
118
|
+
const message = `Unknown service ${unknown.join(", ")}. Valid names: ${candidates.map((s) => s.name).join(", ")}`;
|
|
119
|
+
emit?.({ ok: false }, [{ code: "UNKNOWN_SERVICE", message }]);
|
|
120
|
+
showErrorAndExit(message, 2);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
79
123
|
const hostPortPlan = await getHostPortPlan(projectRoot, {
|
|
80
124
|
inspectDocker: !options.dryRun,
|
|
81
125
|
});
|
|
@@ -101,18 +145,27 @@ export const upCommand = new Command("up")
|
|
|
101
145
|
let stackDescription;
|
|
102
146
|
let focusServiceNames = [];
|
|
103
147
|
if (stackName) {
|
|
104
|
-
|
|
148
|
+
servicesToStart = discoveredResources.filter((resource) => resource.stack === stackName);
|
|
149
|
+
if (servicesToStart.length === 0) {
|
|
105
150
|
errorFactories.stackNotFound(stackName).exit();
|
|
106
151
|
}
|
|
107
|
-
servicesToStart = getResourcesForStack(stackName);
|
|
108
152
|
focusServiceNames = servicesToStart.map((s) => s.name);
|
|
109
153
|
stackDescription = `stack "${stackName}"`;
|
|
110
154
|
}
|
|
111
155
|
else {
|
|
112
|
-
servicesToStart =
|
|
113
|
-
const allStacks = discoverStacks();
|
|
156
|
+
servicesToStart = discoveredResources;
|
|
157
|
+
const allStacks = discoverStacks(discoveredResources);
|
|
114
158
|
stackDescription = `all stacks (${formatCount(allStacks.length, "stack")}, ${formatCount(servicesToStart.length, "service")})`;
|
|
115
159
|
}
|
|
160
|
+
let dependencyNames = [];
|
|
161
|
+
if (options.only) {
|
|
162
|
+
// Dependencies come from the whole project, not just the named stack: dependsOn may cross stacks.
|
|
163
|
+
const selection = resolveOnlySelection(options.only, discoverResources());
|
|
164
|
+
servicesToStart = selection.selected;
|
|
165
|
+
dependencyNames = selection.dependencies;
|
|
166
|
+
focusServiceNames = servicesToStart.map((s) => s.name);
|
|
167
|
+
stackDescription = `${formatCount(options.only.length, "requested service")}${dependencyNames.length > 0 ? ` plus ${dependencyNames.join(", ")}` : ""}`;
|
|
168
|
+
}
|
|
116
169
|
if (options.verbose && !options.quiet) {
|
|
117
170
|
console.log(chalk.gray(`Found ${formatCount(servicesToStart.length, "service")} in ${stackDescription}`));
|
|
118
171
|
}
|
|
@@ -145,13 +198,31 @@ export const upCommand = new Command("up")
|
|
|
145
198
|
});
|
|
146
199
|
}
|
|
147
200
|
}
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
:
|
|
201
|
+
const focusTargets = options.only ?? (stackName ? [stackName] : undefined);
|
|
202
|
+
const tiltArgs = buildTiltUpArgs(focusServiceNames, {
|
|
203
|
+
verbose: options.verbose,
|
|
204
|
+
quiet: options.quiet,
|
|
205
|
+
force: options.force,
|
|
206
|
+
focusTargets,
|
|
207
|
+
});
|
|
208
|
+
const dryRunCommand = focusTargets ? `tilt up ${tiltArgs.join(" ")}` : "tilt up";
|
|
151
209
|
if (!options.quiet) {
|
|
152
210
|
console.log(chalk.blue(formatHostPortPlan(hostPortPlan)));
|
|
153
211
|
console.log(chalk.gray("Override with TDK_HTTP_PORT, TDK_HTTPS_PORT, or TDK_POSTGRES_PORT."));
|
|
154
212
|
}
|
|
213
|
+
if (emit && options.dryRun) {
|
|
214
|
+
emit({
|
|
215
|
+
ok: true,
|
|
216
|
+
dryRun: true,
|
|
217
|
+
stack: stackName ?? null,
|
|
218
|
+
services: serviceNames,
|
|
219
|
+
...(options.only
|
|
220
|
+
? { requested: options.only, declaredDependencies: dependencyNames }
|
|
221
|
+
: {}),
|
|
222
|
+
command: dryRunCommand,
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
155
226
|
if (handleDryRun(options, "not starting services", dryRunCommand)) {
|
|
156
227
|
return;
|
|
157
228
|
}
|
|
@@ -159,6 +230,27 @@ export const upCommand = new Command("up")
|
|
|
159
230
|
writeSavedHostPortPlan(projectRoot, hostPortPlan);
|
|
160
231
|
const basePort = 10350;
|
|
161
232
|
let port = basePort;
|
|
233
|
+
// A second Tilt would apply a different selection to the same containers, whatever port it listens on, so `--only`
|
|
234
|
+
// looks for a Tilt that answers on the default port and on TILT_PORT before it chooses one. A listener that is not
|
|
235
|
+
// Tilt does not count: the port is then picked below as usual.
|
|
236
|
+
if (options.only) {
|
|
237
|
+
const envPort = process.env.TILT_PORT ? Number.parseInt(process.env.TILT_PORT, 10) : NaN;
|
|
238
|
+
const candidatePorts = [
|
|
239
|
+
...new Set([basePort, ...(Number.isInteger(envPort) ? [envPort] : [])]),
|
|
240
|
+
];
|
|
241
|
+
const running = [];
|
|
242
|
+
for (const candidate of candidatePorts) {
|
|
243
|
+
if ((await tiltGetUiResources(candidate)) !== null)
|
|
244
|
+
running.push(candidate);
|
|
245
|
+
}
|
|
246
|
+
if (running.length > 0 && !options.force) {
|
|
247
|
+
const message = `A Tilt is already running on port ${running.join(", ")}. --only cannot change a running stack's services: run \`tdk down\` first, or pass --force to replace it.`;
|
|
248
|
+
emit?.({ ok: false }, [{ code: "TILT_ALREADY_RUNNING", message }]);
|
|
249
|
+
showErrorAndExit(message);
|
|
250
|
+
}
|
|
251
|
+
for (const runningPort of running)
|
|
252
|
+
stopTiltOnPort(runningPort);
|
|
253
|
+
}
|
|
162
254
|
if (process.env.TILT_PORT) {
|
|
163
255
|
port = parseInt(process.env.TILT_PORT, 10);
|
|
164
256
|
}
|
|
@@ -180,18 +272,12 @@ export const upCommand = new Command("up")
|
|
|
180
272
|
stopTiltOnPort(port);
|
|
181
273
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
182
274
|
}
|
|
183
|
-
const tiltArgs = buildTiltUpArgs(focusServiceNames, {
|
|
184
|
-
verbose: options.verbose,
|
|
185
|
-
quiet: options.quiet,
|
|
186
|
-
force: options.force,
|
|
187
|
-
focusTargets: stackName ? [stackName] : undefined,
|
|
188
|
-
});
|
|
189
275
|
if (!options.quiet) {
|
|
190
276
|
console.log(chalk.gray("\nRunning tilt up..."));
|
|
191
277
|
console.log(chalk.gray(`Using Tiltfile: .tdk/.tdk-out/Tiltfile`));
|
|
192
278
|
}
|
|
193
279
|
const tiltRun = runTilt("up", tiltArgs, {
|
|
194
|
-
verbose: options.verbose,
|
|
280
|
+
verbose: options.verbose && !options.json,
|
|
195
281
|
quiet: options.quiet,
|
|
196
282
|
inheritStdio: !options.quiet, // Suppress tilt output in quiet mode
|
|
197
283
|
});
|
|
@@ -202,12 +288,45 @@ export const upCommand = new Command("up")
|
|
|
202
288
|
let printedSuccess = false;
|
|
203
289
|
const successOutput = (async () => {
|
|
204
290
|
const uiReady = await waitForTiltUi(port);
|
|
291
|
+
// JSON consumers get success only after every non-deferred resource is built and running, not when the UI port opens.
|
|
292
|
+
let jsonReady = false;
|
|
293
|
+
let startedServices = serviceNames;
|
|
294
|
+
let startedDependencies = dependencyNames;
|
|
295
|
+
if (uiReady && emit) {
|
|
296
|
+
const deferred = getDeferredResourceNames();
|
|
297
|
+
const readiness = await waitForTiltResourcesReady(port, {
|
|
298
|
+
deferred,
|
|
299
|
+
// Only what the caller named must be enabled. Dependencies are the Tiltfile's call (it skips some on purpose).
|
|
300
|
+
expected: options.only
|
|
301
|
+
? options.only.filter((name) => !deferred.has(name))
|
|
302
|
+
: undefined,
|
|
303
|
+
});
|
|
304
|
+
if (readiness.ready) {
|
|
305
|
+
jsonReady = true;
|
|
306
|
+
if (options.only) {
|
|
307
|
+
// Report what Tilt actually enabled, not the service.json closure, which can differ from the Tiltfile's expansion.
|
|
308
|
+
const enabled = new Set(readiness.enabled);
|
|
309
|
+
const allNames = discoverResources().map((r) => r.name);
|
|
310
|
+
startedServices = allNames.filter((name) => enabled.has(name));
|
|
311
|
+
startedDependencies = startedServices.filter((name) => !options.only.includes(name));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
const detail = readiness.failures.map((f) => `${f.name}: ${f.message}`).join("; ");
|
|
316
|
+
emit({ ok: false, tiltUrl: `http://localhost:${port}`, failures: readiness.failures }, [
|
|
317
|
+
{
|
|
318
|
+
code: readiness.timedOut ? "UP_TIMEOUT" : "UP_RESOURCE_FAILED",
|
|
319
|
+
message: `${readiness.timedOut ? "Timed out waiting for resources" : "Resources failed"}${detail ? ` (${detail})` : ""}. Tilt is still running; inspect it or run: tdk down`,
|
|
320
|
+
},
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
205
324
|
if (uiReady && !options.quiet) {
|
|
206
325
|
for (const line of formatUpSuccess(port))
|
|
207
326
|
console.log(chalk.blue(line));
|
|
208
327
|
printedSuccess = true;
|
|
209
328
|
}
|
|
210
|
-
if (uiReady && smokePlans.length > 0) {
|
|
329
|
+
if (uiReady && smokePlans.length > 0 && (!emit || jsonReady)) {
|
|
211
330
|
if (!options.quiet) {
|
|
212
331
|
console.log(chalk.gray(`Smoke check: ${smokePlans.map((p) => p.name).join(", ")}`));
|
|
213
332
|
}
|
|
@@ -224,8 +343,21 @@ export const upCommand = new Command("up")
|
|
|
224
343
|
console.error(chalk.red(formatSmokeFailure(smokeResult)));
|
|
225
344
|
}
|
|
226
345
|
}
|
|
227
|
-
if (smokeFailed)
|
|
346
|
+
if (smokeFailed) {
|
|
228
347
|
stopTiltOnPort(port);
|
|
348
|
+
emit?.({ ok: false }, [
|
|
349
|
+
{ code: "UP_SMOKE_FAILED", message: "A smoke check failed; Tilt was stopped" },
|
|
350
|
+
]);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (uiReady && jsonReady && !smokeFailed) {
|
|
354
|
+
emit?.({
|
|
355
|
+
ok: true,
|
|
356
|
+
stack: stackName ?? null,
|
|
357
|
+
services: startedServices,
|
|
358
|
+
...(options.only ? { requested: options.only, dependencies: startedDependencies } : {}),
|
|
359
|
+
tiltUrl: `http://localhost:${port}`,
|
|
360
|
+
});
|
|
229
361
|
}
|
|
230
362
|
})();
|
|
231
363
|
const result = await tiltRun;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal Model Context Protocol server over newline-delimited JSON-RPC 2.0 (the stdio transport). It implements only
|
|
3
|
+
* what a tools-only server needs: initialize, ping, tools/list and tools/call. Keeping it dependency-free keeps the
|
|
4
|
+
* published CLI small; the protocol surface used here is stable across the listed protocol versions.
|
|
5
|
+
*/
|
|
6
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
7
|
+
const ERROR = {
|
|
8
|
+
parse: -32700,
|
|
9
|
+
invalidRequest: -32600,
|
|
10
|
+
methodNotFound: -32601,
|
|
11
|
+
invalidParams: -32602,
|
|
12
|
+
};
|
|
13
|
+
function reply(id, result) {
|
|
14
|
+
return JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
15
|
+
}
|
|
16
|
+
function fail(id, code, message) {
|
|
17
|
+
return JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
|
|
18
|
+
}
|
|
19
|
+
export function createMcpServer(tools, info) {
|
|
20
|
+
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
21
|
+
async function handle(message) {
|
|
22
|
+
const isNotification = message.id === undefined;
|
|
23
|
+
if (message.jsonrpc !== "2.0" || typeof message.method !== "string") {
|
|
24
|
+
return isNotification ? null : fail(message.id, ERROR.invalidRequest, "Invalid request");
|
|
25
|
+
}
|
|
26
|
+
switch (message.method) {
|
|
27
|
+
case "initialize": {
|
|
28
|
+
const requested = String(message.params?.protocolVersion ?? "");
|
|
29
|
+
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
|
|
30
|
+
? requested
|
|
31
|
+
: SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
32
|
+
return reply(message.id, {
|
|
33
|
+
protocolVersion,
|
|
34
|
+
capabilities: { tools: { listChanged: false } },
|
|
35
|
+
serverInfo: info,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
case "ping":
|
|
39
|
+
return reply(message.id, {});
|
|
40
|
+
case "tools/list":
|
|
41
|
+
return reply(message.id, {
|
|
42
|
+
tools: tools.map(({ name, description, inputSchema }) => ({
|
|
43
|
+
name,
|
|
44
|
+
description,
|
|
45
|
+
inputSchema,
|
|
46
|
+
})),
|
|
47
|
+
});
|
|
48
|
+
case "tools/call": {
|
|
49
|
+
const name = message.params?.name;
|
|
50
|
+
const tool = typeof name === "string" ? byName.get(name) : undefined;
|
|
51
|
+
if (!tool)
|
|
52
|
+
return fail(message.id, ERROR.invalidParams, `Unknown tool: ${String(name)}`);
|
|
53
|
+
const args = message.params?.arguments;
|
|
54
|
+
if (args !== undefined &&
|
|
55
|
+
(typeof args !== "object" || args === null || Array.isArray(args))) {
|
|
56
|
+
return fail(message.id, ERROR.invalidParams, "arguments must be an object");
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const result = await tool.handler((args ?? {}));
|
|
60
|
+
return reply(message.id, {
|
|
61
|
+
content: [{ type: "text", text: JSON.stringify(result.data) }],
|
|
62
|
+
structuredContent: result.data,
|
|
63
|
+
isError: result.isError,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
// A tool failure is a tool result, not a protocol error, so the model can read and react to it.
|
|
68
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
69
|
+
return reply(message.id, {
|
|
70
|
+
content: [{ type: "text", text }],
|
|
71
|
+
isError: true,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
// Notifications such as notifications/initialized need no reply; unknown requests do.
|
|
77
|
+
return isNotification
|
|
78
|
+
? null
|
|
79
|
+
: fail(message.id, ERROR.methodNotFound, `Method not found: ${message.method}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
async handleLine(line) {
|
|
84
|
+
if (!line.trim())
|
|
85
|
+
return null;
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(line);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return fail(null, ERROR.parse, "Parse error");
|
|
92
|
+
}
|
|
93
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
94
|
+
return fail(null, ERROR.invalidRequest, "Invalid request");
|
|
95
|
+
}
|
|
96
|
+
return handle(parsed);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/** A service or stack name. A leading "-" is rejected so a value can never be read as a flag. */
|
|
6
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
7
|
+
const SINCE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
|
|
8
|
+
/**
|
|
9
|
+
* How to run this CLI again: `node <script>` / `bun <script>` when started from a script, or the executable alone when it is
|
|
10
|
+
* a `bun build --compile` binary, whose argv[1] is a virtual path that exists only inside the binary.
|
|
11
|
+
*/
|
|
12
|
+
export function cliInvocation(execPath = process.execPath, script = process.argv[1], fileExists = existsSync) {
|
|
13
|
+
if (!script || script.startsWith("/$bunfs/") || script.includes("~BUN") || !fileExists(script)) {
|
|
14
|
+
return [execPath];
|
|
15
|
+
}
|
|
16
|
+
return [execPath, script];
|
|
17
|
+
}
|
|
18
|
+
/** Runs this same CLI (`tdk <args>`) so every tool behaves exactly like its command. */
|
|
19
|
+
export const runTdkCli = (args, options = {}) => new Promise((resolve, reject) => {
|
|
20
|
+
const [command, ...prefix] = cliInvocation();
|
|
21
|
+
const child = spawn(command, [...prefix, ...args], {
|
|
22
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
23
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
24
|
+
});
|
|
25
|
+
let stdout = "";
|
|
26
|
+
let stderr = "";
|
|
27
|
+
child.stdout?.on("data", (chunk) => {
|
|
28
|
+
stdout += chunk.toString();
|
|
29
|
+
});
|
|
30
|
+
child.stderr?.on("data", (chunk) => {
|
|
31
|
+
stderr += chunk.toString();
|
|
32
|
+
});
|
|
33
|
+
const timer = setTimeout(() => child.kill("SIGTERM"), options.timeoutMs ?? 120_000);
|
|
34
|
+
child.once("error", (error) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
reject(error);
|
|
37
|
+
});
|
|
38
|
+
child.once("close", (exitCode) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolve({ exitCode, stdout, stderr });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
function names(value, field) {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
return [];
|
|
46
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !NAME.test(item))) {
|
|
47
|
+
throw new Error(`${field} must be an array of service names (letters, digits, ".", "_", "-")`);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function optionalName(value, field) {
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (typeof value !== "string" || !NAME.test(value)) {
|
|
55
|
+
throw new Error(`${field} must be a name (letters, digits, ".", "_", "-")`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function flag(value, field) {
|
|
60
|
+
if (value === undefined)
|
|
61
|
+
return false;
|
|
62
|
+
if (typeof value !== "boolean")
|
|
63
|
+
throw new Error(`${field} must be a boolean`);
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
/** Turn a command's `--json` stdout into a tool result. A parseable envelope with errors, or `ok: false`, is an error result. */
|
|
67
|
+
export function toToolResult(run) {
|
|
68
|
+
const text = run.stdout.trim();
|
|
69
|
+
// The envelope is the last JSON line; anything before it is incidental output.
|
|
70
|
+
const lastLine = text
|
|
71
|
+
.split("\n")
|
|
72
|
+
.filter((line) => line.trim().startsWith("{"))
|
|
73
|
+
.pop();
|
|
74
|
+
if (!lastLine) {
|
|
75
|
+
return {
|
|
76
|
+
data: {
|
|
77
|
+
errors: [
|
|
78
|
+
{ code: "NO_JSON", message: run.stderr.trim() || `tdk exited with code ${run.exitCode}` },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
isError: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const envelope = JSON.parse(lastLine);
|
|
86
|
+
const failed = (envelope.errors?.length ?? 0) > 0 || envelope.data?.ok === false;
|
|
87
|
+
return { data: envelope, isError: failed };
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return {
|
|
91
|
+
data: { errors: [{ code: "BAD_JSON", message: lastLine.slice(0, 200) }] },
|
|
92
|
+
isError: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export const defaultUpDeps = {
|
|
97
|
+
spawnUp(args, logFile) {
|
|
98
|
+
const fd = openSync(logFile, "a");
|
|
99
|
+
const [command, ...prefix] = cliInvocation();
|
|
100
|
+
const child = spawn(command, [...prefix, ...args], {
|
|
101
|
+
detached: true,
|
|
102
|
+
stdio: ["ignore", fd, fd],
|
|
103
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
104
|
+
});
|
|
105
|
+
closeSync(fd);
|
|
106
|
+
return child;
|
|
107
|
+
},
|
|
108
|
+
readLog(logFile) {
|
|
109
|
+
try {
|
|
110
|
+
return readFileSync(logFile, "utf-8");
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
117
|
+
now: () => Date.now(),
|
|
118
|
+
logFile: () => join(tmpdir(), `tdk-mcp-up-${Date.now()}.log`),
|
|
119
|
+
};
|
|
120
|
+
export function buildUpArgs(args) {
|
|
121
|
+
const stack = optionalName(args.stack, "stack");
|
|
122
|
+
const only = names(args.only, "only");
|
|
123
|
+
const out = ["up"];
|
|
124
|
+
if (stack)
|
|
125
|
+
out.push(stack);
|
|
126
|
+
if (only.length > 0)
|
|
127
|
+
out.push(...only.map((name) => `--only=${name}`));
|
|
128
|
+
if (flag(args.force, "force"))
|
|
129
|
+
out.push("--force");
|
|
130
|
+
out.push("--json");
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Starts the stack detached and returns without waiting for it to be ready (a build can take minutes, longer than a tool
|
|
135
|
+
* call may last). It watches only for an early result, which is how a refusal such as UNKNOWN_SERVICE, TILT_ALREADY_RUNNING
|
|
136
|
+
* or an unsupported host arrives; otherwise the caller polls the `status` tool for `data.tilt.readiness`.
|
|
137
|
+
*/
|
|
138
|
+
export async function startUp(args, deps = defaultUpDeps) {
|
|
139
|
+
const argv = buildUpArgs(args);
|
|
140
|
+
const logFile = deps.logFile();
|
|
141
|
+
const child = deps.spawnUp(argv, logFile);
|
|
142
|
+
let exited;
|
|
143
|
+
child.once("exit", (code) => {
|
|
144
|
+
exited = code;
|
|
145
|
+
});
|
|
146
|
+
const waitMs = typeof args.waitSeconds === "number"
|
|
147
|
+
? Math.min(Math.max(args.waitSeconds, 0), 60) * 1000
|
|
148
|
+
: 15_000;
|
|
149
|
+
const deadline = deps.now() + waitMs;
|
|
150
|
+
for (;;) {
|
|
151
|
+
const log = deps.readLog(logFile);
|
|
152
|
+
const line = log
|
|
153
|
+
.split("\n")
|
|
154
|
+
.filter((l) => l.startsWith('{"schemaVersion"'))
|
|
155
|
+
.pop();
|
|
156
|
+
if (line)
|
|
157
|
+
return { ...toToolResult({ exitCode: exited ?? null, stdout: line, stderr: "" }) };
|
|
158
|
+
if (exited !== undefined) {
|
|
159
|
+
return toToolResult({ exitCode: exited, stdout: "", stderr: log.slice(-2000) });
|
|
160
|
+
}
|
|
161
|
+
if (deps.now() >= deadline)
|
|
162
|
+
break;
|
|
163
|
+
await deps.sleep(250);
|
|
164
|
+
}
|
|
165
|
+
child.unref();
|
|
166
|
+
return {
|
|
167
|
+
data: {
|
|
168
|
+
schemaVersion: 1,
|
|
169
|
+
data: {
|
|
170
|
+
ok: true,
|
|
171
|
+
started: true,
|
|
172
|
+
pid: child.pid ?? null,
|
|
173
|
+
logFile,
|
|
174
|
+
next: "Poll the status tool until data.tilt.readiness.ready is true. Call down to stop it.",
|
|
175
|
+
},
|
|
176
|
+
errors: [],
|
|
177
|
+
},
|
|
178
|
+
isError: false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export function buildLogsArgs(args) {
|
|
182
|
+
const out = ["logs", "--json"];
|
|
183
|
+
for (const name of names(args.services, "services"))
|
|
184
|
+
out.push(`--service=${name}`);
|
|
185
|
+
if (args.tail !== undefined) {
|
|
186
|
+
if (!Number.isInteger(args.tail) || args.tail < 1) {
|
|
187
|
+
throw new Error("tail must be a positive integer");
|
|
188
|
+
}
|
|
189
|
+
out.push(`--tail=${args.tail}`);
|
|
190
|
+
}
|
|
191
|
+
if (args.since !== undefined) {
|
|
192
|
+
if (typeof args.since !== "string" || !SINCE.test(args.since)) {
|
|
193
|
+
throw new Error("since must be a duration such as 30s, 5m or 1h");
|
|
194
|
+
}
|
|
195
|
+
out.push(`--since=${args.since}`);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
const OBJECT = (properties) => ({
|
|
200
|
+
type: "object",
|
|
201
|
+
properties,
|
|
202
|
+
additionalProperties: false,
|
|
203
|
+
});
|
|
204
|
+
export function createTdkTools(run = runTdkCli, up = defaultUpDeps) {
|
|
205
|
+
const viaCli = (build) => async (args) => toToolResult(await run(build(args)));
|
|
206
|
+
return [
|
|
207
|
+
{
|
|
208
|
+
name: "doctor",
|
|
209
|
+
description: "Check whether this machine can run the TDK stack. Returns data.ready and data.host.canUp; run it before `up`.",
|
|
210
|
+
inputSchema: OBJECT({
|
|
211
|
+
noPing: { type: "boolean", description: "Skip pinging running services' health endpoints" },
|
|
212
|
+
}),
|
|
213
|
+
handler: viaCli((args) => [
|
|
214
|
+
"doctor",
|
|
215
|
+
"--json",
|
|
216
|
+
...(flag(args.noPing, "noPing") ? ["--no-ping"] : []),
|
|
217
|
+
]),
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "up",
|
|
221
|
+
description: "Start the stack (or part of it) detached and return without waiting for it to be ready. Poll `status` for data.tilt.readiness.ready. Fails fast on an unknown service, an unsupported host, or a Tilt already running.",
|
|
222
|
+
inputSchema: OBJECT({
|
|
223
|
+
stack: { type: "string", description: "Only this stack" },
|
|
224
|
+
only: {
|
|
225
|
+
type: "array",
|
|
226
|
+
items: { type: "string" },
|
|
227
|
+
description: "Start only these services plus what they depend on and shared infrastructure",
|
|
228
|
+
},
|
|
229
|
+
force: { type: "boolean", description: "Replace a Tilt that is already running" },
|
|
230
|
+
waitSeconds: {
|
|
231
|
+
type: "number",
|
|
232
|
+
description: "How long to watch for an early failure before returning (default 15, max 60)",
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
handler: (args) => startUp(args, up),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "down",
|
|
239
|
+
description: "Stop the running stack.",
|
|
240
|
+
inputSchema: OBJECT({}),
|
|
241
|
+
handler: viaCli(() => ["down", "--json"]),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "status",
|
|
245
|
+
description: "Stacks, resources, their ingress URLs and container ports, the stack-level ports, and Tilt readiness (data.tilt.readiness: ready, pending, failures, enabled; null when no Tilt answers).",
|
|
246
|
+
inputSchema: OBJECT({}),
|
|
247
|
+
handler: viaCli(() => ["status", "--json"]),
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: "logs",
|
|
251
|
+
description: "A bounded snapshot of recent logs from the running stack (default the last 200 lines, at most 10000). Not a stream.",
|
|
252
|
+
inputSchema: OBJECT({
|
|
253
|
+
services: { type: "array", items: { type: "string" }, description: "Only these services" },
|
|
254
|
+
tail: { type: "integer", minimum: 1, maximum: 10000 },
|
|
255
|
+
since: {
|
|
256
|
+
type: "string",
|
|
257
|
+
description: "Only logs newer than this, for example 30s, 5m, 1h",
|
|
258
|
+
},
|
|
259
|
+
}),
|
|
260
|
+
handler: viaCli(buildLogsArgs),
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: "resource_list",
|
|
264
|
+
description: "List the project's services (resources) with their stack, type and port.",
|
|
265
|
+
inputSchema: OBJECT({ stack: { type: "string", description: "Only this stack" } }),
|
|
266
|
+
handler: viaCli((args) => {
|
|
267
|
+
const stack = optionalName(args.stack, "stack");
|
|
268
|
+
return ["resources", "--json", ...(stack ? [`--stack=${stack}`] : [])];
|
|
269
|
+
}),
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
//# sourceMappingURL=tools.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
export const WEBCONTAINER_DOCS = "docs/agent-hosts.md";
|
|
3
|
+
export const WEBCONTAINER_UP_MESSAGE = `WebContainers cannot run TDK: there is no Docker daemon or Tilt. Run \`tdk up\` on a machine with Docker. See ${WEBCONTAINER_DOCS}.`;
|
|
4
|
+
export const DEVCONTAINER_DOCKER_FIX = `Docker is not reachable from this Dev Container. Mount the host Docker socket (-v /var/run/docker.sock:/var/run/docker.sock) or add the docker-outside-of-docker feature to devcontainer.json, then retry. Docker-in-docker is not supported. See ${WEBCONTAINER_DOCS}.`;
|
|
5
|
+
function defaultInputs() {
|
|
6
|
+
return {
|
|
7
|
+
env: process.env,
|
|
8
|
+
versions: process.versions,
|
|
9
|
+
platform: process.platform,
|
|
10
|
+
fileExists: existsSync,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Classify where the CLI is running. Informational: Docker reachability is still decided by the runtime checks. */
|
|
14
|
+
export function detectHost(inputs = defaultInputs()) {
|
|
15
|
+
const { env, versions, platform, fileExists } = inputs;
|
|
16
|
+
if (versions.webcontainer) {
|
|
17
|
+
return { kind: "webcontainer", canUp: false };
|
|
18
|
+
}
|
|
19
|
+
if (env.CODESPACES === "true")
|
|
20
|
+
return { kind: "codespaces", canUp: true };
|
|
21
|
+
if (env.REMOTE_CONTAINERS || (fileExists("/.dockerenv") && env.DEVCONTAINER)) {
|
|
22
|
+
return { kind: "devcontainer", canUp: true };
|
|
23
|
+
}
|
|
24
|
+
// Mirrors nativeWindowsUpRefusal in commands/up.ts: native Windows is inspect-only unless explicitly overridden.
|
|
25
|
+
if (platform === "win32") {
|
|
26
|
+
return { kind: "native-windows", canUp: env.TDK_ALLOW_NATIVE_WINDOWS === "1" };
|
|
27
|
+
}
|
|
28
|
+
if (platform === "linux" && env.WSL_DISTRO_NAME)
|
|
29
|
+
return { kind: "wsl2", canUp: true };
|
|
30
|
+
return { kind: "local", canUp: true };
|
|
31
|
+
}
|
|
32
|
+
export function isContainerHost(kind) {
|
|
33
|
+
return kind === "devcontainer" || kind === "codespaces";
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=agent-host.js.map
|