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

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
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Command } from "commander";
3
3
  import { Box, render, Text, useApp, useInput, useStdin, useStdout } from "ink";
4
- import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
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";
@@ -13,6 +13,7 @@ import { singleFlight } from "../utils/single-flight.js";
13
13
  import { createStatusMessageController } from "../utils/status-message.js";
14
14
  import { getListRowFromMouseY, getTerminalRuleWidth } from "../utils/terminal-layout.js";
15
15
  import { isTiltAvailable } from "../utils/tilt.js";
16
+ import { loadTiltEvents, stripTerminalControls, TiltEventsLoadError, } from "../utils/tilt-events.js";
16
17
  import { applyServiceStates, fetchServiceStates, } from "../utils/ui-service-state.js";
17
18
  // biome-ignore lint/correctness/noUnusedFunctionParameters: reserved callback prop kept in the component API
18
19
  const HelpPanel = ({ onClose }) => {
@@ -44,6 +45,35 @@ const EmptyState = ({ message }) => {
44
45
  const theme = useTUITheme();
45
46
  return (_jsxs(Box, { flexDirection: "column", padding: 2, alignItems: "center", children: [_jsx(Text, { bold: true, color: theme.warning, children: "No Services Found" }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { color: theme.muted, children: [theme.ascii ? "[*]" : "\u25c9", " No service.json files found"] }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: theme.foreground, children: "To get started:" }), _jsx(Text, { color: theme.foreground, children: " 1. Run: tdk project" }), _jsx(Text, { color: theme.foreground, children: " 2. Run: tdk resource api --type backend" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: theme.accent, children: "Press [r] to refresh or [q] to quit" }), message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: theme.accent, children: [theme.bannerStart, " ", message, " ", theme.bannerEnd] }) }))] }));
46
47
  };
48
+ function formatResourceStatus(resource) {
49
+ const statuses = [];
50
+ if (resource.runtimeStatus)
51
+ statuses.push(`Runtime ${resource.runtimeStatus}`);
52
+ if (resource.updateStatus)
53
+ statuses.push(`Update ${resource.updateStatus}`);
54
+ if (resource.hasPendingChanges)
55
+ statuses.push("Changes pending");
56
+ return statuses.join("; ") || "No status reported";
57
+ }
58
+ function formatEventTime(value) {
59
+ if (!value)
60
+ return "Current";
61
+ const date = new Date(value);
62
+ return Number.isNaN(date.getTime())
63
+ ? value.slice(0, 20)
64
+ : date
65
+ .toISOString()
66
+ .replace("T", " ")
67
+ .replace(/\.\d{3}Z$/, "Z");
68
+ }
69
+ function formatEventDetails(value, width) {
70
+ const normalized = stripTerminalControls(value).replace(/\s+/g, " ").trim();
71
+ const suffix = "...";
72
+ const maxLength = Math.max(suffix.length + 8, Math.floor(width) - 2);
73
+ return normalized.length > maxLength
74
+ ? normalized.slice(0, maxLength - suffix.length).trimEnd() + suffix
75
+ : normalized;
76
+ }
47
77
  export const TUIApp = ({ animated = true }) => {
48
78
  const theme = useTUITheme();
49
79
  const { exit } = useApp();
@@ -73,6 +103,10 @@ export const TUIApp = ({ animated = true }) => {
73
103
  const [error, setError] = useState(null);
74
104
  const [showTooltips, setShowTooltips] = useState(true);
75
105
  const [showEnabledOnly, setShowEnabledOnly] = useState(true);
106
+ const [eventSnapshot, setEventSnapshot] = useState(null);
107
+ const [eventLoadState, setEventLoadState] = useState("idle");
108
+ const [eventLoadError, setEventLoadError] = useState(null);
109
+ const eventRequestRef = useRef(0);
76
110
  const statusMessage = useMemo(() => createStatusMessageController(setMessage), []);
77
111
  useEffect(() => () => statusMessage.dispose(), [statusMessage]);
78
112
  const projectRoot = findProjectRoot() || "unknown";
@@ -113,9 +147,40 @@ export const TUIApp = ({ animated = true }) => {
113
147
  setLoading(false);
114
148
  }
115
149
  }, []);
