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

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/README.md +9 -7
  2. package/cli/dist/commands/config.js +6 -2
  3. package/cli/dist/commands/logs.js +10 -4
  4. package/cli/dist/commands/networks.js +1 -1
  5. package/cli/dist/commands/resources.js +1 -1
  6. package/cli/dist/commands/status.js +35 -2
  7. package/cli/dist/commands/ui.js +25 -4
  8. package/cli/dist/commands/up.js +89 -26
  9. package/cli/dist/components/ServiceIssues.js +12 -0
  10. package/cli/dist/components/index.js +1 -0
  11. package/cli/dist/generator/template-engine.js +4 -2
  12. package/cli/dist/utils/atomic-write.js +35 -0
  13. package/cli/dist/utils/doctor-runtime.js +2 -5
  14. package/cli/dist/utils/errors.js +20 -2
  15. package/cli/dist/utils/machine-output.js +8 -1
  16. package/cli/dist/utils/port-fallback-notice.js +18 -0
  17. package/cli/dist/utils/service-runtime-state.js +67 -0
  18. package/cli/dist/utils/services.js +2 -15
  19. package/cli/dist/utils/single-flight.js +20 -0
  20. package/cli/dist/utils/stack-status.js +15 -0
  21. package/cli/dist/utils/startup-report.js +68 -0
  22. package/cli/dist/utils/suggestions.js +58 -0
  23. package/cli/dist/utils/tilt-resource-state.js +12 -0
  24. package/cli/dist/utils/tilt-startup.js +7 -0
  25. package/cli/dist/utils/ui-service-state.js +35 -0
  26. package/cli/dist/utils/up-readiness.js +2 -0
  27. package/cli/package.json +1 -1
  28. package/cli/templates/.tiltignore.hbs +1 -2
  29. package/cli/templates/TILT_RESOURCE_DEFAULTS.star.hbs +1 -1
  30. package/cli/templates/TILT_TECH_STACK.star.hbs +1 -1
  31. package/cli/templates/Tiltfile.hbs +3 -3
  32. package/cli/templates/spec.master.hbs +1 -1
  33. package/package.json +1 -1
package/README.md CHANGED
@@ -110,16 +110,18 @@ curl -fsSL https://tdk-landscape.github.io/install.sh | sh
110
110
 
111
111
  ## History
112
112
 
