@tdk-landscape/tdk-cli-core 1.3.96 → 1.3.97

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 CHANGED
@@ -4,6 +4,7 @@ Notable changes to the `tdk` CLI. Versions match [npm](https://www.npmjs.com/pac
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ - `tdk up` keeps each smoke step's status and response body under `.tdk/smoke/<service>/<step>/` (`latest.json` and `body.txt`, body capped at 64 KiB), plus `last-success.json` and `last-success-body.txt` that a later failure never overwrites, so a regression can be diffed against the last good response. The failure line ends with the record path, and `tdk project` gitignores `.tdk/smoke/`. A new `smoke-e2e` workflow runs `scripts/verify-smoke.sh` and uploads the records. Before its first step a smoke check now waits for the service's health path (`healthCheckPath`, default `/health` for backends) with GETs until it answers something other than a connection error, 404 or 5xx, so a write is no longer the first request to hit a service that is still building; write retry rules are unchanged.
7
8
  - Add an opt-in Python backend: `tdk resource <name> --type backend --language python` scaffolds a FastAPI service (`pyproject.toml`, `src/main.py`, a pytest smoke test) and TDK generates its Docker image (`python:3.12-slim`, with `test` and `production` stages). The choice is saved as `language` in `service.json` only when you pass the flag; omitting it keeps the existing Bun + Hono output byte for byte. Unknown ids and `--language` on non-backend types fail before any file is written. A runnable example is in `examples/one-backend-python`, and the provider contract is in `docs/backend-language-providers.md`. Node.js is not included.
8
9
  - `tdk doctor` no longer reports "resource without a package.json" for a Python backend, and a Python service missing `pyproject.toml` or `src/main.py` now fails early with the missing file named.
9
10
  - Live reload now works: Tilt `live_update` sync paths were relative to the project root while Tilt resolves them against `.tdk/.tdk-out`, so they never matched and every source edit rebuilt the image ("Found file(s) not matching any sync"). An edit under `src/` is now copied into the running container without a rebuild, for Bun and Python services. Dependency file changes (`package.json`, `pyproject.toml`) still rebuild the image.
package/cli/dist/cli.js CHANGED
@@ -22,6 +22,7 @@ import { upCommand } from "./commands/up.js";
22
22
  import { upgradeCommand } from "./commands/upgrade.js";
23
23
  import { versionCommand } from "./commands/version.js";
24
24
  import { formatColdPreflight, runColdPreflight } from "./utils/cold-preflight.js";
25
+ import { unknownHelpTarget } from "./utils/help-command.js";
25
26
  const program = new Command();
26
27
  program
27
28
  .name("tdk")
@@ -69,5 +70,12 @@ if (process.argv.length === 3 && ["-h", "--help", "help"].includes(process.argv[
69
70
  showHelp();
70
71
  process.exit(0);
71
72
  }
73
+ const unknownHelp = unknownHelpTarget(process.argv.slice(2), program.commands);
74
+ if (unknownHelp) {
75
+ program.error(`error: unknown command '${unknownHelp}'`, {
76
+ exitCode: 1,
77
+ code: "commander.unknownCommand",
78
+ });
79
+ }
72
80
  program.parse();
73
81
  //# sourceMappingURL=cli.js.map
@@ -0,0 +1,35 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { expect } from "vitest";
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ export const repoRoot = resolve(__dirname, "../../../..");
7
+ export const cliBin = join(repoRoot, "cli", "bin", "tdk.js");
8
+ export function runTdk(args, cwd, input) {
9
+ return execFileSync(process.execPath, [cliBin, ...args], {
10
+ cwd,
11
+ encoding: "utf-8",
12
+ input,
13
+ env: {
14
+ ...process.env,
15
+ TDK_EXTENSION_SOURCE: repoRoot,
16
+ },
17
+ });
18
+ }
19
+ /** `tdk doctor` exits non-zero on machines without Docker, so read its output either way. */
20
+ export function runTdkAllowFailure(args, cwd) {
21
+ const result = spawnSync(process.execPath, [cliBin, ...args], {
22
+ cwd,
23
+ encoding: "utf-8",
24
+ env: { ...process.env, TDK_EXTENSION_SOURCE: repoRoot },
25
+ });
26
+ return `${result.stdout}${result.stderr}`;
27
+ }
28
+ export function starlarkSection(content, name) {
29
+ const start = content.indexOf(`${name} = {`);
30
+ expect(start).toBeGreaterThanOrEqual(0);
31
+ const end = content.indexOf("\n}", start);
32
+ expect(end).toBeGreaterThan(start);
33
+ return content.slice(start, end + 2);
34
+ }
35
+ //# sourceMappingURL=project-e2e-helpers.js.map
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import { resolveBackendFramework } from "../backend-frameworks/registry.js";
6
6
  import { getBackendIndexTemplate } from "../backend-languages/bun.js";
7
7
  import { resolveBackendLanguage } from "../backend-languages/registry.js";
8
- import { resolveFrontendFramework } from "../frontend-frameworks/registry.js";
8
+ import { DEFAULT_FRONTEND_FRAMEWORK, listFrontendFrameworks, resolveFrontendFramework, } from "../frontend-frameworks/registry.js";
9
9
  import { hasDddLicense } from "../generator/extension-fetch.js";
10
10
  import { CREATABLE_RESOURCE_TYPES } from "../types/index.js";
11
11
  import { assertValid, confirmOrCancel } from "../utils/command-helpers.js";
@@ -18,7 +18,7 @@ import { promptSelect, promptText } from "../utils/prompt.js";
18
18
  import { getDefaultFeaturesForResourceType } from "../utils/resource-features.js";
19
19
  import { SERVICE_MANIFEST_SCHEMA_URL, SERVICE_MANIFEST_SCHEMA_VERSION, } from "../utils/service-manifest.js";
20
20
  import { discoverResources } from "../utils/services.js";
21
- import { createKebabCaseValidator, isPathSafe, validateResourceName } from "../utils/validation.js";
21
+ import { createKebabCaseValidator, isPathSafe, validateResourceName, validateStackName, } from "../utils/validation.js";
22
22
  export const BASE_TEMPLATE = {
23
23
  port: 0, // Will be assigned
24
24
  // `dependsOn` is the current name; `dependencies` is deprecated and `tdk doctor` warns about it, so a fresh scaffold must not write it.
@@ -308,11 +308,21 @@ describe('${name}', () => {
308
308
  });
309
309
  `;
310
310
  }
311
+ /** Resource types `tdk resource --type` accepts; `byo` is an alias of `bring-your-own`. */
312
+ export function parseResourceType(type) {
313
+ const normalized = type === "byo" ? "bring-your-own" : type;
314
+ const validTypes = [...CREATABLE_RESOURCE_TYPES, "sdk"];
315
+ if (!validTypes.includes(normalized)) {
316
+ throw new TdkError(`Unknown resource type "${type}". Supported types: ${validTypes.join(", ")}.`, [`Use one of: ${validTypes.join(", ")}`, "Omit --type to create a backend"]);
317
+ }
318
+ return normalized;
319
+ }
311
320
  export const resourceCommand = new Command("resource")
312
321
  .description("Create a new resource (service) from scratch, or register an existing one")
313
322
  .argument("[name]", "Resource name (kebab-case)")
314
323
  .option("-t, --type <type>", "Resource type: backend, frontend, worker, mcp, bring-your-own, sdk", "backend")
315
- .option("--framework <id>", "Framework: frontend react (default), vue, svelte, preact, lit, solid, qwik, vanilla, tanstack-router; backend hono (default), express, elysia, fastify, nestjs, koa, h3")
324
+ .option("--framework <id>", "Framework: frontend react (default), vue, svelte, preact, lit, solid, qwik, vanilla, tanstack-router (see --frameworks); backend hono (default), express, elysia, fastify, nestjs, koa, h3")
325
+ .option("--frameworks", "List registered frontend frameworks and exit")
316
326
  .option("--language <id>", "Backend language: bun (default), python, go, rust")
317
327
  .option("-s, --stack <stack>", "Stack to assign resource to", "default")
318
328
  .option("-p, --path <path>", "Custom path for resource directory")
@@ -327,6 +337,12 @@ export const resourceCommand = new Command("resource")
327
337
  .option("--image <name>", "Docker image name instead of building from Dockerfile (for bring-your-own)")
328
338
  .option("--port <port>", "Port number (default: next free in 4000-5999)")
329
339
  .action(async (name, options) => {
340
+ if (options.frameworks) {
341
+ for (const f of listFrontendFrameworks()) {
342
+ console.log(`${f.id}\t${f.label}\t${f.kind}\t${f.verified ? "verified" : "unverified"}\t${f.command}`);
343
+ }
344
+ return;
345
+ }
330
346
  await runCommand(async () => {
331
347
  const projectRoot = requireProjectRoot();
332
348
  // Support --resource-path as alias for --path
@@ -347,36 +363,26 @@ export const resourceCommand = new Command("resource")
347
363
  else {
348
364
  assertValid(validateResourceName(resourceName));
349
365
  }
350
- // Support sdk type for registering existing SDKs
351
- let resourceType;
352
- const validTypes = [...CREATABLE_RESOURCE_TYPES, "sdk"];
353
- // Normalize bring-your-own aliases
354
- const normalizedType = options.type === "byo" || options.type === "bring-your-own"
355
- ? "bring-your-own"
356
- : options.type;
357
- if (!validTypes.includes(normalizedType)) {
358
- const selectedType = await promptSelect({
359
- message: "Resource type:",
360
- choices: [
361
- { title: "backend - API service with HTTP endpoints", value: "backend" },
362
- { title: "frontend - Web application/UI", value: "frontend" },
363
- { title: "worker - Background job processor", value: "worker" },
364
- { title: "mcp - Model Context Protocol server (HTTP)", value: "mcp" },
365
- {
366
- title: "bring-your-own - Wrap an existing service. No app scaffold. Needs Dockerfile or --image.",
367
- value: "bring-your-own",
368
- },
369
- { title: "sdk - Library/SDK (register existing)", value: "sdk" },
370
- ],
366
+ const resourceType = parseResourceType(options.type);
367
+ let frameworkOption = options.framework;
368
+ if (resourceType === "frontend" &&
369
+ frameworkOption === undefined &&
370
+ !options.yes &&
371
+ process.stdin.isTTY) {
372
+ frameworkOption = await promptSelect({
373
+ message: "Frontend framework:",
374
+ choices: listFrontendFrameworks()
375
+ .map((f) => ({ title: f.label, value: f.id }))
376
+ .sort((a, b) => a.value === DEFAULT_FRONTEND_FRAMEWORK
377
+ ? -1
378
+ : b.value === DEFAULT_FRONTEND_FRAMEWORK
379
+ ? 1
380
+ : 0),
371
381
  });
372
- resourceType = selectedType;
373
- }
374
- else {
375
- resourceType = normalizedType;
376
382
  }
377
- const frontendFramework = resolveFrontendFramework(resourceType, options.framework);
383
+ const frontendFramework = resolveFrontendFramework(resourceType, frameworkOption, resourceName);
378
384
  const backendLanguage = resolveBackendLanguage(resourceType, options.language);
379
- const backendFramework = resolveBackendFramework(resourceType, options.framework, backendLanguage);
385
+ const backendFramework = resolveBackendFramework(resourceType, frameworkOption, backendLanguage);
380
386
  // The id passed on to the file generators. An mcp resource has one fixed scaffold and no `framework` setting, so it passes none
381
387
  // (its provider is selected by the resource type itself).
382
388
  const scaffoldFrameworkId = resourceType === "mcp" ? undefined : (frontendFramework?.id ?? backendFramework?.id);
@@ -394,6 +400,7 @@ export const resourceCommand = new Command("resource")
394
400
  dddEnabled = true;
395
401
  }
396
402
  }
403
+ assertValid(validateStackName(options.stack));
397
404
  let stackName = options.stack;
398
405
  if (stackName === "default") {
399
406
  const existingResources = allResources;
@@ -1,13 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import chalk from "chalk";
3
3
  import { Command } from "commander";
4
- import { confirmOrCancel } from "../utils/command-helpers.js";
4
+ import { assertValid, confirmOrCancel } from "../utils/command-helpers.js";
5
5
  import { clearDiscoveryCache, createDiscoveryContext } from "../utils/discovery-context.js";
6
6
  import { requireProjectRoot, runCommand } from "../utils/errors.js";
7
7
  import { writeJsonFile } from "../utils/file-helpers.js";
8
8
  import { formatCount, showAllSatisfyCondition, showCommandHeader, showDetail, showSuccess, } from "../utils/formatting.js";
9
9
  import { promptMultiSelect, promptText } from "../utils/prompt.js";
10
- import { createKebabCaseValidator } from "../utils/validation.js";
10
+ import { createKebabCaseValidator, validateStackName } from "../utils/validation.js";
11
11
  export const stackCommand = new Command("stack")
12
12
  .description("Organize resources into stacks (groups)")
13
13
  .argument("[stack-name]", "Stack name to assign to resources")
@@ -42,6 +42,8 @@ export const stackCommand = new Command("stack")
42
42
  }
43
43
  return;
44
44
  }
45
+ if (stackName)
46
+ assertValid(validateStackName(stackName));
45
47
  let targetStack = stackName;
46
48
  if (!targetStack) {
47
49
  const name = await promptText({
@@ -14,6 +14,8 @@ export const statusCommand = new Command("status")
14
14
  .option("--tilt", "Show tilt resource status", false)
15
15
  .action(async (options) => {
16
16
  const action = async () => {
17
+ // Discover first: outside a project this fails before any status line is printed.
18
+ const discovery = createDiscoveryContext();
17
19
  let tiltAvailable = false;
18
20
  try {
19
21
  tiltAvailable = await isTiltAvailable();
@@ -30,7 +32,6 @@ export const statusCommand = new Command("status")
30
32
  }
31
33
  console.log();
32
34
  }
33
- const discovery = createDiscoveryContext();
34
35
  let tiltResources = null;
35
36
  let queryError = null;
36
37
  if (options.json && options.tilt && tiltAvailable) {
@@ -8,7 +8,7 @@ import { findProjectRoot } from "../utils/paths.js";
8
8
  import { clearMetadataCache, discoverResources, discoverStacks, getResourceMetadata, getStackMetadata, } from "../utils/services.js";
9
9
  import { isTiltAvailable } from "../utils/tilt.js";
10
10
  // biome-ignore lint/correctness/noUnusedFunctionParameters: reserved callback prop kept in the component API
11
- const HelpPanel = ({ onClose }) => (_jsxs(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 2, paddingY: 1, flexDirection: "column", width: 60, children: [_jsx(Text, { bold: true, color: "cyan", children: "Keyboard Shortcuts" }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, underline: true, children: "Navigation" }), _jsx(Text, { children: " \u2191/\u2193 Navigate list items" }), _jsx(Text, { children: " Enter Select item / Open detail" }), _jsx(Text, { children: " Space Toggle expand (tree view)" }), _jsx(Text, { children: " Tab Next tab" }), _jsx(Text, { children: " 1-5 Direct tab access" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, underline: true, children: "Actions" }) }), _jsx(Text, { children: " a Toggle all/pre-alpha services" }), _jsx(Text, { children: " m Toggle mouse support" }), _jsx(Text, { children: " t Toggle tooltips" }), _jsx(Text, { children: " e Toggle enabled/disabled services" }), _jsx(Text, { children: " r Refresh data" }), _jsx(Text, { children: " / Search/filter" }), _jsx(Text, { children: " ? Show this help" }), _jsx(Text, { children: " q/Esc Quit / Back" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to close..." }) })] }));
11
+ const HelpPanel = ({ onClose }) => (_jsxs(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 2, paddingY: 1, flexDirection: "column", width: 60, children: [_jsx(Text, { bold: true, color: "cyan", children: "Keyboard Shortcuts" }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, underline: true, children: "Navigation" }), _jsx(Text, { children: " \u2191/\u2193 Navigate list items" }), _jsx(Text, { children: " Enter Select item / Open detail" }), _jsx(Text, { children: " Space Toggle expand (tree view)" }), _jsx(Text, { children: " Tab Next tab" }), _jsx(Text, { children: " 1-5 Direct tab access" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, underline: true, children: "Actions" }) }), _jsx(Text, { children: " m Toggle mouse support" }), _jsx(Text, { children: " t Toggle tooltips" }), _jsx(Text, { children: " e Toggle enabled/disabled services" }), _jsx(Text, { children: " r Refresh data" }), _jsx(Text, { children: " / Search/filter" }), _jsx(Text, { children: " ? Show this help" }), _jsx(Text, { children: " q/Esc Quit / Back" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to close..." }) })] }));
12
12
  const LoadingScreen = ({ progress, message }) => (_jsxs(Box, { flexDirection: "column", padding: 2, children: [_jsx(Text, { bold: true, color: "cyan", children: "\u2593\u2592\u2591 TDK NEON EDITION \u2591\u2592\u2593" }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { children: ["Loading: ", message] }), _jsxs(Box, { marginY: 1, borderStyle: "single", borderColor: "gray", width: 50, children: [_jsx(Box, { width: progress / 2, backgroundColor: "cyan", children: _jsx(Text, { children: " ".repeat(progress / 2) }) }), _jsxs(Text, { children: [" ", progress, "%"] })] })] }));
13
13
  // biome-ignore lint/correctness/noUnusedFunctionParameters: reserved callback prop kept in the component API
14
14
  const ErrorScreen = ({ error, onRetry }) => (_jsxs(Box, { flexDirection: "column", padding: 2, alignItems: "center", children: [_jsx(Text, { bold: true, color: "red", children: "Could Not Load Services" }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { color: "red", children: ["\u2717 ", error] }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: "gray", children: "Troubleshooting:" }), _jsx(Text, { color: "gray", children: " 1. Check that every service.json is valid JSON" }), _jsx(Text, { color: "gray", children: " 2. Run tdk from your project (the folder with the Tiltfile)" }), _jsx(Text, { color: "gray", children: " 3. Try: tdk status --verbose" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: "cyan", children: "Press [r] to retry or [q] to quit" })] }));
@@ -418,7 +418,7 @@ const TUIApp = () => {
418
418
  return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [_jsx(Box, { paddingX: 1, paddingY: 0, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\u2593\u2592\u2591 TDK NEON EDITION \u2591\u2592\u2593" }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "white", children: projectRoot }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsxs(Text, { color: "green", children: [services.length, " services ready"] })] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "gray", children: "─".repeat(compact ? 60 : Math.min(terminalWidth - 4, 100)) }) }), isSearching && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: "yellow", children: ["Search: ", searchQuery, "_"] }) })), !isSearching && message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: "cyan", children: ["\u2593\u2592\u2591 ", message, " \u2591\u2592\u2593"] }) })), !isSearching && !message && showTooltips && !showHelp && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: activeTab === "overview" && selectedStack
419
419
  ? `Stack "${selectedStack}" selected. [Enter] view │ [Esc] back │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
420
420
  : activeTab === "overview" && !selectedStack
421
- ? `[↑/↓] Navigate │ [Enter] Select │ [a] Pre-alpha │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
421
+ ? `[↑/↓] Navigate │ [Enter] Select │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
422
422
  : activeTab === "resources"
423
423
  ? `[Tab] Tabs │ [r] Refresh │ [/] Search │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
424
424
  : activeTab === "events"
@@ -1,4 +1,5 @@
1
1
  import { connect } from "node:net";
2
+ import { join } from "node:path";
2
3
  import chalk from "chalk";
3
4
  import { Command } from "commander";
4
5
  import { ensureProjectRuntimeAssets } from "../generator/template-engine.js";
@@ -210,7 +211,9 @@ export const upCommand = new Command("up")
210
211
  if (!options.quiet) {
211
212
  console.log(chalk.gray(`Smoke check: ${smokePlans.map((p) => p.name).join(", ")}`));
212
213
  }
213
- const results = await runSmokePlans(smokePlans);
214
+ const results = await runSmokePlans(smokePlans, {
215
+ recordDir: join(projectRoot, ".tdk", "smoke"),
216
+ });
214
217
  for (const smokeResult of results) {
215
218
  if (smokeResult.ok) {
216
219
  if (!options.quiet)
@@ -20,16 +20,54 @@ export const FRONTEND_FRAMEWORKS = {
20
20
  vanilla: vanillaFrontendProvider,
21
21
  "tanstack-router": tanstackRouterFrontendProvider,
22
22
  };
23
- export function getFrontendFramework(frameworkId) {
23
+ // Frameworks that own their own server or build config. They are never scaffolded as Vite SPA providers; they go through
24
+ // `tdk resource <name> --type bring-your-own` instead.
25
+ export const META_FRAMEWORK_IDS = [
26
+ "next",
27
+ "nuxt",
28
+ "sveltekit",
29
+ "astro",
30
+ "angular",
31
+ "remix",
32
+ "tanstack-start",
33
+ ];
34
+ // Providers that have passed scripts/verify-frontend-frameworks.sh (that script's default list must match this).
35
+ export const VERIFIED_FRONTEND_FRAMEWORKS = [
36
+ "react",
37
+ "vue",
38
+ "svelte",
39
+ "preact",
40
+ "lit",
41
+ "solid",
42
+ "qwik",
43
+ "vanilla",
44
+ "tanstack-router",
45
+ ];
46
+ export function listFrontendFrameworks() {
47
+ return Object.entries(FRONTEND_FRAMEWORKS).map(([id, provider]) => ({
48
+ id,
49
+ label: provider.label,
50
+ kind: "vite-spa",
51
+ verified: VERIFIED_FRONTEND_FRAMEWORKS.includes(id),
52
+ command: `tdk resource <name> --type frontend --framework ${id}`,
53
+ }));
54
+ }
55
+ export function getFrontendFramework(frameworkId, resourceName) {
24
56
  const id = (frameworkId ?? DEFAULT_FRONTEND_FRAMEWORK).trim().toLowerCase();
25
57
  const provider = Object.hasOwn(FRONTEND_FRAMEWORKS, id) ? FRONTEND_FRAMEWORKS[id] : undefined;
26
58
  if (!provider) {
27
59
  const supportedIds = Object.keys(FRONTEND_FRAMEWORKS).join(", ");
28
- throw new TdkError(`Unknown frontend framework "${frameworkId}". Supported frameworks: ${supportedIds}.`, [`Use one of: ${supportedIds}`, `Omit --framework to use ${DEFAULT_FRONTEND_FRAMEWORK}`]);
60
+ const isMeta = META_FRAMEWORK_IDS.includes(id);
61
+ const byo = `tdk resource ${resourceName ?? "<name>"} --type bring-your-own`;
62
+ throw new TdkError(`${isMeta ? `"${frameworkId}" is not a Vite SPA provider` : `Unknown frontend framework "${frameworkId}"`}. Supported frameworks: ${supportedIds}.`, [
63
+ `Use one of: ${supportedIds}`,
64
+ `Omit --framework to use ${DEFAULT_FRONTEND_FRAMEWORK}`,
65
+ ...(isMeta ? [`Create the app with ${id}'s own CLI, then register it: ${byo}`] : []),
66
+ ]);
29
67
  }
30
68
  return provider;
31
69
  }