150
+ const refreshEvents = useCallback(async () => {
151
+ const requestId = ++eventRequestRef.current;
152
+ setEventLoadState("loading");
153
+ setEventLoadError(null);
154
+ try {
155
+ const snapshot = await loadTiltEvents();
156
+ if (requestId !== eventRequestRef.current)
157
+ return;
158
+ setEventSnapshot(snapshot);
159
+ setEventLoadState("ready");
160
+ }
161
+ catch (error) {
162
+ if (requestId !== eventRequestRef.current)
163
+ return;
164
+ setEventSnapshot(null);
165
+ if (error instanceof TiltEventsLoadError && error.kind === "unavailable") {
166
+ setEventLoadState("unavailable");
167
+ return;
168
+ }
169
+ setEventLoadError(error instanceof Error ? error.message : String(error));
170
+ setEventLoadState("error");
171
+ }
172
+ }, []);
116
173
  useEffect(() => {
117
174
  refresh();
118
175
  }, [refresh]);
176
+ useEffect(() => {
177
+ if (activeTab !== "events")
178
+ return;
179
+ void refreshEvents();
180
+ return () => {
181
+ eventRequestRef.current += 1;
182
+ };
183
+ }, [activeTab, refreshEvents]);
119
184
  const selectedStackData = useMemo(() => {
120
185
  if (!selectedStack)
121
186
  return null;
@@ -388,7 +453,10 @@ export const TUIApp = ({ animated = true }) => {
388
453
  return;
389
454
  }
390
455
  if (input === "r") {
391
- if (refresh()) {
456
+ if (activeTab === "events") {
457
+ void refreshEvents();
458
+ }
459
+ else if (refresh()) {
392
460
  statusMessage.show("Data refreshed", 1500);
393
461
  }
394
462
  return;
@@ -494,10 +562,14 @@ export const TUIApp = ({ animated = true }) => {
494
562
  return _jsx(ErrorScreen, { error: error, onRetry: refresh });
495
563
  }
496
564
  const header = (_jsxs(_Fragment, { children: [_jsx(TUIHeader, { projectRoot: projectRoot, resourceCount: services.length, terminalWidth: terminalWidth, version: getPackageVersion() }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.muted, children: (theme.ascii ? "-" : "\u2500").repeat(getTerminalRuleWidth(terminalWidth)) }) })] }));
497
- if (services.length === 0) {
498
- return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [header, _jsx(EmptyState, { message: message })] }));
499
- }
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
565
+ 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" &&
566
+ (services.length === 0 ? (_jsx(EmptyState, { message: message })) : (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: "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 === "events" && (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Event Timeline ]" : "┌─ Event Timeline ─" }) }), (eventLoadState === "idle" || eventLoadState === "loading") && (_jsx(Text, { color: theme.muted, children: "Loading Tilt events..." })), eventLoadState === "unavailable" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.warning, children: "Tilt is not running or its UI is unreachable." }), _jsx(Text, { color: theme.muted, children: "Start the stack with tdk up, then press [r] to retry." })] })), eventLoadState === "error" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.error, children: "Could not load Tilt events." }), _jsx(Text, { color: theme.muted, children: formatEventDetails(eventLoadError ?? "Unknown Tilt error", mainPanelWidth) }), _jsx(Text, { color: theme.muted, children: "Press [r] to retry." })] })), eventLoadState === "ready" && (_jsxs(_Fragment, { children: [eventSnapshot && eventSnapshot.resources.length > 0 && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, color: theme.muted, children: "Current resources" }), eventSnapshot.resources.slice(0, 12).map((resource) => (_jsxs(Text, { color: theme.accent, children: [resource.name, ": ", formatResourceStatus(resource)] }, resource.name))), eventSnapshot.resources.length > 12 && (_jsx(Text, { color: theme.muted, children: "Showing first 12 resources" }))] })), _jsx(Text, { bold: true, color: theme.muted, children: "Recent events" }), eventSnapshot && eventSnapshot.events.length > 20 && (_jsx(Text, { color: theme.muted, children: "Showing first 20 events" })), eventSnapshot?.events.length ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: eventSnapshot.events.slice(0, 20).map((event) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: event.kind === "failed"
567
+ ? theme.error
568
+ : event.kind === "warning"
569
+ ? theme.warning
570
+ : event.kind === "running"
571
+ ? theme.info
572
+ : theme.success, children: [formatEventTime(event.occurredAt), " ", event.resourceName, " ", event.title] }), event.details && (_jsx(Text, { color: theme.muted, children: formatEventDetails(event.details, mainPanelWidth) }))] }, event.id))) })) : (_jsx(Text, { color: theme.muted, children: "No recent build events are available from Tilt yet." }))] }))] })), 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
501
573
  ? {}
