@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 +1 -0
- package/cli/dist/cli.js +8 -0
- package/cli/dist/commands/__tests__/project-e2e-helpers.js +35 -0
- package/cli/dist/commands/resource.js +37 -30
- package/cli/dist/commands/stack.js +4 -2
- package/cli/dist/commands/status.js +2 -1
- package/cli/dist/commands/ui.js +2 -2
- package/cli/dist/commands/up.js +4 -1
- package/cli/dist/frontend-frameworks/registry.js +42 -4
- package/cli/dist/utils/file-helpers.js +7 -1
- package/cli/dist/utils/help-command.js +12 -0
- package/cli/dist/utils/services.js +9 -0
- package/cli/dist/utils/smoke.js +125 -23
- package/cli/dist/utils/validation.js +18 -13
- package/cli/package.json +1 -1
- package/package.json +1 -1
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
|
-
|
|
351
|
-
let
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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,
|
|
383
|
+
const frontendFramework = resolveFrontendFramework(resourceType, frameworkOption, resourceName);
|
|
378
384
|
const backendLanguage = resolveBackendLanguage(resourceType, options.language);
|
|
379
|
-
const backendFramework = resolveBackendFramework(resourceType,
|
|
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) {
|
package/cli/dist/commands/ui.js
CHANGED
|
@@ -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: "
|
|
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 │ [
|
|
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"
|
package/cli/dist/commands/up.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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 = {
|
package/cli/dist/utils/smoke.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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