113
- TDK is older than this repository and its npm package suggest. Development started on 21 April 2026 in
114
- [tdk-landscape/tdk](https://github.com/tdk-landscape/tdk), now archived and kept as read-only history; its first commit is
115
- [`1714637`](https://github.com/tdk-landscape/tdk/commit/1714637637196ed54cbfa18534230a782c05ab36) ("Initial commit: TDK specs,
116
- generators, CLI, and standards"). This repository, `tdk-cli-core`, was created on 19 September 2026 and is where development
117
- continues; the `@tdk-landscape/tdk-cli-core` package on npm was first published on 21 September 2026. So the code lineage is about
118
- five months older than the repository and package dates that you will see on GitHub and npm.
113
+ TDK development began on 21 April 2026. The archived, read-only [tdk-landscape/tdk](https://github.com/tdk-landscape/tdk)
114
+ repository records its [first commit](https://github.com/tdk-landscape/tdk/commit/1714637637196ed54cbfa18534230a782c05ab36)
115
+ ("Initial commit: TDK specs, generators, CLI, and standards") from that day. A separate `tdk-cli` history spanning 21 April to
116
+ 18 September 2026 was [imported into this repository](https://github.com/tdk-landscape/tdk-cli-core/pull/670) as 306 filtered
117
+ commits. Those commits are now ancestors of `main`; the import did not change the source tree.
118
+
119
+ This `tdk-cli-core` repository was created on 19 September 2026, and the `@tdk-landscape/tdk-cli-core` npm package was first
120
+ published on 21 September 2026. Those dates describe the current repository and package, not the start of TDK development.
119
121
 
120
122
  ## Requirements and support
121
123
 
122
- 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. See [WSL2 setup](docs/wsl2.md).
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).
123
125
 
124
126
  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.”
125
127
 
@@ -244,8 +244,12 @@ export const configCommand = new Command("config")
244
244
  }
245
245
  for (const { diff } of result.diffs)
246
246
  console.log(chalk.gray(`\n${diff}`));
247
- if (!result.valid) {
248
- console.log(chalk.gray("\nRun `tdk config regenerate` to fix."));
247
+ const schemaErrors = result.errors.filter((error) => error.includes(".schemaVersion:"));
248
+ if (schemaErrors.length > 0) {
249
+ console.log(chalk.gray("\nRun `tdk config migrate` to fix schemaVersion."));
250
+ }
251
+ if (result.diffs.length > 0) {
252
+ console.log(chalk.gray("\nRun `tdk config regenerate` to fix generated files."));
249
253
  }
250
254
  process.exit(1);
251
255
  }
@@ -1,6 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { STANDARD_PORTS } from "../utils/constants.js";
3
- import { runCommand, showErrorAndExit } from "../utils/errors.js";
3
+ import { errorFactories, runCommand, showErrorAndExit, TdkError } from "../utils/errors.js";
4
4
  import { createMachineEnvelope } from "../utils/machine-output.js";
5
5
  import { isTiltAvailable, runTilt } from "../utils/tilt.js";
6
6
  import { DEFAULT_LOG_TAIL, isTiltConnectionFailure, isValidPort, isValidSince, isValidTail, MAX_LOG_TAIL, parseTiltLogLines, } from "../utils/tilt-logs.js";
@@ -28,12 +28,16 @@ export const logsCommand = new Command("logs")
28
28
  .option("--port <n>", "Tilt UI port (default: TILT_PORT or 10350)")
29
29
  .option("--json", "Output one JSON object and exit", false)
30
30
  .action(async (options) => {
31
- const fail = (code, message, exitCode = 1) => {
31
+ const fail = (code, message, exitCode = 1, suggestions = []) => {
32
32
  if (options.json) {
33
- console.log(JSON.stringify(createMachineEnvelope(null, [{ code, message }])));
33
+ console.log(JSON.stringify(createMachineEnvelope(null, [
34
+ { code, message, ...(suggestions.length > 0 ? { suggestions } : {}) },
35
+ ])));
34
36
  console.error(message);
35
37
  process.exit(exitCode);
36
38
  }
39
+ if (suggestions.length > 0)
40
+ return new TdkError(message, suggestions, exitCode).exit();
37
41
  return showErrorAndExit(message, exitCode);
38
42
  };
39
43
  if (!isValidTail(options.tail))
@@ -54,7 +58,9 @@ export const logsCommand = new Command("logs")
54
58
  const known = await tiltResourceNames(portText);
55
59
  const unknown = services.filter((name) => known && !known.includes(name));
56
60
  if (unknown.length > 0) {
57
- fail("UNKNOWN_SERVICE", `Unknown service ${unknown.join(", ")}. Valid names: ${(known ?? []).join(", ")}`, 2);
61
+ const error = errorFactories.unknownServices(unknown, known ?? []);
62
+ const suggestions = error.suggestions;
63
+ fail("UNKNOWN_SERVICE", error.message, error.exitCode, suggestions);
58
64
  }
59
65
  }
60
66
  const args = ["--port", portText, "--tail", String(tail)];
@@ -172,7 +172,7 @@ export const networksCommand = new Command("networks")
172
172
  }
173
173
  const discovery = createDiscoveryContext();
174
174
  if (options.stack && !stackExists(options.stack, discovery.resources)) {
175
- const error = errorFactories.stackNotFound(options.stack);
175
+ const error = errorFactories.stackNotFound(options.stack, discovery.stackNames);
176
176
  if (options.json || options.jsonLegacy)
177
177
  throw error;
178
178
  error.exit();
@@ -33,7 +33,7 @@ export const resourcesCommand = new Command("resources")
33
33
  }
34
34
  const discovery = createDiscoveryContext();
35
35
  if (options.stack && !stackExists(options.stack, discovery.resources)) {
36
- const error = errorFactories.stackNotFound(options.stack);
36
+ const error = errorFactories.stackNotFound(options.stack, discovery.stackNames);
37
37
  if (options.json)
38
38
  writeMachineError(error);
39
39
  error.exit();
@@ -7,6 +7,7 @@ import { formatCount, showDetail, showStep } from "../utils/formatting.js";
7
7
  import { getHostPortPlan } from "../utils/host-port-config.js";
8
8
  import { createMachineEnvelope, writeMachineError } from "../utils/machine-output.js";
9
9
  import { findProjectRoot } from "../utils/paths.js";
10
+ import { deriveServiceStates } from "../utils/service-runtime-state.js";
10
11
  import { buildServicePorts, buildStackPorts } from "../utils/status-ports.js";
11
12
  import { getTiltfilePath, isTiltAvailable, runTilt } from "../utils/tilt.js";
12
13
  import { evaluateTiltReadiness, tiltGetUiResources } from "../utils/up-readiness.js";
@@ -58,16 +59,26 @@ export const statusCommand = new Command("status")
58
59
  // A single look at the running Tilt, so a caller that started `tdk up` detached can poll for readiness. Null when no
59
60
  // Tilt answers or its output cannot be read.
60
61
  let readiness = null;
61
- if (options.json && tiltAvailable) {
62
+ // The same look at Tilt feeds readiness and each service's state, so `tdk status` and `tdk ui` cannot disagree.
63
+ let serviceStates = null;
64
+ if (tiltAvailable) {
62
65
  const port = Number.parseInt(process.env.TILT_PORT ?? "", 10);
63
66
  const text = await tiltGetUiResources(Number.isInteger(port) ? port : 10350);
64
67
  if (text) {
68
+ const deferred = getDeferredResourceNames();
65
69
  try {
66
- readiness = evaluateTiltReadiness(text, getDeferredResourceNames()).result;
70
+ if (options.json)
71
+ readiness = evaluateTiltReadiness(text, deferred).result;
67
72
  }
68
73
  catch {
69
74
  readiness = null;
70
75
  }
76
+ try {
77
+ serviceStates = deriveServiceStates(text, Object.fromEntries(discovery.resources.map((r) => [r.name, r.config?.dependsOn ?? []])), deferred);
78
+ }
79
+ catch {
80
+ serviceStates = null;
81
+ }
71
82
  }
72
83
  }
73
84
  if (options.json) {
@@ -94,6 +105,13 @@ export const statusCommand = new Command("status")
94
105
  type: resource.type ?? "unknown",
95
106
  port: resource.port ?? null,
96
107
  ...buildServicePorts(resource, portPlan?.ingressHttp),
108
+ status: serviceStates?.[resource.name]?.status ?? "unknown",
109
+ ...(serviceStates?.[resource.name]?.reason
110
+ ? { statusReason: serviceStates[resource.name]?.reason }
111
+ : {}),
112
+ ...(serviceStates?.[resource.name]?.blockedBy
113
+ ? { blockedBy: serviceStates[resource.name]?.blockedBy }
114
+ : {}),
97
115
  })),
98
116
  ports: buildStackPorts(portPlan, process.env.TILT_PORT ? Number.parseInt(process.env.TILT_PORT, 10) : undefined),
99
117
  stacks: discovery.stacks.map((stack) => ({
@@ -132,6 +150,21 @@ export const statusCommand = new Command("status")
132
150
  }
133
151
  }
134
152
  }
153
+ if (serviceStates && Object.keys(serviceStates).length > 0) {
154
+ console.log();
155
+ console.log(chalk.bold("Services:"));
156
+ for (const resource of discovery.resources) {
157
+ const state = serviceStates[resource.name];
158
+ if (!state)
159
+ continue;
160
+ const mark = state.status === "ready"
161
+ ? chalk.green("✓")
162
+ : state.status === "error"
163
+ ? chalk.red("✗")
164
+ : chalk.yellow("…");
165
+ showDetail(`${mark} ${resource.name} ${state.status}${state.reason ? ` (${state.reason})` : ""}`);
166
+ }
167
+ }
135
168
  if (options.tilt && tiltAvailable) {
136
169
  console.log();
137
170
  showStep("Tilt Resources:");
@@ -2,16 +2,18 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Command } from "commander";
3
3
  import { Box, render, Text, useApp, useInput, useStdin, useStdout } from "ink";
4
4
  import { useCallback, useEffect, useMemo, useState } from "react";
5
- import { DetailPanel, FileTree, ResourceSelectInput, ResourceTable, TabBar, TUIHeader, } from "../components/index.js";
5
+ import { DetailPanel, FileTree, ResourceSelectInput, ResourceTable, ServiceIssues, TabBar, TUIHeader, } from "../components/index.js";
6
6
  import { TABS } from "../components/TabBar.js";
7
7
  import { createTUITheme, TUIThemeContext, useTUITheme } from "../components/ui-theme.js";
8
8
  import { errorFactories, requireProjectRoot } from "../utils/errors.js";
9
9
  import { findProjectRoot, getPackageVersion } from "../utils/paths.js";
10
10
  import { describeSearch } from "../utils/search-status.js";
11
11
  import { clearMetadataCache, discoverResources, discoverStacks, getResourceMetadata, getStackMetadata, } from "../utils/services.js";
12
+ import { singleFlight } from "../utils/single-flight.js";
12
13
  import { createStatusMessageController } from "../utils/status-message.js";
13
14
  import { getListRowFromMouseY, getTerminalRuleWidth } from "../utils/terminal-layout.js";
14
15
  import { isTiltAvailable } from "../utils/tilt.js";
16
+ import { applyServiceStates, fetchServiceStates, } from "../utils/ui-service-state.js";
15
17
  // biome-ignore lint/correctness/noUnusedFunctionParameters: reserved callback prop kept in the component API
16
18
  const HelpPanel = ({ onClose }) => {
17
19
  const theme = useTUITheme();
@@ -75,6 +77,25 @@ export const TUIApp = ({ animated = true }) => {
75
77
  useEffect(() => () => statusMessage.dispose(), [statusMessage]);
76
78
  const projectRoot = findProjectRoot() || "unknown";
77
79
  const [{ stacks, services }, setDiscovered] = useState({ stacks: [], services: [] });
80
+ // What the running Tilt says about each service: the same answer `tdk status` gives. Empty while no Tilt answers.
81
+ const [serviceStates, setServiceStates] = useState({});
82
+ useEffect(() => {
83
+ let cancelled = false;
84
+ const tiltPort = Number.parseInt(process.env.TILT_PORT ?? "", 10);
85
+ // A Tilt call can outlast the 5 s interval; never start a poll while the last one is still running.
86
+ const run = singleFlight((next) => {
87
+ if (cancelled)
88
+ return;
89
+ setServiceStates((previous) => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);
90
+ });
91
+ const poll = () => run(() => fetchServiceStates(services, Number.isInteger(tiltPort) ? tiltPort : 10350));
92
+ void poll();
93
+ const timer = setInterval(() => void poll(), 5000);
94
+ return () => {
95
+ cancelled = true;
96
+ clearInterval(timer);
97
+ };
98
+ }, [services]);
78
99
  // Re-read service.json files from disk. An empty project is not an error:
79
100
  // it renders EmptyState. Only a failed discovery shows ErrorScreen.
80
101
  const refresh = useCallback(() => {
@@ -103,9 +124,9 @@ export const TUIApp = ({ animated = true }) => {
103
124
  return null;
104
125
  return {
105
126
  stack,
106
- metadata: getStackMetadata(stack),
127
+ metadata: applyServiceStates(getStackMetadata(stack), serviceStates),
107
128
  };
108
- }, [selectedStack, stacks]);
129
+ }, [selectedStack, stacks, serviceStates]);
109
130
  const selectedServiceData = useMemo(() => {
110
131
  if (!selectedService)
111
132
  return null;
@@ -476,7 +497,7 @@ export const TUIApp = ({ animated = true }) => {
476
497
  if (services.length === 0) {
477
498
  return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [header, _jsx(EmptyState, { message: message })] }));
478
499
  }
479
- return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [header, isSearching && (_jsxs(Box, { paddingX: 1, height: 1, children: [_jsxs(Text, { color: theme.warning, children: ["Search: ", searchQuery, "_"] }), searchStatus && (_jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: ` ${searchStatus.summary}` }))] })), searchStatus?.emptyMessage && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: theme.muted, children: searchStatus.emptyMessage }) })), !isSearching && message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: theme.accent, children: [theme.bannerStart, " ", message, " ", theme.bannerEnd] }) })), !isSearching && !message && showTooltips && !showHelp && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: createHelpHint(activeTab, selectedStack, selectedService, showEnabledOnly, theme.ascii) }) })), showHelp && (_jsx(Box, { paddingX: 1, flexGrow: 1, children: _jsx(HelpPanel, { onClose: () => setShowHelp(false) }) })), !showHelp && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1, children: _jsx(TabBar, { activeTab: activeTab, onTabChange: setActiveTab, terminalWidth: terminalWidth }) }), _jsxs(Box, { flexDirection: "row", paddingX: 1, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, width: mainPanelWidth, children: [activeTab === "overview" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Stacks ]" : "┌─ Stacks ─" }) }), _jsx(Box, { marginTop: 1, flexGrow: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] })), activeTab === "resources" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Resources ]" : "┌─ Resources ─" }) }), selectedStackData ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Stack: ", selectedStackData.stack.name] }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceTable, { resources: selectedStackData.metadata.resources, maxWidth: terminalWidth - (showSidebar ? 50 : 10) }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "Select a stack to view resources" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] }))] })), activeTab === "files" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Autogenerated Files ]" : "┌─ Autogenerated Files ─" }) }), selectedServiceData ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Service: ", selectedServiceData.service.name] }), _jsx(Box, { marginTop: 1, children: _jsx(FileTree, { nodes: fileTreeNodes, selectedPath: selectedFile || undefined }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "Select a service to view files" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] }))] })), activeTab === "config" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Configuration ]" : "┌─ Configuration ─" }) }), selectedServiceData ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.accent, children: selectedServiceData.service.configPath }), _jsx(Box, { marginTop: 1, ...(theme.ascii
500
+ return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [header, isSearching && (_jsxs(Box, { paddingX: 1, height: 1, children: [_jsxs(Text, { color: theme.warning, children: ["Search: ", searchQuery, "_"] }), searchStatus && (_jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: ` ${searchStatus.summary}` }))] })), searchStatus?.emptyMessage && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: theme.muted, children: searchStatus.emptyMessage }) })), !isSearching && message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: theme.accent, children: [theme.bannerStart, " ", message, " ", theme.bannerEnd] }) })), !isSearching && !message && showTooltips && !showHelp && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: createHelpHint(activeTab, selectedStack, selectedService, showEnabledOnly, theme.ascii) }) })), showHelp && (_jsx(Box, { paddingX: 1, flexGrow: 1, children: _jsx(HelpPanel, { onClose: () => setShowHelp(false) }) })), !showHelp && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1, children: _jsx(TabBar, { activeTab: activeTab, onTabChange: setActiveTab, terminalWidth: terminalWidth }) }), _jsxs(Box, { flexDirection: "row", paddingX: 1, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, width: mainPanelWidth, children: [activeTab === "overview" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Stacks ]" : "┌─ Stacks ─" }) }), _jsx(Box, { marginTop: 1, flexGrow: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] })), activeTab === "resources" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Resources ]" : "┌─ Resources ─" }) }), selectedStackData ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Stack: ", selectedStackData.stack.name] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ResourceTable, { resources: selectedStackData.metadata.resources, maxWidth: terminalWidth - (showSidebar ? 50 : 10) }), _jsx(ServiceIssues, { resources: selectedStackData.metadata.resources })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "Select a stack to view resources" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] }))] })), activeTab === "files" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Autogenerated Files ]" : "┌─ Autogenerated Files ─" }) }), selectedServiceData ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Service: ", selectedServiceData.service.name] }), _jsx(Box, { marginTop: 1, children: _jsx(FileTree, { nodes: fileTreeNodes, selectedPath: selectedFile || undefined }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "Select a service to view files" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] }))] })), activeTab === "config" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Configuration ]" : "┌─ Configuration ─" }) }), selectedServiceData ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.accent, children: selectedServiceData.service.configPath }), _jsx(Box, { marginTop: 1, ...(theme.ascii
480
501
  ? {}
481
502
  : { borderStyle: "single", borderColor: theme.border }), paddingX: 1, children: _jsx(Text, { color: theme.muted, wrap: "wrap", children: JSON.stringify(selectedServiceData.service.config, null, 2).slice(0, 1000) }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "Select a service to view configuration" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] }))] }))] }), showSidebar && (_jsx(Box, { marginLeft: 2, children: _jsx(DetailPanel, { stack: selectedStackData?.stack || null, service: selectedServiceData?.service || null, stackMetadata: selectedStackData?.metadata || null, visible: !!selectedStack || !!selectedService }) }))] }), _jsxs(Box, { ...(theme.ascii ? {} : { borderStyle: "single", borderColor: theme.border }), paddingX: 1, height: 3, flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: theme.accent, bold: true, children: [theme.bannerStart, " ", activeTab] }), _jsxs(Text, { color: theme.success, children: [theme.ascii ? "[+]" : "\u25cf", " ", services.filter((s) => s.stack).length, " in stack"] }), _jsxs(Text, { color: theme.warning, children: [theme.ascii ? "-" : "\u25cb", " ", services.filter((s) => !s.stack).length, " no stack"] })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: theme.muted, children: ["Stacks: ", stacks.length] }), _jsxs(Text, { color: theme.muted, children: ["Services: ", services.length] }), _jsxs(Text, { color: theme.muted, children: [theme.ascii ? "Mouse" : "🖱️", " ", mouseEnabled ? "ON" : "OFF", theme.ascii ? " | " : " \u2502 ", theme.ascii ? "Info" : "ℹ️", " ", showTooltips ? "ON" : "OFF", theme.ascii ? " | " : " \u2502 ", _jsx(Text, { color: showEnabledOnly ? theme.success : theme.warning, children: showEnabledOnly
482
503
  ? theme.ascii
@@ -12,16 +12,19 @@ import { formatCount } from "../utils/formatting.js";
12
12
  import { exportHostPortPlan, getHostPortPlan, writeSavedHostPortPlan, } from "../utils/host-port-config.js";
13
13
  import { formatHostPortPlan } from "../utils/host-port-plan.js";
14
14
  import { createJsonEmitter } from "../utils/json-output.js";
15
+ import { toMachineError } from "../utils/machine-output.js";
15
16
  import { findProjectRoot, getPackageVersion } from "../utils/paths.js";
16
17
  import { findAvailablePort } from "../utils/port-assignment.js";
18
+ import { formatPortFallbackNotice } from "../utils/port-fallback-notice.js";
17
19
  import { isApiServiceType } from "../utils/resource-kind.js";
18
20
  import { appendHealthPath, resolveSubdomainBases } from "../utils/service-urls.js";
19
21
  import { discoverResources, discoverResourcesStrict, discoverStacks, stackExists, } from "../utils/services.js";
20
22
  import { buildSmokePlans, formatSmokeFailure, runSmokePlans } from "../utils/smoke.js";
23
+ import { buildStartupReport, formatStartupReport, isStartupStalled, } from "../utils/startup-report.js";
21
24
  import { evaluateTdkVersionFloor } from "../utils/tdk-version.js";
22
25
  import { buildTiltUpArgs, runTilt } from "../utils/tilt.js";
23
26
  import { stopTiltOnPort } from "../utils/tilt-process.js";
24
- import { parseTiltPort, resolveTiltPort, stopTiltForUp } from "../utils/tilt-startup.js";
27
+ import { parseTiltPort, resolveTiltPort, secondUpAction, stopTiltForUp, } from "../utils/tilt-startup.js";
25
28
  import { findUnknownServices, resolveOnlySelection } from "../utils/up-only.js";
26
29
  import { tiltGetUiResources, waitForTiltResourcesReady } from "../utils/up-readiness.js";
27
30
  import { enableDiscoveredStacks } from "./project.js";
@@ -183,18 +186,34 @@ export const upCommand = new Command("up")
183
186
  const foundRoot = options.dryRun ? requireProjectRoot() : findProjectRoot();
184
187
  const projectRoot = foundRoot ?? process.cwd();
185
188
  const discoveredResources = discoverResourcesStrict();
189
+ let discoveredStacks;
190
+ let discoveredStackNames = [];
191
+ if (!options.only || stackName) {
192
+ discoveredStacks = discoverStacks(discoveredResources);
193
+ discoveredStackNames = discoveredStacks.map((stack) => stack.name);
194
+ }
186
195
  // Reject a bad request before anything below can write to the project (.env, runtime assets, .tdk/project.json).
187
196
  if (options.only) {
188
- if (stackName && !stackExists(stackName))
189
- errorFactories.stackNotFound(stackName).exit();
197
+ if (stackName && !stackExists(stackName)) {
198
+ const error = errorFactories.stackNotFound(stackName, discoveredStackNames);
199
+ emit?.({ ok: false }, [toMachineError(error).error]);
200
+ error.exit();
201
+ }
190
202
  const candidates = stackName
191
203
  ? discoveredResources.filter((resource) => resource.stack === stackName)
192
204
  : discoveredResources;
193
205
  const unknown = findUnknownServices(options.only, candidates);
194
206
  if (unknown.length > 0) {
195
- const message = `Unknown service ${unknown.join(", ")}. Valid names: ${candidates.map((s) => s.name).join(", ")}`;
196
- emit?.({ ok: false }, [{ code: "UNKNOWN_SERVICE", message }]);
197
- showErrorAndExit(message, 2);
207
+ const validNames = candidates.map((service) => service.name);
208
+ const error = errorFactories.unknownServices(unknown, validNames);
209
+ emit?.({ ok: false }, [
210
+ {
211
+ code: "UNKNOWN_SERVICE",
212
+ message: error.message,
213
+ ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
214
+ },
215
+ ]);
216
+ error.exit();
198
217
  }
199
218
  }
200
219
  // Also under --dry-run: the check only reads, and a dry run should show what a real run would refuse.
@@ -231,14 +250,16 @@ export const upCommand = new Command("up")
231
250
  if (stackName) {
232
251
  servicesToStart = discoveredResources.filter((resource) => resource.stack === stackName);
233
252
  if (servicesToStart.length === 0) {
234
- errorFactories.stackNotFound(stackName).exit();
253
+ const error = errorFactories.stackNotFound(stackName, discoveredStackNames);
254
+ emit?.({ ok: false }, [toMachineError(error).error]);
255
+ error.exit();
235
256
  }
236
257
  focusServiceNames = servicesToStart.map((s) => s.name);
237
258
  stackDescription = `stack "${stackName}"`;
238
259
  }
239
260
  else {
240
261
  servicesToStart = discoveredResources;
241
- const allStacks = discoverStacks(discoveredResources);
262
+ const allStacks = discoveredStacks ?? discoverStacks(discoveredResources);
242
263
  stackDescription = `all stacks (${formatCount(allStacks.length, "stack")}, ${formatCount(servicesToStart.length, "service")})`;
243
264
  }
244
265
  let dependencyNames = [];
@@ -292,6 +313,8 @@ export const upCommand = new Command("up")
292
313
  const dryRunCommand = focusTargets ? `tilt up ${tiltArgs.join(" ")}` : "tilt up";
293
314
  if (!options.quiet) {
294
315
  console.log(chalk.blue(formatHostPortPlan(hostPortPlan)));
316
+ for (const line of formatPortFallbackNotice(hostPortPlan))
317
+ console.log(chalk.yellow(line));
295
318
  console.log(chalk.gray("Override with TDK_HTTP_PORT, TDK_HTTPS_PORT, or TDK_POSTGRES_PORT."));
296
319
  }
297
320
  if (emit && options.dryRun) {
@@ -313,23 +336,35 @@ export const upCommand = new Command("up")
313
336
  exportHostPortPlan(hostPortPlan);
314
337
  writeSavedHostPortPlan(projectRoot, hostPortPlan);
315
338
  const basePort = 10350;
316
- // A second Tilt would apply a different selection to the same containers, whatever port it listens on, so `--only`
317
- // looks for a Tilt that answers on the default port and on TILT_PORT before it chooses one. A listener that is not
318
- // Tilt does not count: the port is then picked below as usual.
339
+ const watchedPort = configuredTiltPort ?? basePort;
340
+ const candidatePorts = options.only ? [...new Set([basePort, watchedPort])] : [watchedPort];
341
+ const running = [];
342
+ for (const candidate of candidatePorts) {
343
+ if ((await tiltGetUiResources(candidate)) !== null)
344
+ running.push(candidate);
345
+ }
346
+ const decision = secondUpAction({
347
+ runningPorts: running,
348
+ force: options.force,
349
+ only: Boolean(options.only),
350
+ });
351
+ if (decision.action === "already-running") {
352
+ const message = `Environment is already running on port ${decision.ports.join(", ")}.`;
353
+ emit?.({
354
+ ok: true,
355
+ alreadyRunning: true,
356
+ tiltUrl: `http://localhost:${decision.ports[0]}`,
357
+ });
358
+ if (!options.quiet)
359
+ console.log(chalk.green(message));
360
+ return;
361
+ }
362
+ if (decision.action === "only-blocked") {
363
+ const message = `A Tilt is already running on port ${decision.ports.join(", ")}. --only cannot change a running stack's services: run \`tdk down\` first, or pass --force to replace it.`;
364
+ emit?.({ ok: false }, [{ code: "TILT_ALREADY_RUNNING", message }]);
365
+ showErrorAndExit(message);
366
+ }
319
367
  if (options.only) {
320
- const candidatePorts = [
321
- ...new Set([basePort, ...(configuredTiltPort === undefined ? [] : [configuredTiltPort])]),
322
- ];
323
- const running = [];
324
- for (const candidate of candidatePorts) {
325
- if ((await tiltGetUiResources(candidate)) !== null)
326
- running.push(candidate);
327
- }
328
- if (running.length > 0 && !options.force) {
329
- const message = `A Tilt is already running on port ${running.join(", ")}. --only cannot change a running stack's services: run \`tdk down\` first, or pass --force to replace it.`;
330
- emit?.({ ok: false }, [{ code: "TILT_ALREADY_RUNNING", message }]);
331
- showErrorAndExit(message);
332
- }
333
368
  for (const runningPort of running)
334
369
  stopTiltOnPort(runningPort);
335
370
  }
@@ -364,6 +399,7 @@ export const upCommand = new Command("up")
364
399
  const smokePlans = buildSmokePlans(servicesToStart, hostPortPlan.ingressHttp);
365
400
  let smokeFailed = false;
366
401
  let printedSuccess = false;
402
+ let startupFailed = false;
367
403
  const successOutput = (async () => {
368
404
  const uiReady = await waitForTiltUi(port);
369
405
  // JSON consumers get success only after every non-deferred resource is built and running, not when the UI port opens.
@@ -399,12 +435,36 @@ export const upCommand = new Command("up")
399
435
  ]);
400
436
  }
401
437
  }
402
- if (uiReady && !options.quiet) {
438
+ // The UI port opening only means Tilt started. Say "up" once every resource is built and running, and otherwise name
439
+ // what failed and what it blocks.
440
+ if (uiReady && !emit) {
441
+ const deferred = getDeferredResourceNames();
442
+ const dependsOn = Object.fromEntries(discoverResources().map((r) => [r.name, r.config?.dependsOn ?? []]));
443
+ const readiness = await waitForTiltResourcesReady(port, {
444
+ deferred,
445
+ isStalled: (text) => isStartupStalled(text, dependsOn, deferred),
446
+ });
447
+ if (!readiness.ready) {
448
+ startupFailed = true;
449
+ const resourcesJson = await tiltGetUiResources(port);
450
+ const report = resourcesJson
451
+ ? buildStartupReport(resourcesJson, dependsOn, deferred)
452
+ : {
453
+ failed: readiness.failures,
454
+ blocked: [],
455
+ starting: [],
456
+ };
457
+ for (const line of formatStartupReport(report, readiness.timedOut)) {
458
+ console.error(chalk.red(line));
459
+ }
460
+ }
461
+ }
462
+ if (uiReady && !options.quiet && !startupFailed) {
403
463
  for (const line of formatUpSuccess(port))
404
464
  console.log(chalk.blue(line));
405
465
  printedSuccess = true;
406
466
  }
407
- if (uiReady && smokePlans.length > 0 && (!emit || jsonReady)) {
467
+ if (uiReady && smokePlans.length > 0 && (emit ? jsonReady : !startupFailed)) {
408
468
  if (!options.quiet) {
409
469
  console.log(chalk.gray(`Smoke check: ${smokePlans.map((p) => p.name).join(", ")}`));
410
470
  }
@@ -446,6 +506,9 @@ export const upCommand = new Command("up")
446
506
  if (result.exitCode !== 0) {
447
507
  handleTiltFailure("up", result.exitCode);
448
508
  }
509
+ if (startupFailed && result.exitCode === 0) {
510
+ process.exit(1);
511
+ }
449
512
  if (!options.quiet && result.exitCode === 0 && !printedSuccess) {
450
513
  for (const line of formatUpSuccess(port))
451
514
  console.log(chalk.blue(line));
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useTUITheme } from "./ui-theme.js";
4
+ /** One line per service that is not ready and has a reason, so a failed dependency is named on screen. */
5
+ export function ServiceIssues({ resources }) {
6
+ const theme = useTUITheme();
7
+ const issues = resources.filter((r) => r.status !== "ready" && r.statusReason);
8
+ if (issues.length === 0)
9
+ return null;
10
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: issues.map((resource) => (_jsxs(Text, { color: theme.muted, children: [resource.name, ": ", resource.statusReason] }, resource.name))) }));
11
+ }
12
+ //# sourceMappingURL=ServiceIssues.js.map
@@ -3,6 +3,7 @@ export { DetailPanel } from "./DetailPanel.js";
3
3
  export { FileTree } from "./FileTree.js";
4
4
  export { ResourceSelectInput } from "./ResourceSelectInput.js";
5
5
  export { ResourceTable } from "./ResourceTable.js";
6
+ export { ServiceIssues } from "./ServiceIssues.js";
6
7
  export { TabBar } from "./TabBar.js";
7
8
  export { TOOLTIPS } from "./Tooltip.js";
8
9
  export { TUIHeader } from "./TUIHeader.js";
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import Handlebars from "handlebars";
5
5
  import { PLATFORM_STANDARDS } from "../config/platform-standards.js";
6
+ import { writeTextFileAtomic } from "../utils/atomic-write.js";
6
7
  import { writeTextFile } from "../utils/file-helpers.js";
7
8
  import { assertTdkGeneratedPath } from "../utils/generated-paths.js";
8
9
  import { DEFAULT_ALWAYS_ENABLED_INFRA } from "../utils/project-config-defaults.js";
@@ -220,6 +221,7 @@ export function generateDatabaseManagementCompose(projectConfig) {
220
221
  # SYSTEM-GENERATED - DO NOT EDIT
221
222
  # Stack feature: database-management
222
223
  # Source: .tdk/project.json phases.*.enabledStacks includes "database-management"
224
+ # Regenerate with: tdk config regenerate
223
225
  ###############################################################################
224
226
 
225
227
  services:
@@ -443,14 +445,14 @@ export async function generateMasterConfigs(projectRoot, options = {}) {
443
445
  for (const filename of ALL_GENERATED_FILES) {
444
446
  const content = files[filename];
445
447
  const filePath = assertTdkGeneratedPath(projectRoot, path.join(".tdk", ".tdk-out", filename));
446
- writeTextFile(filePath, content);
448
+ writeTextFileAtomic(filePath, content);
447
449
  console.log(`✓ Generated: .tdk/.tdk-out/${filename}`);
448
450
  }
449
451
  if (isStackFeatureEnabledInStacks(projectConfig.phases, "database-management")) {
450
452
  const composePath = assertTdkGeneratedPath(projectRoot, path.join("services", "platform", "database-management", "docker-compose.yml"));
451
453
  const composeDir = path.dirname(composePath);
452
454
  fs.mkdirSync(composeDir, { recursive: true });
453
- writeTextFile(composePath, generateDatabaseManagementCompose(projectConfig));
455
+ writeTextFileAtomic(composePath, generateDatabaseManagementCompose(projectConfig));
454
456
  console.log("✓ Generated: services/platform/database-management/docker-compose.yml");
455
457
  }
456
458
  // Copy .tiltignore to project root so Tilt uses it
@@ -0,0 +1,35 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, fchmodSync, fsyncSync, openSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ /**
5
+ * Replaces `filePath` in one step. A plain writeFileSync truncates first, so an interrupt mid-write leaves a
6
+ * half-written file that looks current; here readers see the old content or the new content, never a mix.
7
+ */
8
+ export function writeTextFileAtomic(filePath, content) {
9
+ const tempPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
10
+ let mode;
11
+ try {
12
+ mode = statSync(filePath).mode & 0o777;
13
+ }
14
+ catch {
15
+ mode = undefined;
16
+ }
17
+ try {
18
+ const fd = openSync(tempPath, "wx");
19
+ try {
20
+ if (mode !== undefined)
21
+ fchmodSync(fd, mode);
22
+ writeFileSync(fd, content, "utf-8");
23
+ fsyncSync(fd);
24
+ }
25
+ finally {
26
+ closeSync(fd);
27
+ }
28
+ renameSync(tempPath, filePath);
29
+ }
30
+ catch (error) {
31
+ rmSync(tempPath, { force: true });
32
+ throw error;
33
+ }
34
+ }
35
+ //# sourceMappingURL=atomic-write.js.map
@@ -7,6 +7,7 @@ import { findProjectRoot } from "./paths.js";
7
7
  import { isApiServiceType } from "./resource-kind.js";
8
8
  import { getProjectName } from "./service-urls.js";
9
9
  import { discoverResources, discoverResourcesFromRoot } from "./services.js";
10
+ import { isTiltResourcePending } from "./tilt-resource-state.js";
10
11
  import { findOnPath } from "./which.js";
11
12
  const EXEC_TIMEOUT_MS = 10_000;
12
13
  const REGISTRY_PROBE_TIMEOUT_MS = 3_000;
@@ -119,11 +120,7 @@ export function parseTiltResourceFailures(jsonText, deferredNames = new Set()) {
119
120
  okCount += 1;
120
121
  continue;
121
122
  }
122
- if (name !== "(Tiltfile)" &&
123
- (updateStatus === "pending" ||
124
- runtimeStatus === "pending" ||
125
- updateStatus === "none" ||
126
- runtimeStatus === "none")) {
123
+ if (name !== "(Tiltfile)" && isTiltResourcePending(updateStatus, runtimeStatus)) {
127
124
  if (deferredNames.has(name)) {
128
125
  deferredCount += 1;
129
126
  }
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import { QUICKSTART_DOCS_URL } from "./constants.js";
3
3
  import { getContainerRuntimeStatus } from "./docker.js";
4
4
  import { findProjectRoot } from "./paths.js";
5
+ import { suggestClosest } from "./suggestions.js";
5
6
  import { isTiltAvailable } from "./tilt.js";
6
7
  export function getErrorMessage(err) {
7
8
  return err instanceof Error ? err.message : String(err);
@@ -30,6 +31,16 @@ export class TdkError extends Error {
30
31
  process.exit(this.exitCode);
31
32
  }
32
33
  }
34
+ function closestNameSuggestions(names, candidates, includeInput = false) {
35
+ const suggestions = new Set();
36
+ for (const name of names) {
37
+ const closest = suggestClosest(name, candidates);
38
+ if (closest) {
39
+ suggestions.add(includeInput ? `Did you mean "${closest}" for "${name}"?` : `Did you mean "${closest}"?`);
40
+ }
41
+ }
42
+ return [...suggestions];
43
+ }
33
44
  export const errorFactories = {
34
45
  tiltNotInstalled: () => new TdkError("Tilt CLI is not installed", [
35
46
  "Install Tilt: `brew install tilt` (macOS)",
@@ -47,14 +58,21 @@ export const errorFactories = {
47
58
  "Quit and reopen Docker Desktop, or run `colima restart`",
48
59
  "Then verify with: `docker ps`",
49
60
  ]),
50
- stackNotFound: (name) => new TdkError(`Stack "${name}" not found`, [
61
+ stackNotFound: (name, stackNames = []) => new TdkError(`Stack "${name}" not found`, [
62
+ ...closestNameSuggestions([name], stackNames),
51
63
  "Run `tdk stacks` to see available stacks",
52
64
  "Run `tdk stack` to assign resources to a stack",
53
65
  ]),
54
- resourceNotFound: (name) => new TdkError(`Resource "${name}" not found`, [
66
+ resourceNotFound: (name, resourceNames = []) => new TdkError(`Resource "${name}" not found`, [
67
+ ...closestNameSuggestions([name], resourceNames),
55
68
  "Run `tdk resources` to list all resources",
56
69
  "Check the resource name spelling",
57
70
  ]),
71
+ unknownServices: (names, validNames) => {
72
+ // Service selectors are Tilt resource names, so share the resource suggestion path.
73
+ const suggestions = closestNameSuggestions(names, validNames, names.length > 1);
74
+ return new TdkError(`Unknown service ${names.join(", ")}. Valid names: ${validNames.join(", ")}`, suggestions, 2);
75
+ },
58
76
  directoryExists: (path) => new TdkError(`Directory already exists: ${path}`, [
59
77
  "Use `--path` to specify a different location",
60
78
  "Remove the existing directory if no longer needed",
@@ -4,7 +4,14 @@ export function createMachineEnvelope(data, errors = []) {
4
4
  }
5
5
  export function toMachineError(error) {
6
6
  if (error instanceof TdkError) {
7
- return { error: { code: "COMMAND_FAILED", message: error.message }, exitCode: 1 };
7
+ return {
8
+ error: {
9
+ code: "COMMAND_FAILED",
10
+ message: error.message,
11
+ ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
12
+ },
13
+ exitCode: error.exitCode,
14
+ };
8
15
  }
9
16
  return {
10
17
  error: {
@@ -0,0 +1,18 @@
1
+ const PORT_KEYS = [
2
+ { key: "ingressHttp", label: "HTTP", env: "TDK_HTTP_PORT" },
3
+ { key: "ingressHttps", label: "HTTPS", env: "TDK_HTTPS_PORT" },
4
+ { key: "postgres", label: "Postgres", env: "TDK_POSTGRES_PORT" },
5
+ ];
6
+ /** One line per port TDK moved off the one it wanted. Ports the user set explicitly are never listed. */
7
+ export function formatPortFallbackNotice(plan) {
8
+ const lines = [];
9
+ for (const { key, label, env } of PORT_KEYS) {
10
+ const requested = plan.requested[key];
11
+ const chosen = plan[key];
12
+ if (plan.explicit[key] || chosen === requested)
13
+ continue;
14
+ lines.push(`${label}: wanted ${requested}, using ${chosen} (${plan.reason[key]}). To force ${requested}: ${env}=${requested} tdk up`);
15
+ }
16
+ return lines;
17
+ }
18
+ //# sourceMappingURL=port-fallback-notice.js.map
@@ -0,0 +1,67 @@
1
+ import { buildStartupReport, transitiveDependencies } from "./startup-report.js";
2
+ import { isTiltResourcePending } from "./tilt-resource-state.js";
3
+ import { onlyEnabledResources } from "./up-readiness.js";
4
+ /**
5
+ * The one answer to "is this service ready?" for `tdk status` and `tdk ui`, from Tilt's resources and the `dependsOn`
6
+ * in service.json. A service whose own process is up is still not ready while something it depends on has failed or
7
+ * is still starting. Services Tilt does not list are absent: the caller shows them as unknown.
8
+ */
9
+ export function deriveServiceStates(jsonText, dependsOn, deferred = new Set()) {
10
+ const report = buildStartupReport(jsonText, dependsOn, deferred);
11
+ const failed = new Map(report.failed.map((entry) => [entry.name, entry.message]));
12
+ const blocked = new Map(report.blocked.map((entry) => [entry.name, entry.because]));
13
+ const starting = new Set(report.starting);
14
+ const items = JSON.parse(onlyEnabledResources(jsonText)).items ?? [];
15
+ const direct = new Map();
16
+ for (const item of items) {
17
+ const name = item.metadata?.name;
18
+ if (!name || name === "(Tiltfile)")
19
+ continue;
20
+ if (failed.has(name)) {
21
+ direct.set(name, { status: "error", reason: failed.get(name) });
22
+ }
23
+ else if (blocked.has(name)) {
24
+ const because = blocked.get(name) ?? [];
25
+ direct.set(name, {
26
+ status: "error",
27
+ reason: `${because.join(", ")} failed`,
28
+ blockedBy: because,
29
+ });
30
+ }
31
+ else if (starting.has(name)) {
32
+ direct.set(name, { status: "pending" });
33
+ }
34
+ else if (deferred.has(name) &&
35
+ isTiltResourcePending(item.status?.updateStatus ?? "", item.status?.runtimeStatus ?? "")) {
36
+ direct.set(name, { status: "unknown", reason: "starts on its first request" });
37
+ }
38
+ else {
39
+ direct.set(name, { status: "ready" });
40
+ }
41
+ }
42
+ const result = {};
43
+ for (const [name, state] of direct) {
44
+ if (state.status !== "ready") {
45
+ result[name] = state;
46
+ continue;
47
+ }
48
+ const deps = [...transitiveDependencies(name, dependsOn)].filter((dep) => direct.has(dep));
49
+ const failedDeps = deps.filter((dep) => direct.get(dep)?.status === "error").sort();
50
+ const waitingDeps = deps.filter((dep) => direct.get(dep)?.status === "pending").sort();
51
+ if (failedDeps.length > 0) {
52
+ result[name] = {
53
+ status: "error",
54
+ reason: `${failedDeps.join(", ")} failed`,
55
+ blockedBy: failedDeps,
56
+ };
57
+ }
58
+ else if (waitingDeps.length > 0) {
59
+ result[name] = { status: "pending", reason: `waiting for ${waitingDeps.join(", ")}` };
60
+ }
61
+ else {
62
+ result[name] = state;
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+ //# sourceMappingURL=service-runtime-state.js.map
@@ -6,6 +6,7 @@ import { errorFactories } from "./errors.js";
6
6
  import { formatCount } from "./formatting.js";
7
7
  import { findProjectRoot } from "./paths.js";
8
8
  import { validateServiceManifest } from "./service-manifest.js";
9
+ import { deriveStackStatus } from "./stack-status.js";
9
10
  import { includes } from "./validation.js";
10
11
  const RESOURCE_JSON_FILENAME = "service.json";
11
12
  function findServiceJsonFiles(dir, maxDepth = 5, currentDepth = 0) {
@@ -338,21 +339,7 @@ export function getStackMetadata(stack) {
338
339
  const timestamps = resourcesMetadata.map((r) => new Date(r.createdAt).getTime());
339
340
  const earliestTimestamp = Math.min(...timestamps);
340
341
  const latestTimestamp = Math.max(...timestamps);
341
- const totalResources = resourcesMetadata.length;
342
- let overallStatus = "unknown";
343
- if (totalResources > 0) {
344
- const readyCount = resourcesMetadata.filter((r) => r.status === "ready").length;
345
- const ratio = readyCount / totalResources;
346
- if (ratio > 0.9) {
347
- overallStatus = "healthy";
348
- }
349
- else if (ratio > 0.5) {
350
- overallStatus = "degraded";
351
- }
352
- else {
353
- overallStatus = "error";
354
- }
355
- }
342
+ const overallStatus = deriveStackStatus(resourcesMetadata.map((r) => r.status));
356
343
  const metadata = {
357
344
  name: stack.name,
358
345
  resourceCount: resourcesMetadata.length,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Runs a load and applies its result, but starts nothing while a previous load is still running. A poll on a timer
3
+ * therefore never overlaps itself: results cannot arrive out of order, and a slow Tilt cannot make every poll get
4
+ * superseded before it finishes.
5
+ */
6
+ export function singleFlight(apply) {
7
+ let running = false;
8
+ return async (load) => {
9
+ if (running)
10
+ return;
11
+ running = true;
12
+ try {
13
+ apply(await load());
14
+ }
15
+ finally {
16
+ running = false;
17
+ }
18
+ };
19
+ }
20
+ //# sourceMappingURL=single-flight.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A stack with no known service status has not been checked, so it is "unknown", not "error". Once any service has a
3
+ * status, the share of ready services decides it.
4
+ */
5
+ export function deriveStackStatus(statuses) {
6
+ if (statuses.length === 0 || statuses.every((status) => status === "unknown"))
7
+ return "unknown";
8
+ const ratio = statuses.filter((status) => status === "ready").length / statuses.length;
9
+ if (ratio > 0.9)
10
+ return "healthy";
11
+ if (ratio > 0.5)
12
+ return "degraded";
13
+ return "error";
14
+ }
15
+ //# sourceMappingURL=stack-status.js.map
@@ -0,0 +1,68 @@
1
+ import { parseTiltResourceFailures, summarizeTiltBuildError } from "./doctor-runtime.js";
2
+ import { isTiltResourcePending } from "./tilt-resource-state.js";
3
+ import { onlyEnabledResources } from "./up-readiness.js";
4
+ export function transitiveDependencies(name, dependsOn) {
5
+ const seen = new Set();
6
+ const visit = (current) => {
7
+ for (const dep of dependsOn[current] ?? []) {
8
+ if (seen.has(dep))
9
+ continue;
10
+ seen.add(dep);
11
+ visit(dep);
12
+ }
13
+ };
14
+ visit(name);
15
+ return seen;
16
+ }
17
+ function isPending(item) {
18
+ return isTiltResourcePending(item.status?.updateStatus ?? "", item.status?.runtimeStatus ?? "");
19
+ }
20
+ export function buildStartupReport(jsonText, dependsOn, deferred = new Set()) {
21
+ const enabledJson = onlyEnabledResources(jsonText);
22
+ const failures = parseTiltResourceFailures(enabledJson, deferred).failures;
23
+ const failed = failures.map((failure) => ({
24
+ name: failure.name,
25
+ message: failure.error
26
+ ? summarizeTiltBuildError(failure.error)
27
+ : `${failure.updateStatus}/${failure.runtimeStatus}`,
28
+ }));
29
+ const failedNames = new Set(failed.map((entry) => entry.name));
30
+ const items = JSON.parse(enabledJson).items ?? [];
31
+ const blocked = [];
32
+ const starting = [];
33
+ for (const item of items) {
34
+ const name = item.metadata?.name ?? "unknown";
35
+ if (name === "(Tiltfile)" || failedNames.has(name) || deferred.has(name) || !isPending(item)) {
36
+ continue;
37
+ }
38
+ const because = [...transitiveDependencies(name, dependsOn)]
39
+ .filter((dep) => failedNames.has(dep))
40
+ .sort();
41
+ if (because.length > 0)
42
+ blocked.push({ name, because });
43
+ else
44
+ starting.push(name);
45
+ }
46
+ return { failed, blocked, starting };
47
+ }
48
+ /** A failure exists and everything still pending is blocked by it, so waiting longer cannot help. */
49
+ export function isStartupStalled(jsonText, dependsOn, deferred = new Set()) {
50
+ const report = buildStartupReport(jsonText, dependsOn, deferred);
51
+ return report.failed.length > 0 && report.starting.length === 0;
52
+ }
53
+ export function formatStartupReport(report, timedOut) {
54
+ const lines = [];
55
+ for (const { name, message } of report.failed)
56
+ lines.push(`✗ ${name} failed: ${message}`);
57
+ for (const { name, because } of report.blocked) {
58
+ lines.push(`✗ ${name} is not ready because ${because.join(", ")} failed`);
59
+ }
60
+ if (report.starting.length > 0) {
61
+ lines.push(`${timedOut ? "Timed out waiting for" : "Still starting:"} ${report.starting.join(", ")}`);
62
+ }
63
+ if (lines.length > 0) {
64
+ lines.push("The environment is not ready. Tilt is still running; inspect it or run: tdk down");
65
+ }
66
+ return lines;
67
+ }
68
+ //# sourceMappingURL=startup-report.js.map
@@ -0,0 +1,58 @@
1
+ function levenshteinDistance(left, right) {
2
+ let previousRow = Array.from({ length: right.length + 1 }, (_, index) => index);
3
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) {
4
+ const currentRow = [leftIndex];
5
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
6
+ const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
7
+ currentRow[rightIndex] = Math.min(currentRow[rightIndex - 1] + 1, previousRow[rightIndex] + 1, previousRow[rightIndex - 1] + substitutionCost);
8
+ }
9
+ previousRow = currentRow;
10
+ }
11
+ return previousRow[right.length];
12
+ }
13
+ function isAdjacentTransposition(left, right) {
14
+ if (left.length !== right.length)
15
+ return false;
16
+ for (let index = 0; index < left.length - 1; index++) {
17
+ if (left[index] !== right[index] &&
18
+ left[index] === right[index + 1] &&
19
+ left[index + 1] === right[index] &&
20
+ left.slice(0, index) === right.slice(0, index) &&
21
+ left.slice(index + 2) === right.slice(index + 2)) {
22
+ return true;
23
+ }
24
+ }
25
+ return false;
26
+ }
27
+ /**
28
+ * Bases the threshold on the longer name and caps it at two edits.
29
+ * An adjacent transposition counts as one typo; ties use deterministic code-unit order.
30
+ */
31
+ export function suggestClosest(input, candidates) {
32
+ if (input.length < 2 || candidates.length === 0)
33
+ return undefined;
34
+ // stackExists and findUnknownServices are case-sensitive, so case-only mismatches need the canonical spelling.
35
+ if (candidates.includes(input))
36
+ return undefined;
37
+ const normalizedInput = input.toLowerCase();
38
+ let closest;
39
+ let closestDistance = Number.POSITIVE_INFINITY;
40
+ for (const candidate of candidates) {
41
+ if (candidate.length === 0)
42
+ continue;
43
+ const normalizedCandidate = candidate.toLowerCase();
44
+ const distance = isAdjacentTransposition(normalizedInput, normalizedCandidate)
45
+ ? 1
46
+ : levenshteinDistance(normalizedInput, normalizedCandidate);
47
+ const threshold = Math.max(1, Math.min(2, Math.floor(Math.max(normalizedInput.length, normalizedCandidate.length) / 3)));
48
+ if (distance > threshold)
49
+ continue;
50
+ if (distance < closestDistance ||
51
+ (distance === closestDistance && (closest === undefined || candidate < closest))) {
52
+ closest = candidate;
53
+ closestDistance = distance;
54
+ }
55
+ }
56
+ return closest;
57
+ }
58
+ //# sourceMappingURL=suggestions.js.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A Tilt resource that is not built and running yet: still waiting (`pending`, `none`) or building right now
3
+ * (`in_progress`). Readiness must treat all of these as not ready.
4
+ */
5
+ export function isTiltResourcePending(updateStatus, runtimeStatus) {
6
+ return (updateStatus === "pending" ||
7
+ updateStatus === "in_progress" ||
8
+ updateStatus === "none" ||
9
+ runtimeStatus === "pending" ||
10
+ runtimeStatus === "none");
11
+ }
12
+ //# sourceMappingURL=tilt-resource-state.js.map
@@ -11,6 +11,13 @@ export function parseTiltPort(value) {
11
11
  }
12
12
  return { ok: true, port };
13
13
  }
14
+ export function secondUpAction(input) {
15
+ if (input.runningPorts.length === 0 || input.force)
16
+ return { action: "start" };
17
+ if (input.only)
18
+ return { action: "only-blocked", ports: input.runningPorts };
19
+ return { action: "already-running", ports: input.runningPorts };
20
+ }
14
21
  export async function resolveTiltPort(options) {
15
22
  if (options.configuredPort !== undefined) {
16
23
  return { port: options.configuredPort, autoSwitched: false };
@@ -0,0 +1,35 @@
1
+ import { getDeferredResourceNames } from "./doctor-runtime.js";
2
+ import { deriveServiceStates } from "./service-runtime-state.js";
3
+ import { deriveStackStatus } from "./stack-status.js";
4
+ import { tiltGetUiResources } from "./up-readiness.js";
5
+ /** One look at the running Tilt. Empty when no Tilt answers, so every service stays unknown. */
6
+ export async function fetchServiceStates(services, tiltPort) {
7
+ try {
8
+ const text = await tiltGetUiResources(tiltPort);
9
+ if (!text)
10
+ return {};
11
+ return deriveServiceStates(text, Object.fromEntries(services.map((r) => [r.name, r.config?.dependsOn ?? []])), getDeferredResourceNames());
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ }
17
+ /** The same states `tdk status` shows, put onto the stack the UI is about to draw. */
18
+ export function applyServiceStates(metadata, states) {
19
+ const resources = metadata.resources.map((resource) => {
20
+ const state = states[resource.name];
21
+ if (!state)
22
+ return resource;
23
+ return {
24
+ ...resource,
25
+ status: state.status,
26
+ ...(state.reason ? { statusReason: state.reason } : {}),
27
+ };
28
+ });
29
+ return {
30
+ ...metadata,
31
+ resources,
32
+ overallStatus: deriveStackStatus(resources.map((r) => r.status)),
33
+ };
34
+ }
35
+ //# sourceMappingURL=ui-service-state.js.map
@@ -102,6 +102,8 @@ export async function waitForTiltResourcesReady(port, options = {}) {
102
102
  last = evaluation.result;
103
103
  if (evaluation.settled)
104
104
  return last;
105
+ if (last.failures.length > 0 && options.isStalled?.(text))
106
+ return last;
105
107
  }
106
108
  catch {
107
109
  /* Tilt may emit partial output while starting; retry. */
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.119",
4
+ "version": "1.3.121",
5
5
  "description": "TDK CLI — start services on your laptop.",
6
6
  "keywords": [
7
7
  "tdk",
@@ -3,8 +3,7 @@
3
3
  # =============================================================================
4
4
  # SYSTEM-GENERATED BY TDK CLI - DO NOT EDIT
5
5
  # Source: .tdk/project.json
6
- # Generated: {{timestamp}}
7
- # Run 'tdk project' to regenerate
6
+ # Regenerate with: tdk config regenerate
8
7
  # =============================================================================
9
8
  #
10
9
  # This file tells Tilt which directories to ignore during file watching.
@@ -6,7 +6,7 @@
6
6
  # SYSTEM-GENERATED BY TDK CLI - DO NOT EDIT
7
7
  # Source: TDK Platform Standards v{{version}}
8
8
  # Output: .tdk/.tdk-out/
9
- # Run 'tdk project' to update
9
+ # Regenerate with: tdk config regenerate
10
10
  # =============================================================================
11
11
 
12
12
  # Port Configuration
@@ -6,7 +6,7 @@
6
6
  # SYSTEM-GENERATED BY TDK CLI - DO NOT EDIT
7
7
  # Source: TDK Platform Standards v{{version}}
8
8
  # Output: .tdk/.tdk-out/
9
- # Run 'tdk project' to update
9
+ # Regenerate with: tdk config regenerate
10
10
  # =============================================================================
11
11
 
12
12
  def get_tech_stack():
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env starlark
2
- # -*- coding: utf-8 -*}
2
+ # -*- coding: utf-8 -*-
3
3
  # =============================================================================
4
4
  # 🎯 {{project.name}} - TILT ENTRYPOINT
5
5
  # =============================================================================
6
6
  # SYSTEM-GENERATED BY TDK CLI - DO NOT EDIT
7
7
  # Source: .tdk/project.json
8
- # Run 'tdk project' to regenerate
8
+ # Regenerate with: tdk config regenerate
9
9
  # =============================================================================
10
10
 
11
11
  print("🚀 Loading {{project.name}}...")
@@ -38,7 +38,7 @@ TDK extension not found.
38
38
  'tdk up' needs the TDK Tilt extension (engine/discovery/specs/ext).
39
39
  Fix it with one of:
40
40
  1) Regenerate the project so the extension is vendored in:
41
- tdk project --yes
41
+ tdk config regenerate
42
42
  (set $TDK_EXTENSION_SOURCE to a tdk-cli checkout if it was not auto-detected)
43
43
  2) Point at any tdk-cli checkout:
44
44
  export TDK_EXTENSION_PATH=/path/to/tdk-cli
@@ -6,7 +6,7 @@
6
6
  # SYSTEM-GENERATED BY TDK CLI - DO NOT EDIT
7
7
  # Source: .tdk/project.json + TDK Platform Standards v{{version}}
8
8
  # Output: .tdk/.tdk-out/
9
- # Run 'tdk project' to update
9
+ # Regenerate with: tdk config regenerate
10
10
  #
11
11
  # 🏗️ ARCHITECTURE: Project → Stack → Service (PSR Model)
12
12
  # Services are organized into stacks by release phase.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tdk-landscape/tdk-cli-core",
3
- "version": "1.3.119",
3
+ "version": "1.3.121",
4
4
  "description": "TDK CLI — start services on your laptop.",
5
5
  "homepage": "https://tdk-landscape.github.io/tdk-website",
6
6
  "repository": {