502
574
  : { 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
503
575
  ? theme.ascii
@@ -529,6 +601,9 @@ function createHelpHint(activeTab, selectedStack, selectedService, showEnabledOn
529
601
  if (activeTab === "files") {
530
602
  return ["Select service to view files", ...common].join(separator);
531
603
  }
604
+ if (activeTab === "events") {
605
+ return ["[r] Refresh events", "[Tab] Next", "[?] help", "[q] Quit"].join(separator);
606
+ }
532
607
  if (activeTab === "config") {
533
608
  return ["View configurations", ...common].join(separator);
534
609
  }
@@ -5,7 +5,8 @@ import { Command } from "commander";
5
5
  import { ensureProjectRuntimeAssets, ProjectConfigNotFoundError, verifyMasterConfigs, } from "../generator/template-engine.js";
6
6
  import { DEVCONTAINER_DOCKER_FIX, detectHost, isContainerHost, WEBCONTAINER_UP_MESSAGE, } from "../utils/agent-host.js";
7
7
  import { handleDryRun } from "../utils/command-helpers.js";
8
- import { getDeferredResourceNames } from "../utils/doctor-runtime.js";
8
+ import { STANDARD_PORTS } from "../utils/constants.js";
9
+ import { getDeferredResourceNames, summarizeTiltBuildError } from "../utils/doctor-runtime.js";
9
10
  import { completeEnvFile } from "../utils/env-validator.js";
10
11
  import { errorFactories, handleTiltFailure, requireProjectRoot, runCommand, showErrorAndExit, withTiltCheck, } from "../utils/errors.js";
11
12
  import { formatCount } from "../utils/formatting.js";
@@ -17,6 +18,7 @@ import { findProjectRoot, getPackageVersion } from "../utils/paths.js";
17
18
  import { findAvailablePort } from "../utils/port-assignment.js";
18
19
  import { formatPortFallbackNotice } from "../utils/port-fallback-notice.js";
19
20
  import { isApiServiceType } from "../utils/resource-kind.js";
21
+ import { enforceServiceConfigGate, warnSchemaVersions } from "../utils/service-config-checks.js";
20
22
  import { appendHealthPath, resolveSubdomainBases } from "../utils/service-urls.js";
21
23
  import { discoverResources, discoverResourcesStrict, discoverStacks, stackExists, } from "../utils/services.js";
22
24
  import { buildSmokePlans, formatSmokeFailure, runSmokePlans } from "../utils/smoke.js";
@@ -223,6 +225,15 @@ export const upCommand = new Command("up")
223
225
  onDrift: (message) => emit?.({ ok: false }, [{ code: "DRIFT_DETECTED", message }]),
224
226
  });
