@tdk-landscape/tdk-cli-core 1.3.105 → 1.3.107
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/completion.js +17 -1
- package/cli/dist/commands/doctor.js +2 -1
- 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 +18 -0
- package/cli/dist/commands/ui.js +14 -21
- package/cli/dist/commands/up.js +102 -34
- package/cli/dist/mcp/server.js +100 -0
- package/cli/dist/mcp/tools.js +273 -0
- package/cli/dist/utils/discovery-context.js +9 -3
- package/cli/dist/utils/doctor-wiring.js +88 -0
- package/cli/dist/utils/services.js +65 -7
- package/cli/dist/utils/status-message.js +22 -0
- package/cli/dist/utils/tilt-logs.js +62 -0
- package/cli/dist/utils/tilt-startup.js +35 -0
- package/cli/dist/utils/up-only.js +29 -0
- package/cli/dist/utils/up-readiness.js +82 -15
- package/cli/package.json +1 -1
- package/package.json +1 -1
package/cli/dist/commands/up.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Command } from "commander";
|
|
|
5
5
|
import { ensureProjectRuntimeAssets } from "../generator/template-engine.js";
|
|
6
6
|
import { DEVCONTAINER_DOCKER_FIX, detectHost, isContainerHost, WEBCONTAINER_UP_MESSAGE, } from "../utils/agent-host.js";
|
|
7
7
|
import { handleDryRun } from "../utils/command-helpers.js";
|
|
8
|
+
import { getDeferredResourceNames } from "../utils/doctor-runtime.js";
|
|
8
9
|
import { completeEnvFile } from "../utils/env-validator.js";
|
|
9
10
|
import { errorFactories, handleTiltFailure, requireProjectRoot, runCommand, showErrorAndExit, withTiltCheck, } from "../utils/errors.js";
|
|
10
11
|
import { formatCount } from "../utils/formatting.js";
|
|
@@ -15,11 +16,13 @@ import { findProjectRoot } from "../utils/paths.js";
|
|
|
15
16
|
import { findAvailablePort } from "../utils/port-assignment.js";
|
|
16
17
|
import { isApiServiceType } from "../utils/resource-kind.js";
|
|
17
18
|
import { appendHealthPath, resolveSubdomainBases } from "../utils/service-urls.js";
|
|
18
|
-
import { discoverResources,
|
|
19
|
+
import { discoverResources, discoverResourcesStrict, discoverStacks, stackExists, } from "../utils/services.js";
|
|
19
20
|
import { buildSmokePlans, formatSmokeFailure, runSmokePlans } from "../utils/smoke.js";
|
|
20
21
|
import { buildTiltUpArgs, runTilt } from "../utils/tilt.js";
|
|
21
22
|
import { stopTiltOnPort } from "../utils/tilt-process.js";
|
|
22
|
-
import {
|
|
23
|
+
import { parseTiltPort, resolveTiltPort, stopTiltForUp } from "../utils/tilt-startup.js";
|
|
24
|
+
import { findUnknownServices, resolveOnlySelection } from "../utils/up-only.js";
|
|
25
|
+
import { tiltGetUiResources, waitForTiltResourcesReady } from "../utils/up-readiness.js";
|
|
23
26
|
import { enableDiscoveredStacks } from "./project.js";
|
|
24
27
|
export function formatUpSuccess(port, appUrls = []) {
|
|
25
28
|
return [
|
|
@@ -81,11 +84,18 @@ export const upCommand = new Command("up")
|
|
|
81
84
|
.option("-q, --quiet", "Suppress non-essential output", false)
|
|
82
85
|
.option("--dry-run", "Show what would be started without starting", false)
|
|
83
86
|
.option("-f, --force", "Kill existing Tilt process before starting", false)
|
|
87
|
+
.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")
|
|
84
88
|
.option("--json", "Print one JSON object on stdout when the stack is ready or the command fails; implies --quiet", false)
|
|
85
89
|
.action(async (stackName, options) => {
|
|
86
90
|
const emit = options.json ? createJsonEmitter("UP_FAILED", "tdk up") : undefined;
|
|
87
91
|
if (options.json)
|
|
88
92
|
options.quiet = true;
|
|
93
|
+
const portSetting = parseTiltPort(process.env.TILT_PORT);
|
|
94
|
+
if (!portSetting.ok) {
|
|
95
|
+
emit?.({ ok: false }, [{ code: "INVALID_TILT_PORT", message: portSetting.message }]);
|
|
96
|
+
showErrorAndExit(portSetting.message);
|
|
97
|
+
}
|
|
98
|
+
const configuredTiltPort = portSetting.port;
|
|
89
99
|
const hostRefusal = options.dryRun ? null : await hostUpRefusal();
|
|
90
100
|
if (hostRefusal) {
|
|
91
101
|
emit?.({ ok: false }, [{ code: "HOST_UNSUPPORTED", message: hostRefusal }]);
|
|
@@ -102,6 +112,21 @@ export const upCommand = new Command("up")
|
|
|
102
112
|
const projectRoot = options.dryRun
|
|
103
113
|
? requireProjectRoot()
|
|
104
114
|
: (findProjectRoot() ?? process.cwd());
|
|
115
|
+
const discoveredResources = discoverResourcesStrict();
|
|
116
|
+
// Reject a bad request before anything below can write to the project (.env, runtime assets, .tdk/project.json).
|
|
117
|
+
if (options.only) {
|
|
118
|
+
if (stackName && !stackExists(stackName))
|
|
119
|
+
errorFactories.stackNotFound(stackName).exit();
|
|
120
|
+
const candidates = stackName
|
|
121
|
+
? discoveredResources.filter((resource) => resource.stack === stackName)
|
|
122
|
+
: discoveredResources;
|
|
123
|
+
const unknown = findUnknownServices(options.only, candidates);
|
|
124
|
+
if (unknown.length > 0) {
|
|
125
|
+
const message = `Unknown service ${unknown.join(", ")}. Valid names: ${candidates.map((s) => s.name).join(", ")}`;
|
|
126
|
+
emit?.({ ok: false }, [{ code: "UNKNOWN_SERVICE", message }]);
|
|
127
|
+
showErrorAndExit(message, 2);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
105
130
|
const hostPortPlan = await getHostPortPlan(projectRoot, {
|
|
106
131
|
inspectDocker: !options.dryRun,
|
|
107
132
|
});
|
|
@@ -127,18 +152,27 @@ export const upCommand = new Command("up")
|
|
|
127
152
|
let stackDescription;
|
|
128
153
|
let focusServiceNames = [];
|
|
129
154
|
if (stackName) {
|
|
130
|
-
|
|
155
|
+
servicesToStart = discoveredResources.filter((resource) => resource.stack === stackName);
|
|
156
|
+
if (servicesToStart.length === 0) {
|
|
131
157
|
errorFactories.stackNotFound(stackName).exit();
|
|
132
158
|
}
|
|
133
|
-
servicesToStart = getResourcesForStack(stackName);
|
|
134
159
|
focusServiceNames = servicesToStart.map((s) => s.name);
|
|
135
160
|
stackDescription = `stack "${stackName}"`;
|
|
136
161
|
}
|
|
137
162
|
else {
|
|
138
|
-
servicesToStart =
|
|
139
|
-
const allStacks = discoverStacks();
|
|
163
|
+
servicesToStart = discoveredResources;
|
|
164
|
+
const allStacks = discoverStacks(discoveredResources);
|
|
140
165
|
stackDescription = `all stacks (${formatCount(allStacks.length, "stack")}, ${formatCount(servicesToStart.length, "service")})`;
|
|
141
166
|
}
|
|
167
|
+
let dependencyNames = [];
|
|
168
|
+
if (options.only) {
|
|
169
|
+
// Dependencies come from the whole project, not just the named stack: dependsOn may cross stacks.
|
|
170
|
+
const selection = resolveOnlySelection(options.only, discoverResources());
|
|
171
|
+
servicesToStart = selection.selected;
|
|
172
|
+
dependencyNames = selection.dependencies;
|
|
173
|
+
focusServiceNames = servicesToStart.map((s) => s.name);
|
|
174
|
+
stackDescription = `${formatCount(options.only.length, "requested service")}${dependencyNames.length > 0 ? ` plus ${dependencyNames.join(", ")}` : ""}`;
|
|
175
|
+
}
|
|
142
176
|
if (options.verbose && !options.quiet) {
|
|
143
177
|
console.log(chalk.gray(`Found ${formatCount(servicesToStart.length, "service")} in ${stackDescription}`));
|
|
144
178
|
}
|
|
@@ -171,9 +205,14 @@ export const upCommand = new Command("up")
|
|
|
171
205
|
});
|
|
172
206
|
}
|
|
173
207
|
}
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
:
|
|
208
|
+
const focusTargets = options.only ?? (stackName ? [stackName] : undefined);
|
|
209
|
+
const tiltArgs = buildTiltUpArgs(focusServiceNames, {
|
|
210
|
+
verbose: options.verbose,
|
|
211
|
+
quiet: options.quiet,
|
|
212
|
+
force: options.force,
|
|
213
|
+
focusTargets,
|
|
214
|
+
});
|
|
215
|
+
const dryRunCommand = focusTargets ? `tilt up ${tiltArgs.join(" ")}` : "tilt up";
|
|
177
216
|
if (!options.quiet) {
|
|
178
217
|
console.log(chalk.blue(formatHostPortPlan(hostPortPlan)));
|
|
179
218
|
console.log(chalk.gray("Override with TDK_HTTP_PORT, TDK_HTTPS_PORT, or TDK_POSTGRES_PORT."));
|
|
@@ -184,6 +223,9 @@ export const upCommand = new Command("up")
|
|
|
184
223
|
dryRun: true,
|
|
185
224
|
stack: stackName ?? null,
|
|
186
225
|
services: serviceNames,
|
|
226
|
+
...(options.only
|
|
227
|
+
? { requested: options.only, declaredDependencies: dependencyNames }
|
|
228
|
+
: {}),
|
|
187
229
|
command: dryRunCommand,
|
|
188
230
|
});
|
|
189
231
|
return;
|
|
@@ -194,33 +236,42 @@ export const upCommand = new Command("up")
|
|
|
194
236
|
exportHostPortPlan(hostPortPlan);
|
|
195
237
|
writeSavedHostPortPlan(projectRoot, hostPortPlan);
|
|
196
238
|
const basePort = 10350;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
239
|
+
// A second Tilt would apply a different selection to the same containers, whatever port it listens on, so `--only`
|
|
240
|
+
// looks for a Tilt that answers on the default port and on TILT_PORT before it chooses one. A listener that is not
|
|
241
|
+
// Tilt does not count: the port is then picked below as usual.
|
|
242
|
+
if (options.only) {
|
|
243
|
+
const candidatePorts = [
|
|
244
|
+
...new Set([basePort, ...(configuredTiltPort === undefined ? [] : [configuredTiltPort])]),
|
|
245
|
+
];
|
|
246
|
+
const running = [];
|
|
247
|
+
for (const candidate of candidatePorts) {
|
|
248
|
+
if ((await tiltGetUiResources(candidate)) !== null)
|
|
249
|
+
running.push(candidate);
|
|
209
250
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
console.log(chalk.yellow(`Force flag set - stopping Tilt on port ${port} if running...`));
|
|
251
|
+
if (running.length > 0 && !options.force) {
|
|
252
|
+
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.`;
|
|
253
|
+
emit?.({ ok: false }, [{ code: "TILT_ALREADY_RUNNING", message }]);
|
|
254
|
+
showErrorAndExit(message);
|
|
215
255
|
}
|
|
216
|
-
|
|
217
|
-
|
|
256
|
+
for (const runningPort of running)
|
|
257
|
+
stopTiltOnPort(runningPort);
|
|
218
258
|
}
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
quiet: options.quiet,
|
|
259
|
+
const resolution = await resolveTiltPort({
|
|
260
|
+
configuredPort: configuredTiltPort,
|
|
222
261
|
force: options.force,
|
|
223
|
-
|
|
262
|
+
basePort,
|
|
263
|
+
findAvailablePort,
|
|
264
|
+
});
|
|
265
|
+
const port = resolution.port;
|
|
266
|
+
if (resolution.autoSwitched && !options.quiet) {
|
|
267
|
+
console.log(chalk.yellow(`⚠️ Port ${basePort} is already in use`));
|
|
268
|
+
console.log(chalk.blue(`🔄 Auto-switching to port ${port}\n`));
|
|
269
|
+
}
|
|
270
|
+
process.env.TILT_PORT = port.toString();
|
|
271
|
+
await stopTiltForUp({ force: options.force, quiet: options.quiet, port }, {
|
|
272
|
+
stop: stopTiltOnPort,
|
|
273
|
+
log: (message) => console.log(chalk.yellow(message)),
|
|
274
|
+
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
224
275
|
});
|
|
225
276
|
if (!options.quiet) {
|
|
226
277
|
console.log(chalk.gray("\nRunning tilt up..."));
|
|
@@ -240,10 +291,26 @@ export const upCommand = new Command("up")
|
|
|
240
291
|
const uiReady = await waitForTiltUi(port);
|
|
241
292
|
// JSON consumers get success only after every non-deferred resource is built and running, not when the UI port opens.
|
|
242
293
|
let jsonReady = false;
|
|
294
|
+
let startedServices = serviceNames;
|
|
295
|
+
let startedDependencies = dependencyNames;
|
|
243
296
|
if (uiReady && emit) {
|
|
244
|
-
const
|
|
297
|
+
const deferred = getDeferredResourceNames();
|
|
298
|
+
const readiness = await waitForTiltResourcesReady(port, {
|
|
299
|
+
deferred,
|
|
300
|
+
// Only what the caller named must be enabled. Dependencies are the Tiltfile's call (it skips some on purpose).
|
|
301
|
+
expected: options.only
|
|
302
|
+
? options.only.filter((name) => !deferred.has(name))
|
|
303
|
+
: undefined,
|
|
304
|
+
});
|
|
245
305
|
if (readiness.ready) {
|
|
246
306
|
jsonReady = true;
|
|
307
|
+
if (options.only) {
|
|
308
|
+
// Report what Tilt actually enabled, not the service.json closure, which can differ from the Tiltfile's expansion.
|
|
309
|
+
const enabled = new Set(readiness.enabled);
|
|
310
|
+
const allNames = discoverResources().map((r) => r.name);
|
|
311
|
+
startedServices = allNames.filter((name) => enabled.has(name));
|
|
312
|
+
startedDependencies = startedServices.filter((name) => !options.only.includes(name));
|
|
313
|
+
}
|
|
247
314
|
}
|
|
248
315
|
else {
|
|
249
316
|
const detail = readiness.failures.map((f) => `${f.name}: ${f.message}`).join("; ");
|
|
@@ -288,7 +355,8 @@ export const upCommand = new Command("up")
|
|
|
288
355
|
emit?.({
|
|
289
356
|
ok: true,
|
|
290
357
|
stack: stackName ?? null,
|
|
291
|
-
services:
|
|
358
|
+
services: startedServices,
|
|
359
|
+
...(options.only ? { requested: options.only, dependencies: startedDependencies } : {}),
|
|
292
360
|
tiltUrl: `http://localhost:${port}`,
|
|
293
361
|
});
|
|
294
362
|
}
|
|
@@ -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
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createCacheValidator } from "./cache.js";
|
|
2
|
-
import {
|
|
2
|
+
import { findProjectRoot } from "./paths.js";
|
|
3
|
+
import { discoverResources, discoverResourcesWithProblems, discoverStacks, getAllStacks, warnAboutSkippedFiles, } from "./services.js";
|
|
3
4
|
let cachedContext = null;
|
|
4
5
|
const cacheValidator = createCacheValidator(1000);
|
|
5
6
|
export function clearDiscoveryCache() {
|
|
@@ -13,9 +14,13 @@ export function createDiscoveryContext(forceRefresh = false) {
|
|
|
13
14
|
if (!forceRefresh && isCacheValid() && cachedContext) {
|
|
14
15
|
return cachedContext;
|
|
15
16
|
}
|
|
16
|
-
const
|
|
17
|
-
const
|
|
17
|
+
const projectRoot = findProjectRoot();
|
|
18
|
+
const { resources, problems } = projectRoot
|
|
19
|
+
? discoverResourcesWithProblems(projectRoot)
|
|
20
|
+
: { resources: discoverResources(), problems: [] };
|
|
21
|
+
const stacks = discoverStacks(resources);
|
|
18
22
|
const stackNames = getAllStacks(resources);
|
|
23
|
+
warnAboutSkippedFiles(problems);
|
|
19
24
|
const unassignedResources = resources.filter((r) => !r.stack);
|
|
20
25
|
const resourcesByStack = new Map();
|
|
21
26
|
for (const resource of resources) {
|
|
@@ -27,6 +32,7 @@ export function createDiscoveryContext(forceRefresh = false) {
|
|
|
27
32
|
}
|
|
28
33
|
const context = {
|
|
29
34
|
resources,
|
|
35
|
+
problems,
|
|
30
36
|
stacks,
|
|
31
37
|
stackNames,
|
|
32
38
|
unassignedResources,
|