@tdk-landscape/tdk-cli-core 1.3.121 → 1.3.123

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +13 -8
  3. package/cli/dist/cli.js +1 -1
  4. package/cli/dist/commands/doctor.js +64 -7
  5. package/cli/dist/commands/help.js +17 -1
  6. package/cli/dist/commands/logs.js +28 -7
  7. package/cli/dist/commands/stack.js +94 -22
  8. package/cli/dist/commands/ui.js +81 -6
  9. package/cli/dist/commands/up.js +17 -3
  10. package/cli/dist/components/TabBar.js +3 -2
  11. package/cli/dist/generator/__tests__/golden-helpers.js +42 -0
  12. package/cli/dist/generator/template-engine.js +1 -1
  13. package/cli/dist/utils/bun-floor.js +8 -0
  14. package/cli/dist/utils/cold-preflight.js +6 -4
  15. package/cli/dist/utils/doctor-runtime.js +4 -1
  16. package/cli/dist/utils/doctor-wiring.js +62 -1
  17. package/cli/dist/utils/host-port-plan.js +3 -0
  18. package/cli/dist/utils/port-conflict-fix.js +24 -0
  19. package/cli/dist/utils/port-listener-probe.js +19 -0
  20. package/cli/dist/utils/secret-redaction.js +55 -0
  21. package/cli/dist/utils/service-config-checks.js +188 -0
  22. package/cli/dist/utils/startup-report.js +6 -1
  23. package/cli/dist/utils/tilt-events.js +195 -0
  24. package/cli/dist/utils/tilt-unreachable-message.js +8 -0
  25. package/cli/package.json +1 -1
  26. package/cli/templates/TILT_TECH_STACK.star.hbs +4 -4
  27. package/engine/topologies/platform/docker/compose/compose.star +1 -86
  28. package/engine/topologies/platform/docker/compose/traefik_standalone.star +4 -4
  29. package/engine/topologies/platform/docker/networking/traefik.star +40 -7
  30. package/engine/topologies/platform/docker/networking/traefik_helpers.star +1 -1
  31. package/engine/topologies/tilt/resources/infra-loader.star +3 -2
  32. package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +20 -0
  33. 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 stack` can assign named resources in scripts with `--resources` (space- or comma-separated) and skip confirmation with `--yes`. Unknown names list valid resources, and non-TTY assignments without explicit resource names fail with a command hint. Invalid JSON encountered while writing now names the affected `service.json`.
7
8
  - `tdk eject` no longer claims files it does not create or prints a command that cannot work. It still writes only `EJECTED.md` (no file is copied, moved or generated; the Tiltfile and engine stay in git-ignored `.tdk/.tdk-out/`, and the per-service Compose files, golden-layer Dockerfile and Traefik file only exist after Tilt has run). `EJECTED.md` no longer lists them under "Keep" and says the Tiltfile still reads `.tdk/project.json` and the `service.json` files. `Next: tilt up` (which finds no Tiltfile at the project root) is replaced by `tilt up -f .tdk/.tdk-out/Tiltfile -- --focus=<stack>`, the form `tdk up` runs. A project that already has an `EJECTED.md` keeps its old text (the file is never overwritten) and is now told so. `--dry-run` headings read `Generated files, left where they are (git-ignored):` and `Files written:`. A command that exports plain Compose files and Dockerfiles does not exist. Fixes #610.
8
9
  - `tdk config regenerate` now restores a root `.tiltignore` that TDK wrote (it carries the `SYSTEM-GENERATED BY TDK CLI` header) and that was edited by hand, and prints `Restored: .tiltignore → project root (hand edits discarded)`. Before, it only copied the file when none existed, so `tdk config verify` kept failing with `Out of sync: .tiltignore (run 'tdk config regenerate')` after the advice was followed. A root `.tiltignore` without the header is still never overwritten, and plain `tdk project --yes` still does not overwrite an existing one. Docs: `docs/adopt-tdk.md` now quotes the current `tdk doctor` unknown-field wording.
9
10
  - `tdk doctor` now checks the project's own resource configs for duplicates. Two resources with the same `appName` fail (`Duplicate resource names`, naming both `service.json` paths): the name becomes the Compose service and Traefik router, so they collide, and before nothing reported it. Two backend, mcp or routed bring-your-own resources on the same `port` give a warning, not a failure (`Duplicate resource ports`): service ports are not published on the host and each service has its own container, so the pair can run, but it is usually a copied `service.json`. Frontends, workers, libraries and a missing `port` are ignored. Fixes #76.
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  English | [简体中文](README-zh_cn.md) | [繁體中文](README-zh_tw.md) | [日本語](README-ja.md) | [한국어](README-ko.md)
15
15
 
16
- TDK CLI starts your services on your laptop. It is not a deploy and not a Compose file: define each service in `service.json`, then run `tdk up`. No Kubernetes is needed on the machine.
16
+ TDK CLI — start services on your laptop. It is not a deploy and not a Compose file: define each service in `service.json`, then run `tdk up`. No Kubernetes is needed on the machine.
17
17
 
18
18
  Stability: 1.x local dev. Write `service.json`; TDK generates the local runtime files. See [what TDK writes](docs/generated-files.md) and how to check for drift. Core CLI is MIT and needs no key. Premium is optional.
19
19
 
@@ -32,9 +32,11 @@ mkdir shop && cd shop
32
32
  tdk project --yes
33
33
  tdk resource orders-api --type backend --stack shop --yes
34
34
  tdk up shop
35
- curl http://api.shop.localhost/api/orders-api/health
35
+ curl http://api.shop.localhost:8080/api/orders/health
36
36
  ```