225
227
  }
228
+ // Duplicate names, circular dependsOn and invalid ports are refused before anything starts, dry run included.
229
+ if (foundRoot) {
230
+ enforceServiceConfigGate(foundRoot, {
231
+ onInvalid: (message) => emit?.({ ok: false }, [{ code: "INVALID_SERVICE_CONFIG", message }]),
232
+ });
233
+ }
234
+ // A project without schemaVersion keeps working; say how to add it, but only where a person is reading.
235
+ if (foundRoot && !emit && !options.quiet)
236
+ warnSchemaVersions(foundRoot);
226
237
  const hostPortPlan = await getHostPortPlan(projectRoot, {
227
238
  inspectDocker: !options.dryRun,
228
239
  });
@@ -335,7 +346,7 @@ export const upCommand = new Command("up")
335
346
  }
336
347
  exportHostPortPlan(hostPortPlan);
337
348
  writeSavedHostPortPlan(projectRoot, hostPortPlan);
338
- const basePort = 10350;
349
+ const basePort = STANDARD_PORTS.tiltUi;
339
350
  const watchedPort = configuredTiltPort ?? basePort;
340
351
  const candidatePorts = options.only ? [...new Set([basePort, watchedPort])] : [watchedPort];
341
352
  const running = [];
@@ -450,7 +461,10 @@ export const upCommand = new Command("up")
450
461
  const report = resourcesJson
451
462
  ? buildStartupReport(resourcesJson, dependsOn, deferred)
452
463
  : {
453
- failed: readiness.failures,
464
+ failed: readiness.failures.map((failure) => ({
465
+ name: failure.name,
466
+ message: summarizeTiltBuildError(failure.message),
467
+ })),
454
468
  blocked: [],
455
469
  starting: [],
456
470
  };
@@ -5,8 +5,9 @@ import { useTUITheme } from "./ui-theme.js";
5
5
  export const TABS = [
6
6
  { id: "overview", label: "OVERVIEW", shortcut: "1" },
7
7
  { id: "resources", label: "RESOURCES", shortcut: "2" },
8
- { id: "files", label: "FILES", shortcut: "3" },
9
- { id: "config", label: "CONFIG", shortcut: "4" },
8
+ { id: "events", label: "EVENTS", shortcut: "3" },
9
+ { id: "files", label: "FILES", shortcut: "4" },
10
+ { id: "config", label: "CONFIG", shortcut: "5" },
10
11
  ];
