@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.
@@ -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, discoverStacks, getResourcesForStack, stackExists, } from "../utils/services.js";
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
- if (!stackExists(stackName)) {
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 = discoverResources();
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 dryRunCommand = stackName
149
- ? `tilt up -- --focus=${stackName} ${focusServiceNames.join(" ")}`
150
- : "tilt up";
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