@tdk-landscape/tdk-cli-core 1.3.105 → 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 +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/up.js +84 -17
- 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/tilt-logs.js +62 -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
|
@@ -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,
|
|
@@ -165,6 +165,94 @@ export function checkFrontendBackendUrls(projectRoot = findProjectRoot() ?? proc
|
|
|
165
165
|
fix: `Backends are only reachable through Traefik: http://api.${project}.localhost/api/<name without -api>/... (for example catalog-api is /api/catalog). Use that URL, or the generated .autogenerated/env.ts`,
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
|
+
/** The common ways to apply schema changes from a command line: Prisma, Drizzle, Knex, TypeORM, MikroORM, Sequelize, dbmate, Atlas, Alembic. */
|
|
169
|
+
const MIGRATION_COMMAND = /\b(?:prisma\s+(?:migrate\s+(?:deploy|dev|resolve)|db\s+push)|drizzle-kit\s+(?:migrate|push)|knex\s+migrate:\w+|typeorm\s+migration:run|mikro-orm\s+migration:up|sequelize(?:-cli)?\s+db:migrate|dbmate\s+(?:up|migrate)|atlas\s+migrate\s+apply|alembic\s+upgrade)\b/;
|
|
170
|
+
const START_SCRIPTS = new Set(["start", "dev", "serve", "prestart", "predev", "preserve"]);
|
|
171
|
+
const SHELL_ENTRYPOINT = /\.(?:sh|bash)$/;
|
|
172
|
+
const DOCKERFILE_START = /^\s*(?:CMD|ENTRYPOINT)\b/i;
|
|
173
|
+
function readText(path) {
|
|
174
|
+
try {
|
|
175
|
+
return readFileSync(path, "utf-8");
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function startScriptMigrations(resourceDir) {
|
|
182
|
+
const text = readText(join(resourceDir, "package.json"));
|
|
183
|
+
if (text === undefined)
|
|
184
|
+
return [];
|
|
185
|
+
let scripts = {};
|
|
186
|
+
try {
|
|
187
|
+
scripts = JSON.parse(text)?.scripts ?? {};
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
return Object.entries(scripts)
|
|
193
|
+
.filter(([name, command]) => START_SCRIPTS.has(name) && typeof command === "string" && MIGRATION_COMMAND.test(command))
|
|
194
|
+
.map(([name]) => `package.json "${name}" script`);
|
|
195
|
+
}
|
|
196
|
+
function fileMigrations(path, onlyStartLines) {
|
|
197
|
+
const text = readText(path);
|
|
198
|
+
if (text === undefined)
|
|
199
|
+
return false;
|
|
200
|
+
return text.split("\n").some((line) =>
|
|
201
|
+
// `["prisma", "migrate", "deploy"]` is the same command as `prisma migrate deploy`.
|
|
202
|
+
MIGRATION_COMMAND.test(line.replace(/["'`,[\]]+/g, " ")) &&
|
|
203
|
+
!COMMENT_LINE.test(line) &&
|
|
204
|
+
(!onlyStartLines || DOCKERFILE_START.test(line)));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A backend or worker that applies migrations from its own start-up (a `start` script, a Dockerfile CMD,
|
|
208
|
+
* an entrypoint script or its source) races its siblings and runs again on every restart. TDK has a
|
|
209
|
+
* `migrator` app type for this: it runs once, before the services that depend on it. This is a warning,
|
|
210
|
+
* not a failure: such services work, and the opt-in Infisical entrypoint is one of them.
|
|
211
|
+
*/
|
|
212
|
+
export function checkMigrationsInApi(projectRoot = findProjectRoot() ?? process.cwd()) {
|
|
213
|
+
const findings = [];
|
|
214
|
+
for (const resource of discoverResourcesFromRoot(projectRoot)) {
|
|
215
|
+
const appType = resource.config?.appType ?? resource.type;
|
|
216
|
+
if (appType !== "backend" && appType !== "worker")
|
|
217
|
+
continue;
|
|
218
|
+
const dir = resource.path;
|
|
219
|
+
for (const finding of startScriptMigrations(dir))
|
|
220
|
+
findings.push(`${resource.name}: ${finding}`);
|
|
221
|
+
const dockerfile = join(dir, resource.config?.dockerfile ?? "Dockerfile");
|
|
222
|
+
if (fileMigrations(dockerfile, true)) {
|
|
223
|
+
findings.push(`${resource.name}: ${relative(projectRoot, dockerfile)} CMD or ENTRYPOINT`);
|
|
224
|
+
}
|
|
225
|
+
let entries = [];
|
|
226
|
+
try {
|
|
227
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
228
|
+
}
|
|
229
|
+
catch { }
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
const path = join(dir, entry.name);
|
|
232
|
+
if (entry.isFile() && SHELL_ENTRYPOINT.test(entry.name) && fileMigrations(path, false)) {
|
|
233
|
+
findings.push(`${resource.name}: ${relative(projectRoot, path)}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const path of listSourceFiles(join(dir, "src"))) {
|
|
237
|
+
if (fileMigrations(path, false))
|
|
238
|
+
findings.push(`${resource.name}: ${relative(projectRoot, path)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (findings.length === 0) {
|
|
242
|
+
return {
|
|
243
|
+
name: "Migrations outside the API",
|
|
244
|
+
didPass: true,
|
|
245
|
+
message: "No backend or worker runs a migration command from its own start-up",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
name: "Migrations outside the API",
|
|
250
|
+
didPass: false,
|
|
251
|
+
isWarning: true,
|
|
252
|
+
message: `${formatCount(findings.length, "migration command")} run from a backend or worker's own start-up:\n ${findings.join("\n ")}`,
|
|
253
|
+
fix: "Move the migration into a `migrator` resource (appType migrator) that the service depends on, so it runs once before the service starts instead of on every start and restart",
|
|
254
|
+
};
|
|
255
|
+
}
|
|
168
256
|
export function checkNatsBroker(projectRoot = findProjectRoot() ?? process.cwd()) {
|
|
169
257
|
const users = discoverResourcesFromRoot(projectRoot)
|
|
170
258
|
.filter((resource) => resource.config?.featuresEnabled?.includes("nats"))
|
|
@@ -48,11 +48,34 @@ const printedWarnings = new Set();
|
|
|
48
48
|
export function resetPrintedServiceWarnings() {
|
|
49
49
|
printedWarnings.clear();
|
|
50
50
|
}
|
|
51
|
+
class InvalidServiceJsonError extends Error {
|
|
52
|
+
reason;
|
|
53
|
+
constructor(reason) {
|
|
54
|
+
super(reason);
|
|
55
|
+
this.reason = reason;
|
|
56
|
+
this.name = "InvalidServiceJsonError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function errorMessage(error) {
|
|
60
|
+
return error instanceof Error ? error.message : String(error);
|
|
61
|
+
}
|
|
51
62
|
function parseResource(serviceJsonPath) {
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
let content;
|
|
64
|
+
try {
|
|
65
|
+
content = readFileSync(serviceJsonPath, "utf-8");
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new InvalidServiceJsonError(`could not read file: ${errorMessage(error)}`);
|
|
69
|
+
}
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(content);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw new InvalidServiceJsonError(`invalid JSON: ${errorMessage(error)}`);
|
|
76
|
+
}
|
|
54
77
|
if (!isValidResourceConfig(parsed)) {
|
|
55
|
-
throw new
|
|
78
|
+
throw new InvalidServiceJsonError("missing required fields (appName, runtime or appType)");
|
|
56
79
|
}
|
|
57
80
|
const parsedConfig = parsed;
|
|
58
81
|
for (const warning of validateServiceManifest(parsed, serviceJsonPath).warnings) {
|
|
@@ -76,12 +99,35 @@ function parseResource(serviceJsonPath) {
|
|
|
76
99
|
stack: config.stack,
|
|
77
100
|
};
|
|
78
101
|
}
|
|
79
|
-
export function
|
|
102
|
+
export function discoverResourcesWithProblems(projectRoot) {
|
|
80
103
|
const serviceJsonPaths = findServiceJsonFiles(projectRoot);
|
|
81
104
|
const resources = [];
|
|
105
|
+
const problems = [];
|
|
82
106
|
for (const path of serviceJsonPaths) {
|
|
83
|
-
|
|
107
|
+
try {
|
|
108
|
+
resources.push(parseResource(path));
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
problems.push({
|
|
112
|
+
path,
|
|
113
|
+
reason: error instanceof InvalidServiceJsonError ? error.reason : errorMessage(error),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { resources, problems };
|
|
118
|
+
}
|
|
119
|
+
export function warnAboutSkippedFiles(problems) {
|
|
120
|
+
for (const problem of problems) {
|
|
121
|
+
const warning = `skipped ${problem.path}: ${problem.reason}`;
|
|
122
|
+
if (printedWarnings.has(warning))
|
|
123
|
+
continue;
|
|
124
|
+
printedWarnings.add(warning);
|
|
125
|
+
console.warn(`⚠️ ${warning}`);
|
|
84
126
|
}
|
|
127
|
+
}
|
|
128
|
+
export function discoverResourcesFromRoot(projectRoot) {
|
|
129
|
+
const { resources, problems } = discoverResourcesWithProblems(projectRoot);
|
|
130
|
+
warnAboutSkippedFiles(problems);
|
|
85
131
|
return resources;
|
|
86
132
|
}
|
|
87
133
|
export function discoverResources() {
|
|
@@ -91,6 +137,19 @@ export function discoverResources() {
|
|
|
91
137
|
}
|
|
92
138
|
return discoverResourcesFromRoot(projectRoot);
|
|
93
139
|
}
|
|
140
|
+
export function discoverResourcesStrict() {
|
|
141
|
+
const projectRoot = findProjectRoot();
|
|
142
|
+
if (!projectRoot) {
|
|
143
|
+
throw new Error("Could not find project root (no .tdk/project.json found). Run `tdk project --yes` first.");
|
|
144
|
+
}
|
|
145
|
+
const { resources, problems } = discoverResourcesWithProblems(projectRoot);
|
|
146
|
+
if (problems.length > 0) {
|
|
147
|
+
throw new Error(problems
|
|
148
|
+
.map((problem) => `Invalid service.json at ${problem.path}: ${problem.reason}`)
|
|
149
|
+
.join("\n"));
|
|
150
|
+
}
|
|
151
|
+
return resources;
|
|
152
|
+
}
|
|
94
153
|
/**
|
|
95
154
|
* Unique, sorted `stack` values across discovered service.json files under `projectRoot`.
|
|
96
155
|
* Tilt's discovery groups services by this same field (see tdk-cli-extensions
|
|
@@ -117,8 +176,7 @@ export function getAllStacks(resources) {
|
|
|
117
176
|
}
|
|
118
177
|
return Array.from(stacks).sort();
|
|
119
178
|
}
|
|
120
|
-
export function discoverStacks() {
|
|
121
|
-
const resources = discoverResources();
|
|
179
|
+
export function discoverStacks(resources = discoverResources()) {
|
|
122
180
|
const stackMap = new Map();
|
|
123
181
|
for (const resource of resources) {
|
|
124
182
|
if (resource.stack) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const DEFAULT_LOG_TAIL = 200;
|
|
2
|
+
/** Upper bound for --tail so a snapshot stays small enough for an agent to read. */
|
|
3
|
+
export const MAX_LOG_TAIL = 10_000;
|
|
4
|
+
const NANOSECONDS = {
|
|
5
|
+
ns: 1,
|
|
6
|
+
us: 1e3,
|
|
7
|
+
µs: 1e3,
|
|
8
|
+
ms: 1e6,
|
|
9
|
+
s: 1e9,
|
|
10
|
+
m: 6e10,
|
|
11
|
+
h: 3.6e12,
|
|
12
|
+
};
|
|
13
|
+
// Go's time.Duration is an int64 of nanoseconds, so anything beyond this is rejected by `tilt logs`.
|
|
14
|
+
const MAX_DURATION_NANOSECONDS = 2 ** 63 - 1;
|
|
15
|
+
/**
|
|
16
|
+
* Go-style duration such as 30s, 5m, 1h or 1h30m, within roughly the range Go's ParseDuration accepts.
|
|
17
|
+
* The sum is a JS number, so values within a few nanoseconds of the int64 limit can disagree with Go; this only needs to
|
|
18
|
+
* reject absurd input, and Tilt remains the final judge (its rejection is reported as TILT_LOGS_FAILED).
|
|
19
|
+
*/
|
|
20
|
+
export function isValidSince(value) {
|
|
21
|
+
if (!/^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/.test(value))
|
|
22
|
+
return false;
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (const [, amount, unit] of value.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) {
|
|
25
|
+
total += Number(amount) * (NANOSECONDS[unit] ?? 0);
|
|
26
|
+
}
|
|
27
|
+
return total <= MAX_DURATION_NANOSECONDS;
|
|
28
|
+
}
|
|
29
|
+
export function isValidPort(value) {
|
|
30
|
+
return /^[1-9]\d{0,4}$/.test(value) && Number(value) <= 65535;
|
|
31
|
+
}
|
|
32
|
+
export function isValidTail(value) {
|
|
33
|
+
return /^[1-9]\d*$/.test(value) && Number(value) <= MAX_LOG_TAIL;
|
|
34
|
+
}
|
|
35
|
+
/** Parse `tilt logs --json` (JSON Lines) into normalized lines, keeping at most the last `limit`. Unparseable lines are skipped. */
|
|
36
|
+
export function parseTiltLogLines(output, limit) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
for (const raw of output.split("\n")) {
|
|
39
|
+
if (!raw.trim())
|
|
40
|
+
continue;
|
|
41
|
+
try {
|
|
42
|
+
const entry = JSON.parse(raw);
|
|
43
|
+
const str = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
44
|
+
lines.push({
|
|
45
|
+
time: str(entry.time),
|
|
46
|
+
resource: str(entry.resource),
|
|
47
|
+
level: str(entry.level),
|
|
48
|
+
source: str(entry.source),
|
|
49
|
+
text: typeof entry.message === "string" ? entry.message : "",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Tilt can print a non-JSON notice before the stream; it is not a log line.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return lines.slice(-limit);
|
|
57
|
+
}
|
|
58
|
+
/** True when `tilt logs` failed because no Tilt server answered, as opposed to rejecting its arguments. */
|
|
59
|
+
export function isTiltConnectionFailure(stderr) {
|
|
60
|
+
return /connection refused|connecting to Tilt|no such host|dial tcp|websocket_token/i.test(stderr);
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=tilt-logs.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Requested names that match no discovered service (the Tiltfile silently enables everything for an unknown focus name). */
|
|
2
|
+
export function findUnknownServices(requested, candidates) {
|
|
3
|
+
const known = new Set(candidates.map((resource) => resource.name));
|
|
4
|
+
return requested.filter((name) => !known.has(name));
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The requested services plus their `dependsOn` closure. This mirrors what the generated Tiltfile enables for
|
|
8
|
+
* `--focus=<service>` (see get_all_needed_services in engine/topologies/tilt/config/profiles.star) so the CLI can report
|
|
9
|
+
* the same set. Shared infrastructure (Postgres, Traefik, the golden images) is always enabled by the Tiltfile and is not listed here.
|
|
10
|
+
* A dependsOn entry that is not a discovered service is ignored.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveOnlySelection(requested, all) {
|
|
13
|
+
const byName = new Map(all.map((resource) => [resource.name, resource]));
|
|
14
|
+
const wanted = new Set();
|
|
15
|
+
const visit = (name) => {
|
|
16
|
+
if (wanted.has(name) || !byName.has(name))
|
|
17
|
+
return;
|
|
18
|
+
wanted.add(name);
|
|
19
|
+
for (const dep of byName.get(name)?.config?.dependsOn ?? [])
|
|
20
|
+
visit(dep);
|
|
21
|
+
};
|
|
22
|
+
for (const name of requested)
|
|
23
|
+
visit(name);
|
|
24
|
+
return {
|
|
25
|
+
selected: all.filter((resource) => wanted.has(resource.name)),
|
|
26
|
+
dependencies: all.map((r) => r.name).filter((n) => wanted.has(n) && !requested.includes(n)),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=up-only.js.map
|