11
12
  export const TabBar = ({ activeTab, compact = false, terminalWidth }) => {
12
13
  const theme = useTUITheme();
@@ -0,0 +1,42 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ const normalize = (text) => text.replace(/\r\n/g, "\n");
4
+ export const UPDATE_COMMAND = "UPDATE_GOLDEN=1 bun run test -- golden-generated-output";
5
+ /**
6
+ * Compares generated files with the committed golden copies in `goldenDir`. Returns one problem per file that
7
+ * changed, is missing a golden, or has a golden nothing generates any more. With `update` it rewrites the goldens
8
+ * instead and returns nothing, which is how an intentional change is accepted.
9
+ */
10
+ export function compareWithGolden(actual, goldenDir, update = false) {
11
+ if (update) {
12
+ for (const [name, content] of Object.entries(actual)) {
13
+ const target = join(goldenDir, name);
14
+ mkdirSync(dirname(target), { recursive: true });
15
+ writeFileSync(target, normalize(content));
16
+ }
17
+ for (const stale of staleGoldens(actual, goldenDir)) {
18
+ rmSync(join(goldenDir, stale), { force: true });
19
+ }
20
+ return [];
21
+ }
22
+ const problems = [];
23
+ for (const [name, content] of Object.entries(actual)) {
24
+ const path = join(goldenDir, name);
25
+ if (!existsSync(path)) {
26
+ problems.push(`${name}: no golden file yet`);
27
+ }
28
+ else if (normalize(readFileSync(path, "utf-8")) !== normalize(content)) {
29
+ problems.push(`${name}: generated output differs from its golden file`);
30
+ }
31
+ }
32
+ for (const stale of staleGoldens(actual, goldenDir)) {
33
+ problems.push(`${stale}: golden file that nothing generates any more`);
34
+ }
35
+ return problems;
36
+ }
37
+ function staleGoldens(actual, goldenDir) {
38
+ if (!existsSync(goldenDir))
39
+ return [];
40
+ return readdirSync(goldenDir).filter((name) => !(name in actual));
41
+ }
42
+ //# sourceMappingURL=golden-helpers.js.map
@@ -234,7 +234,7 @@ services:
234
234
  POSTGRES_PASSWORD: \${DB_PASSWORD}
235
235
  POSTGRES_DB: postgres
236
236
  ports:
237
- - "\${TDK_POSTGRES_PORT:-15432}:5432"
237
+ - "\${TDK_BIND_ADDRESS:-127.0.0.1}:\${TDK_POSTGRES_PORT:-15432}:5432"
238
238
  volumes:
239
239
  - ${name}_postgres_data:/var/lib/postgresql/data
240
240
  networks:
@@ -0,0 +1,8 @@
1
+ import { MIN_BUN_VERSION, versionMeetsMinimum } from "../commands/doctor.js";
2
+ /** The Bun floor `tdk doctor` enforces, so `tdk up` cannot drift from it. */
3
+ export function bunMeetsFloor(version) {
4
+ return versionMeetsMinimum(version, MIN_BUN_VERSION);
5
+ }
6
+ /** "1.2+" for a floor of 1.2.0, the wording the preflight already printed. */
7
+ export const BUN_FLOOR_LABEL = `${MIN_BUN_VERSION[0]}.${MIN_BUN_VERSION[1]}+`;
8
+ //# sourceMappingURL=bun-floor.js.map
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { checkDockerCompose, checkDockerRuntime, checkDockerVersions, checkTilt, } from "../commands/doctor.js";
5
+ import { BUN_FLOOR_LABEL, bunMeetsFloor } from "./bun-floor.js";
5
6
  import { checkNatsBroker } from "./doctor-wiring.js";
6
7
  import { getHostPortPlan } from "./host-port-config.js";
7
8
  import { findProjectRoot } from "./paths.js";
@@ -86,8 +87,7 @@ export async function runColdPreflight(opts = {}) {
86
87
  encoding: "utf-8",
87
88
  timeout: 3000,
88
89
  }).trim();
89
- const [major = 0, minor = 0] = version.split(".").map((part) => Number.parseInt(part, 10));
90
- bun = major > 1 || (major === 1 && minor >= 2);
90
+ bun = bunMeetsFloor(version);
91
91
  }