32
- export function resolveFrontendFramework(resourceType, frameworkId) {
70
+ export function resolveFrontendFramework(resourceType, frameworkId, resourceName) {
33
71
  if (resourceType !== "frontend") {
34
72
  // Backends take their own framework ids (see backend-frameworks/registry.ts).
35
73
  if (frameworkId !== undefined && resourceType !== "backend") {
@@ -39,6 +77,6 @@ export function resolveFrontendFramework(resourceType, frameworkId) {
39
77
  }
40
78
  return undefined;
41
79
  }
42
- return getFrontendFramework(frameworkId);
80
+ return getFrontendFramework(frameworkId, resourceName);
43
81
  }
44
82
  //# sourceMappingURL=registry.js.map
@@ -43,7 +43,13 @@ export function ensureDirectory(dirPath) {
43
43
  }
44
44
  }
45
45
  /** Generated files that must never be committed: .env holds a generated DB password. */
46
- const GITIGNORE_ENTRIES = [".env", ".tdk/.tdk-out/", ".tdk/.project-id", "node_modules/"];
46
+ const GITIGNORE_ENTRIES = [
47
+ ".env",
48
+ ".tdk/.tdk-out/",
49
+ ".tdk/.project-id",
50
+ ".tdk/smoke/",
51
+ "node_modules/",
52
+ ];
47
53
  /**
48
54
  * Appends the TDK entries missing from the project's .gitignore (creating it
49
55
  * if needed), so the first `git add .` doesn't commit .env or generated
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `tdk help <name>` should fail for a name that is not a command or alias, like `tdk <name>` does.
3
+ * Returns the unknown name, or undefined when the arguments are not `help <name>` or the name is known.
4
+ */
5
+ export function unknownHelpTarget(args, commands) {
6
+ const [first, target] = args;
7
+ if (first !== "help" || !target || target.startsWith("-"))
8
+ return undefined;
9
+ const known = commands.some((command) => command.name() === target || command.aliases().includes(target));
10
+ return known ? undefined : target;
11
+ }
12
+ //# sourceMappingURL=help-command.js.map
@@ -42,6 +42,12 @@ function isValidResourceConfig(value) {
42
42
  typeof config.type === "string";
43
43
  return typeof config.appName === "string" && hasRuntimeOrAppType;
44
44
  }
45
+ // Discovery parses every service.json more than once per command (resources, then stacks),
46
+ // so remember what was already printed and show each warning once.
47
+ const printedWarnings = new Set();
48
+ export function resetPrintedServiceWarnings() {
49
+ printedWarnings.clear();
50
+ }
45
51
  function parseResource(serviceJsonPath) {
46
52
  const content = readFileSync(serviceJsonPath, "utf-8");
47
53
  const parsed = JSON.parse(content);
@@ -51,6 +57,9 @@ function parseResource(serviceJsonPath) {
51
57
  const parsedConfig = parsed;
52
58
  for (const warning of validateServiceManifest(parsed, serviceJsonPath).warnings) {
53
59
  // The warning names the file and field and says what is wrong (unknown, deprecated, ignored), so do not prefix it.
60
+ if (printedWarnings.has(warning))
61
+ continue;
62
+ printedWarnings.add(warning);
54
63
  console.warn(`⚠️ ${warning}`);
55
64
  }
56
65
  const config = {
@@ -1,9 +1,13 @@
1
+ import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { isApiServiceType } from "./resource-kind.js";
2
4
  import { resolveServicePath, resolveSubdomainBases } from "./service-urls.js";
3
5
  export const DEFAULT_SMOKE_TIMEOUT_SECONDS = 60;
4
6
  const RETRY_DELAY_MS = 1000;
5
7
  const REQUEST_TIMEOUT_MS = 10_000;
6
8
  const BODY_SNIPPET_CHARS = 200;
9
+ /** Largest response body kept in a smoke record. */
10
+ export const SMOKE_RECORD_BODY_MAX_BYTES = 64 * 1024;
7
11
  /** Statuses a proxy answers while a route or its upstream is not up yet. Anything else is the service's real answer. */
8
12
  const NOT_READY_STATUSES = new Set([404, 502, 503, 504]);
9
13
  /** Error codes that prove the request never left this machine, so repeating even a write is safe. */
@@ -87,9 +91,12 @@ export function buildSmokePlans(resources, ingressPort) {
87
91
  if (config.exposeViaProxy === false)
88
92
  continue;
89
93
  const base = isApi ? apiBase : appBase;
94
+ // The container healthcheck path (default /health for APIs) is the one request that is safe to repeat while the route comes up.
95
+ const readyPath = config.healthCheckPath || (isApi && appType !== "bring-your-own" ? "/health" : undefined);
90
96
  plans.push({
91
97
  name: resource.name,
92
98
  baseUrl: `${base}${resolveServicePath(resource).replace(/\/+$/, "")}`,
99
+ ...(readyPath?.startsWith("/") ? { readyPath } : {}),
93
100
  smoke: config.smoke,
94
101
  });
95
102
  }
@@ -122,12 +129,65 @@ function snippet(text) {
122
129
  const flat = text.replace(/\s+/g, " ").trim();
123
130
  return flat.length > BODY_SNIPPET_CHARS ? `${flat.slice(0, BODY_SNIPPET_CHARS)}...` : flat;
124
131
  }
132
+ function slug(text) {
133
+ return (text
134
+ .toLowerCase()
135
+ .replace(/[^a-z0-9]+/g, "-")
136
+ .replace(/^-+|-+$/g, "") || "step");
137
+ }
138
+ /** Two steps whose names slug the same (`read back`, `read-back`) must not share a record directory. */
139
+ function stepKey(seen, label) {
140
+ const base = slug(label);
141
+ const n = (seen.get(base) ?? 0) + 1;
142
+ seen.set(base, n);
143
+ return n === 1 ? base : `${base}-${n}`;
144
+ }
145
+ /** Writes latest.json (+ body.txt) for a step; a passing step also replaces the last-success pair. Returns the latest.json path. */
146
+ function writeStepRecord(recordDir, stepKey, record, body, passed) {
147
+ try {
148
+ const dir = join(recordDir, slug(record.service), stepKey);
149
+ mkdirSync(dir, { recursive: true });
150
+ const latest = join(dir, "latest.json");
151
+ const bodyPath = join(dir, "body.txt");
152
+ if (body === undefined) {
153
+ rmSync(bodyPath, { force: true });
154
+ }
155
+ else {
156
+ writeFileSync(bodyPath, body);
157
+ }
158
+ writeFileSync(latest, `${JSON.stringify({ ...record, bodyPath: body === undefined ? null : "body.txt" }, null, 2)}\n`);
159
+ if (passed) {
160
+ copyFileSync(latest, join(dir, "last-success.json"));
161
+ if (body === undefined)
162
+ rmSync(join(dir, "last-success-body.txt"), { force: true });
163
+ else
164
+ copyFileSync(bodyPath, join(dir, "last-success-body.txt"));
165
+ }
166
+ return latest;
167
+ }
168
+ catch {
169
+ // The record is a diagnostic aid; failing to write it must not change the smoke result.
170
+ return undefined;
171
+ }
172
+ }
173
+ /** Caps a body at SMOKE_RECORD_BODY_MAX_BYTES of UTF-8 without splitting a character. */
174
+ function capBody(text) {
175
+ const buf = Buffer.from(text, "utf8");
176
+ if (buf.length <= SMOKE_RECORD_BODY_MAX_BYTES)
177
+ return { body: text, truncated: false, bytes: buf.length };
178
+ const body = buf
179
+ .subarray(0, SMOKE_RECORD_BODY_MAX_BYTES)
180
+ .toString("utf8")
181
+ .replace(/\uFFFD$/, "");
182
+ return { body, truncated: true, bytes: Buffer.byteLength(body, "utf8") };
183
+ }
125
184
  /** Runs the steps in order. A not-ready answer (connection error, 404, 502-504) is retried until the timeout; a real wrong answer fails at once. */
126
185
  export async function runSmokePlan(plan, deps = {}) {
127
186
  const doFetch = deps.fetch ?? ((url, init) => fetch(url, init));
128
187
  const now = deps.now ?? Date.now;
129
188
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
130
- const deadline = now() + (plan.smoke.timeoutSeconds ?? DEFAULT_SMOKE_TIMEOUT_SECONDS) * 1000;
189
+ const budgetMs = (plan.smoke.timeoutSeconds ?? DEFAULT_SMOKE_TIMEOUT_SECONDS) * 1000;
190
+ let deadline = now() + budgetMs;
131
191
  const saved = {};
132
192
  // A manifest with errors is still loaded, so a malformed block must fail the check, not throw out of `tdk up`.
133
193
  const invalid = validateSmoke(plan.smoke);
@@ -138,6 +198,33 @@ export async function runSmokePlan(plan, deps = {}) {
138
198
  failure: `${plan.name}: invalid smoke block: ${invalid.join("; ")}`,
139
199
  };
140
200
  }
201
+ // `tdk up` starts the check once Tilt's UI is up, while images may still be building. Traefik then resets or 404s early requests.
202
+ // A write must not be repeated after a reset (it may have reached the service), so wait for readiness with GETs instead.
203
+ // If the service never answers, fall through: the first step then fails with its own record.
204
+ if (plan.readyPath) {
205
+ const readyUrl = `${plan.baseUrl}${plan.readyPath}`;
206
+ for (;;) {
207
+ try {
208
+ const response = await doFetch(readyUrl, {
209
+ method: "GET",
210
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
211
+ });
212
+ await response.text();
213
+ // A 5xx from the health path (migrations running, database not up) is still "not ready"; 401 or 3xx means something answered.
214
+ if (!NOT_READY_STATUSES.has(response.status) && response.status < 500)
215
+ break;
216
+ }
217
+ catch {
218
+ // not accepting connections yet
219
+ }
220
+ if (now() >= deadline)
221
+ break;
222
+ await sleep(RETRY_DELAY_MS);
223
+ }
224
+ }
225
+ // The steps get their own `timeoutSeconds`; a slow build must not eat the first step's budget.
226
+ deadline = now() + budgetMs;
227
+ const stepKeys = new Map();
141
228
  for (const [index, step] of plan.smoke.steps.entries()) {
142
229
  const label = step.name ?? `step ${index + 1}`;
143
230
  const method = (step.method ?? "GET").toUpperCase();
@@ -147,8 +234,10 @@ export async function runSmokePlan(plan, deps = {}) {
147
234
  let status;
148
235
  let text = "";
149
236
  let error;
237
+ let errorCode;
150
238
  for (;;) {
151
239
  error = undefined;
240
+ errorCode = undefined;
152
241
  status = undefined;
153
242
  let retryableError = false;
154
243
  try {
@@ -164,6 +253,10 @@ export async function runSmokePlan(plan, deps = {}) {
164
253
  catch (err) {
165
254
  retryableError = shouldRetryRequestError(err, method);
166
255
  error = err instanceof Error ? err.message : String(err);
256
+ const code = isRecord(err)
257
+ ? (err.code ?? (isRecord(err.cause) ? err.cause.code : undefined))
258
+ : undefined;
259
+ errorCode = typeof code === "string" ? code : undefined;
167
260
  }
168
261
  const notReady = (error !== undefined && retryableError) ||
169
262
  (status !== undefined && status !== expected && NOT_READY_STATUSES.has(status));
@@ -172,21 +265,38 @@ export async function runSmokePlan(plan, deps = {}) {
172
265
  await sleep(RETRY_DELAY_MS);
173
266
  }
174
267
  const where = `${plan.name}: ${label}: ${method} ${url}`;
268
+ // Every step that ran leaves a record. A step is "passed" for last-success only if status and bodyContains both match.
269
+ let recordPath;
270
+ if (deps.recordDir) {
271
+ const capped = status === undefined ? undefined : capBody(text);
272
+ const passed = error === undefined &&
273
+ status === expected &&
274
+ (step.bodyContains === undefined || text.includes(step.bodyContains));
275
+ recordPath = writeStepRecord(deps.recordDir, stepKey(stepKeys, label), {
276
+ service: plan.name,
277
+ step: label,
278
+ method,
279
+ url,
280
+ expectedStatus: expected,
281
+ status: status ?? null,
282
+ ...(error === undefined ? {} : { error: errorCode ?? error }),
283
+ truncated: capped?.truncated ?? false,
284
+ bodyBytes: capped?.bytes ?? 0,
285
+ }, capped?.body, passed);
286
+ }
287
+ const fail = (failure) => ({
288
+ name: plan.name,
289
+ ok: false,
290
+ failure,
291
+ recordPath,
292
+ });
175
293
  if (error !== undefined)
176
- return { name: plan.name, ok: false, failure: `${where} never answered (${error})` };
294
+ return fail(`${where} never answered (${error})`);
177
295
  if (status !== expected) {
178
- return {
179
- name: plan.name,
180
- ok: false,
181
- failure: `${where} returned ${status}, expected ${expected}${text ? `: ${snippet(text)}` : ""}`,
182
- };
296
+ return fail(`${where} returned ${status}, expected ${expected}${text ? `: ${snippet(text)}` : ""}`);
183
297
  }
184
298
  if (step.bodyContains !== undefined && !text.includes(step.bodyContains)) {
185
- return {
186
- name: plan.name,
187
- ok: false,
188
- failure: `${where} returned ${status} but the body does not contain "${step.bodyContains}": ${snippet(text)}`,
189
- };
299
+ return fail(`${where} returned ${status} but the body does not contain "${step.bodyContains}": ${snippet(text)}`);
190
300
  }
191
301
  if (step.save) {
192
302
  let parsed;
@@ -194,20 +304,12 @@ export async function runSmokePlan(plan, deps = {}) {
194
304
  parsed = JSON.parse(text);
195
305
  }
196
306
  catch {
197
- return {
198
- name: plan.name,
199
- ok: false,
200
- failure: `${where} returned a body that is not JSON, cannot save: ${snippet(text)}`,
201
- };
307
+ return fail(`${where} returned a body that is not JSON, cannot save: ${snippet(text)}`);
202
308
  }
203
309
  for (const [key, path] of Object.entries(step.save)) {
204
310
  const value = readPath(parsed, path);
205
311
  if (value === undefined || value === null || typeof value === "object") {
206
- return {
207
- name: plan.name,
208
- ok: false,
209
- failure: `${where} response has no ${path} to save as {{${key}}}: ${snippet(text)}`,
210
- };
312
+ return fail(`${where} response has no ${path} to save as {{${key}}}: ${snippet(text)}`);
211
313
  }
212
314
  saved[key] = String(value);
213
315
  }
@@ -216,7 +318,7 @@ export async function runSmokePlan(plan, deps = {}) {
216
318
  return { name: plan.name, ok: true };
217
319
  }
218
320
  export function formatSmokeFailure(result) {
219
- return `Smoke check failed: ${result.failure}`;
321
+ return `Smoke check failed: ${result.failure}${result.recordPath ? ` (record: ${result.recordPath})` : ""}`;
220
322
  }
221
323
  /** Runs every plan side by side; each plan keeps its own timeout. */
222
324
  export async function runSmokePlans(plans, deps = {}) {
@@ -3,29 +3,34 @@ export const KEBAB_CASE_REGEX = /^[a-z0-9-]+$/;
3
3
  function isKebabCase(value) {
4
4
  return KEBAB_CASE_REGEX.test(value);
5
5
  }
6
+ const KEBAB_RULE = "Use lowercase letters, numbers, and hyphens only";
7
+ function invalidNameMessage(label, value) {
8
+ const example = label === "Resource name" ? "my-service" : "my-stack";
9
+ return `${label} "${value}" is not valid. ${KEBAB_RULE}, e.g. "${example}".`;
10
+ }
6
11
  export function validateResourceName(name) {
7
12
  if (!name.trim()) {
8
13
  return { valid: false, error: "Resource name is required" };
9
14
  }
10
15
  if (!isKebabCase(name)) {
11
- return {
12
- valid: false,
13
- error: "Use lowercase letters, numbers, and hyphens only",
14
- };
16
+ return { valid: false, error: invalidNameMessage("Resource name", name) };
17
+ }
18
+ return { valid: true };
19
+ }
20
+ export function validateStackName(name) {
21
+ if (!name.trim()) {
22
+ return { valid: false, error: "Stack name is required" };
23
+ }
24
+ if (!isKebabCase(name)) {
25
+ return { valid: false, error: invalidNameMessage("Stack name", name) };
15
26
  }
16
27
  return { valid: true };
17
28
  }
18
29
  export function createKebabCaseValidator(context) {
30
+ const validate = context === "resource" ? validateResourceName : validateStackName;
19
31
  return (input) => {
20
- if (!input.trim()) {
21
- return context === "resource" ? "Resource name is required" : "Stack name is required";
22
- }
23
- if (!isKebabCase(input)) {
24
- return context === "resource"
25
- ? "Use lowercase letters, numbers, and hyphens only"
26
- : "Use kebab-case (lowercase, numbers, hyphens only)";
27
- }
28
- return true;
32
+ const result = validate(input);
33
+ return result.valid ? true : (result.error ?? "Invalid name");
29
34
  };
30
35
  }
31
36
  export function validateOptionalInfraService(service) {
package/cli/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tdk-cli-internal",
3
3
  "private": true,
4
- "version": "1.3.96",
4
+ "version": "1.3.97",
5
5
  "description": "TDK CLI — start services on your laptop.",
6
6
  "keywords": [
7
7
  "tdk",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tdk-landscape/tdk-cli-core",
3
- "version": "1.3.96",
3
+ "version": "1.3.97",
4
4
  "description": "TDK CLI — start services on your laptop.",
5
5
  "homepage": "https://tdk-landscape.github.io/tdk-website",
6
6
  "repository": {