37
37
 
38
+ `tdk up` prints the host ports it chose. Port 8080 is the default; if it was taken, use the HTTP port `tdk up` reports (or set `TDK_HTTP_PORT`). A resource named `orders-api` is served at `/api/orders` (a trailing `-api` is dropped); `tdk up` prints the exact URLs.
39
+
38
40
  ![TDK scaffolding a backend and a frontend, then listing the stack](docs/assets/demo.svg)
39
41
 
40
42
  *Scaffolding a backend and frontend, then listing the stack.*
@@ -63,6 +65,13 @@ If Helm, Compose, or your existing Tilt setup already gives you a working local
63
65
 
64
66
  See [how TDK CLI works alongside Helm](https://tdk-landscape.github.io/tdk-website/docs/with-helm/), the [service schema](engine/schemas/service-schema.json), and the [project configuration schema](engine/schemas/project-schema.json).
65
67
 
68
+ ## Terminal UI
69
+
70
+ Use `tdk ui` to inspect stacks and resources. Arrow keys or `j`/`k` move the
71
+ selection; `g`/`G` or Home/End jump to the first/last item, and PageUp/PageDown
72
+ move one visible page. `/` searches; navigation letters remain search text
73
+ while searching. The selected row stays visible when the terminal is resized.
74
+
66
75
  ## Installation
67
76
 
68
77
  Install the CLI from npm (requires Node.js 22.12+) or use the prebuilt binary (no Node.js or Bun required):
@@ -83,6 +92,7 @@ curl -fsSL https://tdk-landscape.github.io/install.sh | sh
83
92
 
84
93
  ## Docs
85
94
 
95
+ - [CLI command cheat sheet](cli/README.md#cheat-sheet)
86
96
  - [Documentation index](docs/README.md)
87
97
  - [Pilot TDK on a real repository](docs/adopt-tdk.md)
88
98
  - [FAQ for agencies and multi-client teams](docs/faq-teams.md)
@@ -121,7 +131,7 @@ published on 21 September 2026. Those dates describe the current repository and
121
131
 
122
132
  ## Requirements and support
123
133
 
124
- For the local runtime, install Docker (Desktop, OrbStack, or Colima; Engine 25+, Compose 2.20+) and [Tilt](https://docs.tilt.dev/install.html). Bun 1.2+ is used by the default generated services. TDK selects host ports from bounded fallback ranges for HTTP, HTTPS, and Postgres; set `TDK_HTTP_PORT`, `TDK_HTTPS_PORT`, or `TDK_POSTGRES_PORT` to override them. TDK supports macOS, Linux, and Windows through WSL2 Ubuntu; native Windows supports CLI inspection only. Run `tdk doctor` to check local readiness (supports `--no-ping` to skip service health checks and `--ping-timeout <ms>`). See [WSL2 setup](docs/wsl2.md).
134
+ For the local runtime, install Docker (Desktop, OrbStack, or Colima; Engine 25+, Compose 2.20.2+) and [Tilt](https://docs.tilt.dev/install.html). Bun 1.2+ is used by the default generated services. TDK selects host ports from bounded fallback ranges for HTTP, HTTPS, and Postgres; set `TDK_HTTP_PORT`, `TDK_HTTPS_PORT`, or `TDK_POSTGRES_PORT` to override them. Dev ports listen on `127.0.0.1` only, so other machines on your network cannot reach them. Set `TDK_BIND_ADDRESS` (for example `0.0.0.0`, to test from a phone) to change that; `tdk doctor` warns when it is a wildcard address. TDK supports macOS, Linux, and Windows through WSL2 Ubuntu; native Windows supports CLI inspection only. Run `tdk doctor` to check local readiness (supports `--no-ping` to skip service health checks and `--ping-timeout <ms>`). See [WSL2 setup](docs/wsl2.md).
125
135
 
126
136
  On native Windows, `tdk --version`, `tdk doctor`, and `tdk up --dry-run` are inspect-only commands. `tdk up` exits 2 with “Landscape startup needs Ubuntu on WSL2. Native Windows is inspect-only.”
127
137
 
@@ -144,8 +154,3 @@ Contributions are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md) and the
144
154
  TDK is MIT-licensed; see [LICENSE](LICENSE). The [license boundary](GOVERNANCE.md#license-boundary-and-donation-scope) says which code is MIT and which is downloaded with a key.
145
155
 
146
156
  Project governance: [GOVERNANCE.md](GOVERNANCE.md), [MAINTAINERS.md](MAINTAINERS.md), [ADOPTERS.md](ADOPTERS.md).
147
-
148
- Use `tdk ui` to inspect stacks and resources. Arrow keys or `j`/`k` move the
149
- selection; `g`/`G` or Home/End jump to the first/last item, and PageUp/PageDown
150
- move one visible page. `/` searches; navigation letters remain search text
151
- while searching. The selected row stays visible when the terminal is resized.
package/cli/dist/cli.js CHANGED
@@ -30,7 +30,7 @@ import { unknownHelpTarget } from "./utils/help-command.js";
30
30
  const program = new Command();
31
31
  program
32
32
  .name("tdk")
33
- .description("Tilt Development Kit - Project/Stack/Resource management")
33
+ .description("TDK CLI — start services on your laptop.")
34
34
  .version(pkg.version, "-v, --version", "Display version number")
35
35
  .option("--verbose", "Enable verbose output", false)
36
36
  .configureOutput({
@@ -9,7 +9,7 @@ import { MASTER_CONFIG_FILES, REQUIRED_PACKAGE_SCRIPTS } from "../utils/constant
9
9
  import { isPathDiscovered, readDiscoveryPaths } from "../utils/discovery-paths.js";
10
10
  import { collectDoctorChecks, createDoctorReport, getDoctorExitCode, } from "../utils/doctor-report.js";
11
11
  import { checkHealthRoutes, checkIngressPorts, checkPrivateNpmRegistry, checkTiltResourceHealth, projectConfigEnablesVerdaccio, summarizeServiceProbes, } from "../utils/doctor-runtime.js";
12
- import { checkDockerNetworkCapacity, checkDuplicateResourceNames, checkDuplicateResourcePorts, checkFrontendBackendUrls, checkMigrationsInApi, checkNatsBroker, checkPrismaConsistency, checkResourcePackageJson, checkServiceUrlPorts, checkSharedPlatformPostgres, checkTiltInstances, } from "../utils/doctor-wiring.js";
12
+ import { checkDockerNetworkCapacity, checkDuplicateResourceNames, checkDuplicateResourcePorts, checkFrontendBackendUrls, checkMigrationsInApi, checkNatsBroker, checkPrismaConsistency, checkResourcePackageJson, checkServiceUrlPorts, checkSharedPlatformPostgres, checkSharedStackRoutes, checkTiltInstances, } from "../utils/doctor-wiring.js";
13
13
  import { validateEnvFile } from "../utils/env-validator.js";
14
14
  import { execAsync, isExecTimeout } from "../utils/exec-async.js";
15
15
  import { formatCount } from "../utils/formatting.js";
@@ -17,6 +17,8 @@ import { getHostPortPlan } from "../utils/host-port-config.js";
17
17
  import { createHostPortPlan } from "../utils/host-port-plan.js";
18
18
  import { findProjectRoot, getPackageVersion } from "../utils/paths.js";
19
19
  import { isApiServiceType } from "../utils/resource-kind.js";
20
+ import { EnvUnreadableError, envSecretValues, redactSecrets, redactValue, } from "../utils/secret-redaction.js";
21
+ import { checkCircularDependencies, checkDependsOnShape, checkSchemaVersions, checkServicePorts, } from "../utils/service-config-checks.js";
20
22
  import { buildHealthTargets, pingHealthTargets } from "../utils/service-urls.js";
21
23
  import { discoverResourcesFromRoot } from "../utils/services.js";
22
24
  import { evaluateTdkVersionFloor } from "../utils/tdk-version.js";
@@ -70,6 +72,40 @@ export function checkWslProjectLocation(projectPath, strict, isWsl = process.pla
70
72
  fix: "Move the repository under your WSL home directory (for example, ~/projects) and retry tdk doctor.",
71
73
  };
72
74
  }
75
+ /** True for IPv4 0.0.0.0 and any IPv6 unspecified form (::, 0:0::0, [::], ...). */
76
+ function isWildcardBindAddress(value) {
77
+ const host = value.replace(/^\[(.*)\]$/, "$1");
78
+ if (host === "0.0.0.0")
79
+ return true;
80
+ // IPv4-embedded unspecified address, e.g. ::0.0.0.0
81
+ if (/^[0:]*:0\.0\.0\.0$/.test(host))
82
+ return true;
83
+ return host.includes(":") && /^[0:]+$/.test(host);
84
+ }
85
+ /**
86
+ * Dev ports (Traefik, Postgres) bind to 127.0.0.1 by default. TDK_BIND_ADDRESS
87
+ * opts in to another address; a wildcard exposes them to every machine on the
88
+ * network (GHSA-3hj3-f39v-j2x5).
89
+ */
90
+ export function checkPublishedBindAddress(env = process.env, strict = false) {
91
+ const bindAddress = (env.TDK_BIND_ADDRESS ?? "").trim();
92
+ if (!isWildcardBindAddress(bindAddress)) {
93
+ return {
94
+ name: "Dev Port Bind Address",
95
+ didPass: true,
96
+ message: bindAddress
97
+ ? `Dev ports bind to ${bindAddress}`
98
+ : "Dev ports bind to 127.0.0.1 (this machine only)",
99
+ };
100
+ }
101
+ return {
102
+ name: "Dev Port Bind Address",
103
+ didPass: !strict,
104
+ isWarning: !strict,
105
+ message: `TDK_BIND_ADDRESS is ${bindAddress}: Traefik and Postgres are reachable from other machines on the network.`,
106
+ fix: "Unset TDK_BIND_ADDRESS to keep dev ports on 127.0.0.1. Keep it only when you test from another device on purpose.",
107
+ };
108
+ }
73
109
  /** Failures are shown before passing statuses, with a 5432 conflict first. */
74
110
  export function orderDoctorResults(results) {
75
111
  const failures = results.filter((result) => !result.didPass && !result.isSkipped && !result.isWarning);
@@ -286,8 +322,8 @@ export async function checkTilt(exec = execAsync) {
286
322
  return {
287
323
  name: "Tilt CLI",
288
324
  didPass: false,
289
- message: `Tilt ${raw || "version unknown"} is below the required v0.25.0 floor`,
290
- fix: "Install Tilt v0.25.0 or newer: https://docs.tilt.dev/install.html",
325
+ message: `Tilt ${raw || "version unknown"} is below the required v${MIN_TILT_VERSION.join(".")} floor`,
326
+ fix: `Install Tilt v${MIN_TILT_VERSION.join(".")} or newer: https://docs.tilt.dev/install.html`,
291
327
  };
292
328
  }
293
329
  return { name: "Tilt CLI", didPass: true, message: `Tilt ${raw} is available` };
@@ -345,8 +381,8 @@ export async function checkBun(exec = execAsync) {
345
381
  return {
346
382
  name: "Bun",
347
383
  didPass: false,
348
- message: `Bun ${rawVersion || "version unknown"} is below the required 1.2.0 floor`,
349
- fix: "Install Bun 1.2.0 or newer: https://bun.sh/install",
384
+ message: `Bun ${rawVersion || "version unknown"} is below the required ${MIN_BUN_VERSION.join(".")} floor`,
385
+ fix: `Install Bun ${MIN_BUN_VERSION.join(".")} or newer: https://bun.sh/install`,
350
386
  };
351
387
  }
352
388
  return {
@@ -1122,6 +1158,7 @@ export const doctorCommand = new Command("doctor")
1122
1158
  checkDockerVersions,
1123
1159
  checkBun,
1124
1160
  () => checkWslProjectLocation(findProjectRoot() ?? process.cwd(), options.strict),
1161
+ () => checkPublishedBindAddress(process.env, options.strict),
1125
1162
  // Each project needs several networks; a full address pool fails `tdk up` late.
1126
1163
  () => checkDockerNetworkCapacity(),
1127
1164
  ];
@@ -1139,9 +1176,14 @@ export const doctorCommand = new Command("doctor")
1139
1176
  // Wiring mistakes that otherwise surface minutes into `tdk up`.
1140
1177
  () => checkResourcePackageJson(),
1141
1178
  () => checkDuplicateResourceNames(),
1179
+ () => checkDependsOnShape(),
1180
+ () => checkCircularDependencies(),
1181
+ () => checkServicePorts(),
1182
+ () => checkSchemaVersions(undefined, options.strict),
1142
1183
  () => checkDuplicateResourcePorts(),
1143
1184
  () => checkServiceUrlPorts(),
1144
1185
  () => checkFrontendBackendUrls(),
1186
+ () => checkSharedStackRoutes(),
1145
1187
  () => checkNatsBroker(),
1146
1188
  () => checkPrismaConsistency(),
1147
1189
  // Shared platform Postgres: will-start report + unknown dependsOn names stay errors.
@@ -1182,9 +1224,24 @@ export const doctorCommand = new Command("doctor")
1182
1224
  const exitCode = getDoctorExitCode(report);
1183
1225
  const allPassed = report.data.ready;
1184
1226
  if (options.json) {
1185
- console.log(JSON.stringify(report));
1186
- for (const error of errors)
1227
+ let secrets;
1228
+ try {
1229
+ secrets = envSecretValues(findProjectRoot() ?? process.cwd());
1230
+ }
1231
+ catch (error) {
1232
+ if (!(error instanceof EnvUnreadableError))
1233
+ throw error;
1234
+ // Without the values the report cannot be published safely, so it is replaced by a structured error.
1235
+ console.log(JSON.stringify(createDoctorReport([], inProject, [
1236
+ ...errors,
1237
+ { code: "ENV_UNREADABLE", message: error.message },
1238
+ ])));
1187
1239
  console.error(error.message);
1240
+ process.exit(1);
1241
+ }
1242
+ console.log(JSON.stringify(redactValue(report, secrets)));
1243
+ for (const error of errors)
1244
+ console.error(redactSecrets(error.message, secrets));
1188
1245
  if (exitCode !== 0)
1189
1246
  process.exit(exitCode);
1190
1247
  return;
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import pkg from "../../package.json" with { type: "json" };
3
3
  const TDK_BANNER = `
4
- ${chalk.bold.cyan("tdk")} ${chalk.gray(`v${pkg.version}`)} ${chalk.white("TDK runs many services locally with Docker + Tilt; Helm deploys the cluster")}
4
+ ${chalk.bold.cyan("tdk")} ${chalk.gray(`v${pkg.version}`)} ${chalk.white("TDK CLI — start services on your laptop.")}
5
5
  `;
6
6
  const COMMAND_GROUPS = [
7
7
  {
@@ -14,6 +14,16 @@ const COMMAND_GROUPS = [
14
14
  alias: "",
15
15
  },
16
16
  { name: "projects, info", desc: "Show project information and config status", alias: "" },
17
+ {
18
+ name: "import",
19
+ desc: "Import the services a directory describes",
20
+ alias: "[dir] --dry-run --yes",
21
+ },
22
+ {
23
+ name: "eject",
24
+ desc: "Write EJECTED.md describing the generated Tilt files",
25
+ alias: "--dry-run --yes",
26
+ },
17
27
  ],
18
28
  },
19
29
  {
@@ -39,6 +49,7 @@ const COMMAND_GROUPS = [
39
49
  { name: "up, deploy", desc: "Start services (optionally by stack)", alias: "[stack-name]" },
40
50
  { name: "down", desc: "Stop all tilt resources", alias: "" },
41
51
  { name: "status", desc: "Show status of services and stacks", alias: "" },
52
+ { name: "logs", desc: "Print recent logs from the running stack", alias: "--json" },
42
53
  ],
43
54
  },
44
55
  {
@@ -63,6 +74,7 @@ const COMMAND_GROUPS = [
63
74
  alias: "regenerate, verify, edit",
64
75
  },
65
76
  { name: "doctor", desc: "Check environment readiness", alias: "" },
77
+ { name: "mcp", desc: "Run an MCP server over stdio for coding agents", alias: "" },
66
78
  {
67
79
  name: "runtime",
68
80
  desc: "Inspect packaged engine and template assets",
@@ -75,6 +87,10 @@ const COMMAND_GROUPS = [
75
87
  ],
76
88
  },
77
89
  ];
90
+ /** Every command name and alias that the help table lists. */
91
+ export function listedCommandNames() {
92
+ return COMMAND_GROUPS.flatMap((group) => group.commands.flatMap((command) => command.name.split(",").map((name) => name.trim())));
93
+ }
78
94
  function formatCommand(name, desc, alias, color) {
79
95
  const nameFormatted = color(name.padEnd(20));
80
96
  const descFormatted = chalk.white(desc);
@@ -2,8 +2,11 @@ import { Command } from "commander";
2
2
  import { STANDARD_PORTS } from "../utils/constants.js";
3
3
  import { errorFactories, runCommand, showErrorAndExit, TdkError } from "../utils/errors.js";
4
4
  import { createMachineEnvelope } from "../utils/machine-output.js";
5
+ import { findProjectRoot } from "../utils/paths.js";
6
+ import { EnvUnreadableError, envSecretValues, redactSecrets, redactValue, } from "../utils/secret-redaction.js";
5
7
  import { isTiltAvailable, runTilt } from "../utils/tilt.js";
6
8
  import { DEFAULT_LOG_TAIL, isTiltConnectionFailure, isValidPort, isValidSince, isValidTail, MAX_LOG_TAIL, parseTiltLogLines, } from "../utils/tilt-logs.js";
9
+ import { tiltUnreachableMessage } from "../utils/tilt-unreachable-message.js";
7
10
  /** Resource names Tilt knows about, or null when they cannot be listed (the logs call then reports the real error). */
8
11
  async function tiltResourceNames(port) {
9
12
  try {
@@ -28,16 +31,24 @@ export const logsCommand = new Command("logs")
28
31
  .option("--port <n>", "Tilt UI port (default: TILT_PORT or 10350)")
29
32
  .option("--json", "Output one JSON object and exit", false)
30
33
  .action(async (options) => {
31
- const fail = (code, message, exitCode = 1, suggestions = []) => {
34
+ let secrets = [];
35
+ const fail = (code, rawMessage, exitCode = 1, suggestions = []) => {
36
+ const message = redactSecrets(rawMessage, secrets);
37
+ const redactedSuggestions = redactValue(suggestions, secrets);
32
38
  if (options.json) {
33
39
  console.log(JSON.stringify(createMachineEnvelope(null, [
34
- { code, message, ...(suggestions.length > 0 ? { suggestions } : {}) },
40
+ {
41
+ code,
42
+ message,
43
+ ...(redactedSuggestions.length > 0 ? { suggestions: redactedSuggestions } : {}),
44
+ },
35
45
  ])));
36
46
  console.error(message);
37
47
  process.exit(exitCode);
38
48
  }
39
- if (suggestions.length > 0)
40
- return new TdkError(message, suggestions, exitCode).exit();
49
+ if (redactedSuggestions.length > 0) {
50
+ return new TdkError(message, redactedSuggestions, exitCode).exit();
51
+ }
41
52
  return showErrorAndExit(message, exitCode);
42
53
  };
43
54
  if (!isValidTail(options.tail))
@@ -50,6 +61,16 @@ export const logsCommand = new Command("logs")
50
61
  fail("USAGE", "--port must be a valid port number", 2);
51
62
  const tail = Number(options.tail);
52
63
  const services = options.service ?? [];
64
+ try {
65
+ secrets = envSecretValues(findProjectRoot() ?? process.cwd());
66
+ }
67
+ catch (error) {
68
+ if (!(error instanceof EnvUnreadableError))
69
+ throw error;
70
+ fail("ENV_UNREADABLE", error.message, 1, [
71
+ "Fix the read permission on .env, or move it out of the project",
72
+ ]);
73
+ }
53
74
  const action = async () => {
54
75
  if (!(await isTiltAvailable())) {
55
76
  fail("TILT_MISSING", "Tilt is not installed. Run: tdk doctor");
@@ -75,16 +96,16 @@ export const logsCommand = new Command("logs")
75
96
  if (result.exitCode !== 0) {
76
97
  const detail = result.stderr.trim();
77
98
  if (!detail || isTiltConnectionFailure(detail)) {
78
- fail("TILT_NOT_RUNNING", `Could not reach Tilt on port ${portText}: ${detail || "is the stack running? Start it with: tdk up"}`);
99
+ fail("TILT_NOT_RUNNING", tiltUnreachableMessage(portText));
79
100
  }
80
101
  fail("TILT_LOGS_FAILED", `tilt logs failed: ${detail}`);
81
102
  }
82
103
  if (!options.json) {
83
- process.stdout.write(result.stdout);
104
+ process.stdout.write(redactSecrets(result.stdout, secrets));
84
105
  return;
85
106
  }
86
107
  const lines = parseTiltLogLines(result.stdout, tail);
87
- console.log(JSON.stringify(createMachineEnvelope({ services, tail, since: options.since ?? null, lines })));
108
+ console.log(JSON.stringify(redactValue(createMachineEnvelope({ services, tail, since: options.since ?? null, lines }), secrets)));
88
109
  };
89
110
  await runCommand(action);
90
111
  });
@@ -3,21 +3,39 @@ import chalk from "chalk";
3
3
  import { Command } from "commander";
4
4
  import { assertValid, confirmOrCancel } from "../utils/command-helpers.js";
5
5
  import { clearDiscoveryCache, createDiscoveryContext } from "../utils/discovery-context.js";
6
- import { requireProjectRoot, runCommand } from "../utils/errors.js";
6
+ import { errorFactories, requireProjectRoot, runCommand, TdkError } 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
10
  import { createKebabCaseValidator, validateStackName } from "../utils/validation.js";
11
+ export function parseStackResourceConfig(content, configPath) {
12
+ try {
13
+ return JSON.parse(content);
14
+ }
15
+ catch (error) {
16
+ const reason = error instanceof Error ? error.message : String(error);
17
+ throw new TdkError(`Could not parse service.json at ${configPath}: ${reason}`);
18
+ }
19
+ }
20
+ function normalizeResourceNames(names) {
21
+ const normalizedNames = names.flatMap((name) => name
22
+ .split(",")
23
+ .map((part) => part.trim())
24
+ .filter(Boolean));
25
+ return [...new Set(normalizedNames)];
26
+ }
11
27
  export const stackCommand = new Command("stack")
12
28
  .description("Organize resources into stacks (groups)")
13
29
  .argument("[stack-name]", "Stack name to assign to resources")
14
30
  .option("--list", "List resources without a stack", false)
31
+ .option("--resources <names...>", "Resource names to add (comma- or space-separated)")
32
+ .option("--yes", "Skip confirmation prompt", false)
15
33
  .action(async (stackName, options) => {
16
34
  await runCommand(async () => {
17
35
  requireProjectRoot();
18
36
  showCommandHeader("Stack Management");
19
37
  const discovery = createDiscoveryContext();
20
- if (discovery.resources.length === 0) {
38
+ if (discovery.resources.length === 0 && options.resources === undefined) {
21
39
  console.log(chalk.yellow("No resources discovered. Make sure you're in a project with service.json files."));
22
40
  return;
23
41
  }
@@ -42,6 +60,24 @@ export const stackCommand = new Command("stack")
42
60
  }
43
61
  return;
44
62
  }
63
+ if (!process.stdin.isTTY) {
64
+ if (options.resources === undefined) {
65
+ throw new TdkError("Interactive resource selection requires a TTY.", [
66
+ "Pass --resources <name...> to select resources without a prompt.",
67
+ "Use --list to inspect unassigned resources without changing them.",
68
+ ]);
69
+ }
70
+ if (!stackName) {
71
+ throw new TdkError("A stack name is required when stdin is not a TTY.", [
72
+ "Use: tdk stack <stack-name> --resources <name...> [--yes]",
73
+ ]);
74
+ }
75
+ if (!options.yes) {
76
+ throw new TdkError("Confirmation requires a TTY.", [
77
+ "Pass --yes to skip confirmation when assigning resources without a TTY.",
78
+ ]);
79
+ }
80
+ }
45
81
  if (stackName)
46
82
  assertValid(validateStackName(stackName));
47
83
  let targetStack = stackName;
@@ -56,28 +92,64 @@ export const stackCommand = new Command("stack")
56
92
  targetStack = name;
57
93
  }
58
94
  const resourcesToUpdate = discovery.unassignedResources;
59
- if (resourcesToUpdate.length === 0) {
60
- console.log(chalk.yellow("\nNo resources available to add to this stack."));
61
- return;
95
+ const selectedResources = [];
96
+ if (options.resources !== undefined) {
97
+ const requestedNames = normalizeResourceNames(options.resources);
98
+ if (requestedNames.length === 0) {
99
+ throw new TdkError("Pass at least one resource name to --resources.", [
100
+ "Example: `tdk stack api --resources users-api orders-api --yes`",
101
+ ]);
102
+ }
103
+ const configPathsByName = new Map();
104
+ for (const resource of discovery.resources) {
105
+ const configPaths = configPathsByName.get(resource.name) ?? [];
106
+ configPaths.push(resource.configPath);
107
+ configPathsByName.set(resource.name, configPaths);
108
+ }
109
+ const duplicates = [...configPathsByName].filter(([, configPaths]) => configPaths.length > 1);
110
+ if (duplicates.length > 0) {
111
+ throw new TdkError("Cannot assign resources while duplicate names are present.", duplicates.map(([name, configPaths]) => `Duplicate "${name}" found in ${configPaths.join(" and ")}`));
112
+ }
113
+ const validNames = [...configPathsByName.keys()].sort();
114
+ const resourceByName = new Map(discovery.resources.map((resource) => [resource.name, resource]));
115
+ for (const name of requestedNames) {
116
+ const resource = resourceByName.get(name);
117
+ if (!resource) {
118
+ const error = errorFactories.resourceNotFound(name, validNames);
119
+ error.suggestions.unshift(`Valid resources: ${validNames.join(", ")}`);
120
+ throw error;
121
+ }
122
+ if (resource.stack) {
123
+ throw new TdkError(`Resource "${name}" is already assigned to stack "${resource.stack}".`, ["Run `tdk resources` to review current assignments."]);
124
+ }
125
+ selectedResources.push(resource.configPath);
126
+ }
62
127
  }
63
- const selectedResources = await promptMultiSelect({
64
- message: `Select resources to add to stack "${targetStack}":`,
65
- choices: resourcesToUpdate.map((r) => ({
66
- title: r.name,
67
- value: r.configPath,
68
- })),
69
- validate: (input) => {
70
- if (input.length === 0)
71
- return "Select at least one resource";
72
- return true;
73
- },
74
- });
75
- if (selectedResources.length === 0) {
76
- console.log(chalk.yellow("No resources selected. Exiting."));
77
- return;
128
+ else {
129
+ if (resourcesToUpdate.length === 0) {
130
+ console.log(chalk.yellow("\nNo resources available to add to this stack."));
131
+ return;
132
+ }
133
+ const promptedResources = await promptMultiSelect({
134
+ message: `Select resources to add to stack "${targetStack}":`,
135
+ choices: resourcesToUpdate.map((resource) => ({
136
+ title: resource.name,
137
+ value: resource.configPath,
138
+ })),
139
+ validate: (input) => {
140
+ if (input.length === 0)
141
+ return "Select at least one resource";
142
+ return true;
143
+ },
144
+ });
145
+ if (promptedResources.length === 0) {
146
+ console.log(chalk.yellow("No resources selected. Exiting."));
147
+ return;
148
+ }
149
+ selectedResources.push(...promptedResources);
78
150
  }
79
151
  showDetail(`\nWill add "stack": "${targetStack}" to ${formatCount(selectedResources.length, "resource")}.`, 0);
80
- const confirmed = await confirmOrCancel("Proceed?");
152
+ const confirmed = options.yes || (await confirmOrCancel("Proceed?"));
81
153
  if (!confirmed)
82
154
  return;
83
155
  // Clear cache since we're about to modify resources
@@ -85,7 +157,7 @@ export const stackCommand = new Command("stack")
85
157
  let updated = 0;
86
158
  for (const configPath of selectedResources) {
87
159
  const content = readFileSync(configPath, "utf-8");
88
- const config = JSON.parse(content);
160
+ const config = parseStackResourceConfig(content, configPath);
89
161
  config.stack = targetStack;
90
162
  writeJsonFile(configPath, config);
91
163
  updated++;