92
92
  catch {
93
93
  /* reported as a failed runtime check */
@@ -96,7 +96,9 @@ export async function runColdPreflight(opts = {}) {
96
96
  items.push({
97
97
  id: "bun",
98
98
  ok: bun,
99
- message: bun ? "Bun 1.2+ is available on PATH" : "Bun 1.2+ not found on PATH",
99
+ message: bun
100
+ ? `Bun ${BUN_FLOOR_LABEL} is available on PATH`
101
+ : `Bun ${BUN_FLOOR_LABEL} not found on PATH`,
100
102
  fix: bun ? undefined : "curl -fsSL https://bun.sh/install | bash",
101
103
  });
102
104
  try {
@@ -157,7 +159,7 @@ export async function runColdPreflight(opts = {}) {
157
159
  const header = failures.length
158
160
  ? machineFailed
159
161
  ? bunFailed
160
- ? "Cold start blocked. Bun 1.2+ must be installed on PATH.\nPrisma/NATS configuration is project-specific and generated after `tdk project`. Fix the machine checks below."
162
+ ? `Cold start blocked. Bun ${BUN_FLOOR_LABEL} must be installed on PATH.\nPrisma/NATS configuration is project-specific and generated after \`tdk project\`. Fix the machine checks below.`
161
163
  : "Cold start blocked. Bun/Prisma/NATS are not the first failure.\nThey are generated after `tdk project`. Fix the machine checks below."
162
164
  : "Machine is ready. Project wiring is not."
163
165
  : "";
@@ -2,6 +2,7 @@ import { execFileSync, execSync } from "node:child_process";
2
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
3
  import { createServer } from "node:net";
4
4
  import { join } from "node:path";
5
+ import { STANDARD_PORTS } from "./constants.js";
5
6
  import { formatCount } from "./formatting.js";
6
7
  import { findProjectRoot } from "./paths.js";
7
8
  import { isApiServiceType } from "./resource-kind.js";
@@ -590,7 +591,9 @@ export function checkTiltResourceHealth(exec = execSync) {
590
591
  const hasRegistryFailure = ordered.some((failure) => isRegistryRelatedBuildError(failure.error) ||
591
592
  isRegistryRelatedBuildError(summarizeTiltBuildError(failure.error)));
592
593
  const hasKafkaRuntimeFailure = described.some(({ why }) => /Kafka broker unreachable|kafka:9092/i.test(why));
593
- let fix = "Open http://localhost:10350, inspect the red resources, fix the listed errors, then `tilt trigger <resource>` or re-run `tdk up`.";
594
+ let fix = "Open the Tilt UI URL printed by tdk up (default port " +
595
+ STANDARD_PORTS.tiltUi +
596
+ " when TILT_PORT is unset), inspect the red resources, fix the listed errors, then `tilt trigger <resource>` or re-run `tdk up`.";
594
597
  if (hasPortConflict) {
595
598
  fix =
596
599
  "Free the conflicting host port (usually 80/443): `docker ps --filter publish=80` then `docker stop <other-traefik>`. Re-trigger Traefik in the Tilt UI or run `tdk up` again.";
@@ -1,6 +1,6 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
- import { join, relative } from "node:path";
3
+ import { basename, dirname, join, relative } from "node:path";
4
4
  import { BACKEND_LANGUAGES } from "../backend-languages/registry.js";
5
5
  import { execAsync } from "./exec-async.js";
6
6
  import { formatCount } from "./formatting.js";
@@ -328,6 +328,67 @@ function listSourceFiles(dir) {
328
328
  }
329
329
  const LOCALHOST_PORT = /(?:localhost|127\.0\.0\.1):(\d{2,5})\b/g;
330
330
  const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*|<!--|#)/;
331
+ /**
332
+ * Two API backends in one stack used to share the stack-scoped Traefik routers (`/api/<stack>-management`), so the same rule
333
+ * matched both and Traefik chose between them arbitrarily. The engine now counts the routable backends of a stack inside one
334
+ * service directory (the same grouping as here: resources in one directory that name the same stack). When there are two or more,
335
+ * it drops the stack routers for all of them. A backend that sets its own `traefik.host` / `traefik.pathPrefix` keeps a router
336
+ * for just that route; the others are left with `/api/<name>` only. This check warns about the backends that lose the stack path
337
+ * and about two backends that set the same explicit route.
338
+ */
339
+ export function checkSharedStackRoutes(projectRoot = findProjectRoot() ?? process.cwd()) {
340
+ const groups = new Map();
341
+ for (const resource of discoverResourcesFromRoot(projectRoot)) {
342
+ const config = resource.config;
343
+ const routable = isApiServiceType(config?.appType) ||
344
+ (config?.appType === "bring-your-own" && config.exposeViaProxy !== false);
345
+ if (!routable)
346
+ continue;
347
+ const directory = dirname(resource.path);
348
+ const stack = config?.stack ?? basename(directory);
349
+ const key = `${directory}\0${stack}`;
350
+ const group = groups.get(key) ?? { stack, resources: [] };
351
+ group.resources.push(resource);
352
+ groups.set(key, group);
353
+ }
354
+ const problems = [];
355
+ for (const { stack, resources } of groups.values()) {
356
+ if (resources.length < 2)
357
+ continue;
358
+ const explicit = (resource) => Boolean(resource.config?.traefik?.host || resource.config?.traefik?.pathPrefix);
359
+ const lost = resources.filter((resource) => !explicit(resource)).map((r) => r.name);
360
+ if (lost.length > 0) {
361
+ problems.push(`stack "${stack}": ${lost.sort().join(", ")} lose the shared /api/${stack}-management route because ${resources.length} backends share the stack; they are routed at /api/<name> only`);
362
+ }
363
+ for (const field of ["host", "pathPrefix"]) {
364
+ const byValue = new Map();
365
+ for (const resource of resources) {
366
+ const value = resource.config?.traefik?.[field];
367
+ if (value)
368
+ byValue.set(value, [...(byValue.get(value) ?? []), resource.name]);
369
+ }
370
+ for (const [value, names] of byValue) {
371
+ if (names.length > 1) {
372
+ problems.push(`stack "${stack}": ${names.sort().join(" and ")} both set traefik.${field} "${value}", so Traefik cannot tell them apart`);
373
+ }
374
+ }
375
+ }
376
+ }
377
+ if (problems.length === 0) {
378
+ return {
379
+ name: "Stack API routes",
380
+ didPass: true,
381
+ message: "No two backends in one service directory compete for one stack route",
382
+ };
383
+ }
384
+ return {
385
+ name: "Stack API routes",
386
+ didPass: false,
387
+ isWarning: true,
388
+ message: problems.join("\n "),
389
+ fix: 'Call each backend at /api/<name>, move one backend to its own stack, or give each its own "traefik.pathPrefix" in service.json',
390
+ };
391
+ }
331
392
  export function checkFrontendBackendUrls(projectRoot = findProjectRoot() ?? process.cwd()) {
332
393
  const resources = discoverResourcesFromRoot(projectRoot);
333
394
  const byPort = new Map([...backendPorts(resources)].map(([name, port]) => [port, name]));
@@ -1,4 +1,5 @@
1
1
  import { createServer } from "node:net";
2
+ import { hasLocalListener } from "./port-listener-probe.js";
2
3
  export const DEFAULT_HOST_PORT_RANGES = {
3
4
  ingressHttp: { start: 8080, end: 8180 },
4
5
  ingressHttps: { start: 8443, end: 8543 },
@@ -11,6 +12,8 @@ const ENV_KEYS = {
11
12
  };
12
13
  const REQUESTED_PORTS = { ingressHttp: 80, ingressHttps: 443, postgres: 5432 };
13
14
  export async function isHostPortAvailable(port) {
15
+ if (await hasLocalListener(port))
16
+ return false;
14
17
  return new Promise((resolve) => {
15
18
  const server = createServer();
16
19
  server.once("error", () => resolve(false));
@@ -0,0 +1,24 @@
1
+ /** The env var that moves each host port TDK publishes. Services publish no host ports, so only these conflict. */
2
+ const PORT_ENV_VARS = {
3
+ "80": "TDK_HTTP_PORT",
4
+ "443": "TDK_HTTPS_PORT",
5
+ "5432": "TDK_POSTGRES_PORT",
6
+ };
7
+ /**
8
+ * The next step for a host port another process already holds. The occupant is not named: finding it needs the OS, so
9
+ * the command that finds it is given instead.
10
+ */
11
+ export function portConflictFix(message) {
12
+ // The raw Docker error can reach the report when the readiness check fails before the summary runs.
13
+ const match = message.match(/^host port (\d+) is already allocated$/) ??
14
+ message.match(/Bind for [^ ]+:(\d+) failed: port is already allocated/i);
15
+ if (!match)
16
+ return null;
17
+ const port = match[1];
18
+ const find = `find it with: lsof -nP -iTCP:${port} -sTCP:LISTEN`;
19
+ const envVar = PORT_ENV_VARS[port];
20
+ if (!envVar)
21
+ return `Fix: stop the process using port ${port} (${find})`;
22
+ return `Fix: stop the process using port ${port} (${find}), or set ${envVar} to a free port and run tdk up`;
23
+ }
24
+ //# sourceMappingURL=port-conflict-fix.js.map
@@ -0,0 +1,19 @@
1
+ import { connect } from "node:net";
2
+ /**
3
+ * True when something already accepts connections on 127.0.0.1:port. Binding a socket to find out is not enough:
4
+ * on macOS a bind on 127.0.0.1 succeeds even while another process listens on the same port, so a busy port such as
5
+ * 5432 looked free and `tdk doctor` passed before Docker failed to publish it.
6
+ */
7
+ export function hasLocalListener(port, timeoutMs = 500) {
8
+ return new Promise((resolve) => {
9
+ const socket = connect({ port, host: "127.0.0.1" });
10
+ const finish = (result) => {
11
+ socket.destroy();
12
+ resolve(result);
13
+ };
14
+ socket.setTimeout(timeoutMs, () => finish(false));
15
+ socket.once("connect", () => finish(true));
16
+ socket.once("error", () => finish(false));
17
+ });
18
+ }
19
+ //# sourceMappingURL=port-listener-probe.js.map
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseEnv } from "./env-validator.js";
4
+ export const REDACTED = "[REDACTED]";
5
+ export class EnvUnreadableError extends Error {
6
+ path;
7
+ constructor(path, reason) {
8
+ super(`Could not read ${path}: ${reason}`);
9
+ this.path = path;
10
+ this.name = "EnvUnreadableError";
11
+ }
12
+ }
13
+ /**
14
+ * Shorter values are left alone: masking a two-letter value would hit ordinary words in every log line. The cost is
15
+ * that a short secret in .env is not masked, so keep real secrets at least this long.
16
+ */
17
+ export const MIN_REDACTED_VALUE_LENGTH = 8;
18
+ /**
19
+ * Values of the project's own .env file, longest first so a value that contains another is masked whole. Throws when the
20
+ * file exists but cannot be read: continuing without the values would print secrets unmasked.
21
+ */
22
+ export function envSecretValues(projectRoot) {
23
+ const envPath = join(projectRoot, ".env");
24
+ if (!existsSync(envPath))
25
+ return [];
26
+ let content;
27
+ try {
28
+ content = readFileSync(envPath, "utf-8");
29
+ }
30
+ catch (error) {
31
+ throw new EnvUnreadableError(envPath, error instanceof Error ? error.message : String(error));
32
+ }
33
+ const values = [...parseEnv(content).values()].filter((value) => value.length >= MIN_REDACTED_VALUE_LENGTH);
34
+ return [...new Set(values)].sort((a, b) => b.length - a.length);
35
+ }
36
+ export function redactSecrets(text, secrets) {
37
+ let result = text;
38
+ for (const secret of secrets)
39
+ result = result.split(secret).join(REDACTED);
40
+ return result;
41
+ }
42
+ /** Redacts every string inside a JSON-shaped value, so the output stays valid JSON. */
43
+ export function redactValue(value, secrets) {
44
+ if (secrets.length === 0)
45
+ return value;
46
+ if (typeof value === "string")
47
+ return redactSecrets(value, secrets);
48
+ if (Array.isArray(value))
49
+ return value.map((item) => redactValue(item, secrets));
50
+ if (value !== null && typeof value === "object") {
51
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactValue(item, secrets)]));
52
+ }
53
+ return value;
54
+ }
55
+ //# sourceMappingURL=secret-redaction.js.map