@tdk-landscape/tdk-cli-core 1.3.120 → 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 (56) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +21 -14
  3. package/cli/dist/cli.js +1 -1
  4. package/cli/dist/commands/config.js +6 -2
  5. package/cli/dist/commands/doctor.js +64 -7
  6. package/cli/dist/commands/help.js +17 -1
  7. package/cli/dist/commands/logs.js +34 -7
  8. package/cli/dist/commands/networks.js +1 -1
  9. package/cli/dist/commands/resources.js +1 -1
  10. package/cli/dist/commands/stack.js +94 -22
  11. package/cli/dist/commands/status.js +35 -2
  12. package/cli/dist/commands/ui.js +105 -9
  13. package/cli/dist/commands/up.js +105 -28
  14. package/cli/dist/components/ServiceIssues.js +12 -0
  15. package/cli/dist/components/TabBar.js +3 -2
  16. package/cli/dist/components/index.js +1 -0
  17. package/cli/dist/generator/__tests__/golden-helpers.js +42 -0
  18. package/cli/dist/generator/template-engine.js +5 -3
  19. package/cli/dist/utils/atomic-write.js +35 -0
  20. package/cli/dist/utils/bun-floor.js +8 -0
  21. package/cli/dist/utils/cold-preflight.js +6 -4
  22. package/cli/dist/utils/doctor-runtime.js +6 -6
  23. package/cli/dist/utils/doctor-wiring.js +62 -1
  24. package/cli/dist/utils/errors.js +20 -2
  25. package/cli/dist/utils/host-port-plan.js +3 -0
  26. package/cli/dist/utils/machine-output.js +8 -1
  27. package/cli/dist/utils/port-conflict-fix.js +24 -0
  28. package/cli/dist/utils/port-fallback-notice.js +18 -0
  29. package/cli/dist/utils/port-listener-probe.js +19 -0
  30. package/cli/dist/utils/secret-redaction.js +55 -0
  31. package/cli/dist/utils/service-config-checks.js +188 -0
  32. package/cli/dist/utils/service-runtime-state.js +67 -0
  33. package/cli/dist/utils/services.js +2 -15
  34. package/cli/dist/utils/single-flight.js +20 -0
  35. package/cli/dist/utils/stack-status.js +15 -0
  36. package/cli/dist/utils/startup-report.js +73 -0
  37. package/cli/dist/utils/suggestions.js +58 -0
  38. package/cli/dist/utils/tilt-events.js +195 -0
  39. package/cli/dist/utils/tilt-resource-state.js +12 -0
  40. package/cli/dist/utils/tilt-startup.js +7 -0
  41. package/cli/dist/utils/tilt-unreachable-message.js +8 -0
  42. package/cli/dist/utils/ui-service-state.js +35 -0
  43. package/cli/dist/utils/up-readiness.js +2 -0
  44. package/cli/package.json +1 -1
  45. package/cli/templates/.tiltignore.hbs +1 -2
  46. package/cli/templates/TILT_RESOURCE_DEFAULTS.star.hbs +1 -1
  47. package/cli/templates/TILT_TECH_STACK.star.hbs +5 -5
  48. package/cli/templates/Tiltfile.hbs +3 -3
  49. package/cli/templates/spec.master.hbs +1 -1
  50. package/engine/topologies/platform/docker/compose/compose.star +1 -86
  51. package/engine/topologies/platform/docker/compose/traefik_standalone.star +4 -4
  52. package/engine/topologies/platform/docker/networking/traefik.star +40 -7
  53. package/engine/topologies/platform/docker/networking/traefik_helpers.star +1 -1
  54. package/engine/topologies/tilt/resources/infra-loader.star +3 -2
  55. package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +20 -0
  56. package/package.json +1 -1
@@ -1,17 +1,20 @@
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";
5
- import { DetailPanel, FileTree, ResourceSelectInput, ResourceTable, TabBar, TUIHeader, } from "../components/index.js";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 { loadTiltEvents, stripTerminalControls, TiltEventsLoadError, } from "../utils/tilt-events.js";
17
+ import { applyServiceStates, fetchServiceStates, } from "../utils/ui-service-state.js";
15
18
  // biome-ignore lint/correctness/noUnusedFunctionParameters: reserved callback prop kept in the component API
16
19
  const HelpPanel = ({ onClose }) => {
17
20
  const theme = useTUITheme();
@@ -42,6 +45,35 @@ const EmptyState = ({ message }) => {
42
45
  const theme = useTUITheme();
43
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] }) }))] }));
44
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
+ }
45
77
  export const TUIApp = ({ animated = true }) => {
46
78
  const theme = useTUITheme();
47
79
  const { exit } = useApp();
@@ -71,10 +103,33 @@ export const TUIApp = ({ animated = true }) => {
71
103
  const [error, setError] = useState(null);
72
104
  const [showTooltips, setShowTooltips] = useState(true);
73
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);
74
110
  const statusMessage = useMemo(() => createStatusMessageController(setMessage), []);
75
111
  useEffect(() => () => statusMessage.dispose(), [statusMessage]);
76
112
  const projectRoot = findProjectRoot() || "unknown";
77
113
  const [{ stacks, services }, setDiscovered] = useState({ stacks: [], services: [] });
114
+ // What the running Tilt says about each service: the same answer `tdk status` gives. Empty while no Tilt answers.
115
+ const [serviceStates, setServiceStates] = useState({});
116
+ useEffect(() => {
117
+ let cancelled = false;
118
+ const tiltPort = Number.parseInt(process.env.TILT_PORT ?? "", 10);
119
+ // A Tilt call can outlast the 5 s interval; never start a poll while the last one is still running.
120
+ const run = singleFlight((next) => {
121
+ if (cancelled)
122
+ return;
123
+ setServiceStates((previous) => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);
124
+ });
125
+ const poll = () => run(() => fetchServiceStates(services, Number.isInteger(tiltPort) ? tiltPort : 10350));
126
+ void poll();
127
+ const timer = setInterval(() => void poll(), 5000);
128
+ return () => {
129
+ cancelled = true;
130
+ clearInterval(timer);
131
+ };
132
+ }, [services]);
78
133
  // Re-read service.json files from disk. An empty project is not an error:
79
134
  // it renders EmptyState. Only a failed discovery shows ErrorScreen.
80
135
  const refresh = useCallback(() => {
@@ -92,9 +147,40 @@ export const TUIApp = ({ animated = true }) => {
92
147
  setLoading(false);
93
148
  }
94
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
+ }, []);
95
173
  useEffect(() => {
96
174
  refresh();
97
175
  }, [refresh]);
176
+ useEffect(() => {
177
+ if (activeTab !== "events")
178
+ return;
179
+ void refreshEvents();
180
+ return () => {
181
+ eventRequestRef.current += 1;
182
+ };
183
+ }, [activeTab, refreshEvents]);
98
184
  const selectedStackData = useMemo(() => {
99
185
  if (!selectedStack)
100
186
  return null;
@@ -103,9 +189,9 @@ export const TUIApp = ({ animated = true }) => {
103
189
  return null;
104
190
  return {
105
191
  stack,
106
- metadata: getStackMetadata(stack),
192
+ metadata: applyServiceStates(getStackMetadata(stack), serviceStates),
107
193
  };
108
- }, [selectedStack, stacks]);
194
+ }, [selectedStack, stacks, serviceStates]);
109
195
  const selectedServiceData = useMemo(() => {
110
196
  if (!selectedService)
111
197
  return null;
@@ -367,7 +453,10 @@ export const TUIApp = ({ animated = true }) => {
367
453
  return;
368
454
  }
369
455
  if (input === "r") {
370
- if (refresh()) {
456
+ if (activeTab === "events") {
457
+ void refreshEvents();
458
+ }
459
+ else if (refresh()) {
371
460
  statusMessage.show("Data refreshed", 1500);
372
461
  }
373
462
  return;
@@ -473,10 +562,14 @@ export const TUIApp = ({ animated = true }) => {
473
562
  return _jsx(ErrorScreen, { error: error, onRetry: refresh });
474
563
  }
475
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)) }) })] }));
476
- if (services.length === 0) {
477
- return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [header, _jsx(EmptyState, { message: message })] }));
478
- }
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
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
480
573
  ? {}
481
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
482
575
  ? theme.ascii
@@ -508,6 +601,9 @@ function createHelpHint(activeTab, selectedStack, selectedService, showEnabledOn
508
601
  if (activeTab === "files") {
509
602
  return ["Select service to view files", ...common].join(separator);
510
603
  }
604
+ if (activeTab === "events") {
605
+ return ["[r] Refresh events", "[Tab] Next", "[?] help", "[q] Quit"].join(separator);
606
+ }
511
607
  if (activeTab === "config") {
512
608
  return ["View configurations", ...common].join(separator);
513
609
  }
@@ -5,23 +5,28 @@ 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";
12
13
  import { exportHostPortPlan, getHostPortPlan, writeSavedHostPortPlan, } from "../utils/host-port-config.js";
13
14
  import { formatHostPortPlan } from "../utils/host-port-plan.js";
14
15
  import { createJsonEmitter } from "../utils/json-output.js";
16
+ import { toMachineError } from "../utils/machine-output.js";
15
17
  import { findProjectRoot, getPackageVersion } from "../utils/paths.js";
16
18
  import { findAvailablePort } from "../utils/port-assignment.js";
19
+ import { formatPortFallbackNotice } from "../utils/port-fallback-notice.js";
17
20
  import { isApiServiceType } from "../utils/resource-kind.js";
21
+ import { enforceServiceConfigGate, warnSchemaVersions } from "../utils/service-config-checks.js";
18
22
  import { appendHealthPath, resolveSubdomainBases } from "../utils/service-urls.js";
19
23
  import { discoverResources, discoverResourcesStrict, discoverStacks, stackExists, } from "../utils/services.js";
20
24
  import { buildSmokePlans, formatSmokeFailure, runSmokePlans } from "../utils/smoke.js";
25
+ import { buildStartupReport, formatStartupReport, isStartupStalled, } from "../utils/startup-report.js";
21
26
  import { evaluateTdkVersionFloor } from "../utils/tdk-version.js";
22
27
  import { buildTiltUpArgs, runTilt } from "../utils/tilt.js";
23
28
  import { stopTiltOnPort } from "../utils/tilt-process.js";
24
- import { parseTiltPort, resolveTiltPort, stopTiltForUp } from "../utils/tilt-startup.js";
29
+ import { parseTiltPort, resolveTiltPort, secondUpAction, stopTiltForUp, } from "../utils/tilt-startup.js";
25
30
  import { findUnknownServices, resolveOnlySelection } from "../utils/up-only.js";
26
31
  import { tiltGetUiResources, waitForTiltResourcesReady } from "../utils/up-readiness.js";
27
32
  import { enableDiscoveredStacks } from "./project.js";
@@ -183,18 +188,34 @@ export const upCommand = new Command("up")
183
188
  const foundRoot = options.dryRun ? requireProjectRoot() : findProjectRoot();
184
189
  const projectRoot = foundRoot ?? process.cwd();
185
190
  const discoveredResources = discoverResourcesStrict();
191
+ let discoveredStacks;
192
+ let discoveredStackNames = [];
193
+ if (!options.only || stackName) {
194
+ discoveredStacks = discoverStacks(discoveredResources);
195
+ discoveredStackNames = discoveredStacks.map((stack) => stack.name);
196
+ }
186
197
  // Reject a bad request before anything below can write to the project (.env, runtime assets, .tdk/project.json).
187
198
  if (options.only) {
188
- if (stackName && !stackExists(stackName))
189
- errorFactories.stackNotFound(stackName).exit();
199
+ if (stackName && !stackExists(stackName)) {
200
+ const error = errorFactories.stackNotFound(stackName, discoveredStackNames);
201
+ emit?.({ ok: false }, [toMachineError(error).error]);
202
+ error.exit();
203
+ }
190
204
  const candidates = stackName
191
205
  ? discoveredResources.filter((resource) => resource.stack === stackName)
192
206
  : discoveredResources;
193
207
  const unknown = findUnknownServices(options.only, candidates);
194
208
  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);
209
+ const validNames = candidates.map((service) => service.name);
210
+ const error = errorFactories.unknownServices(unknown, validNames);
211
+ emit?.({ ok: false }, [
212
+ {
213
+ code: "UNKNOWN_SERVICE",
214
+ message: error.message,
215
+ ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
216
+ },
217
+ ]);
218
+ error.exit();
198
219
  }
199
220
  }
200
221
  // Also under --dry-run: the check only reads, and a dry run should show what a real run would refuse.
@@ -204,6 +225,15 @@ export const upCommand = new Command("up")
204
225
  onDrift: (message) => emit?.({ ok: false }, [{ code: "DRIFT_DETECTED", message }]),
205
226
  });
206
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);
207
237
  const hostPortPlan = await getHostPortPlan(projectRoot, {
208
238
  inspectDocker: !options.dryRun,
209
239
  });
@@ -231,14 +261,16 @@ export const upCommand = new Command("up")
231
261
  if (stackName) {
232
262
  servicesToStart = discoveredResources.filter((resource) => resource.stack === stackName);
233
263
  if (servicesToStart.length === 0) {
234
- errorFactories.stackNotFound(stackName).exit();
264
+ const error = errorFactories.stackNotFound(stackName, discoveredStackNames);
265
+ emit?.({ ok: false }, [toMachineError(error).error]);
266
+ error.exit();
235
267
  }
236
268
  focusServiceNames = servicesToStart.map((s) => s.name);
237
269
  stackDescription = `stack "${stackName}"`;
238
270
  }
239
271
  else {
240
272
  servicesToStart = discoveredResources;
241
- const allStacks = discoverStacks(discoveredResources);
273
+ const allStacks = discoveredStacks ?? discoverStacks(discoveredResources);
242
274
  stackDescription = `all stacks (${formatCount(allStacks.length, "stack")}, ${formatCount(servicesToStart.length, "service")})`;
243
275
  }
244
276
  let dependencyNames = [];
@@ -292,6 +324,8 @@ export const upCommand = new Command("up")
292
324
  const dryRunCommand = focusTargets ? `tilt up ${tiltArgs.join(" ")}` : "tilt up";
293
325
  if (!options.quiet) {
294
326
  console.log(chalk.blue(formatHostPortPlan(hostPortPlan)));
327
+ for (const line of formatPortFallbackNotice(hostPortPlan))
328
+ console.log(chalk.yellow(line));
295
329
  console.log(chalk.gray("Override with TDK_HTTP_PORT, TDK_HTTPS_PORT, or TDK_POSTGRES_PORT."));
296
330
  }
297
331
  if (emit && options.dryRun) {
@@ -312,24 +346,36 @@ export const upCommand = new Command("up")
312
346
  }
313
347
  exportHostPortPlan(hostPortPlan);
314
348
  writeSavedHostPortPlan(projectRoot, hostPortPlan);
315
- 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.
349
+ const basePort = STANDARD_PORTS.tiltUi;
350
+ const watchedPort = configuredTiltPort ?? basePort;
351
+ const candidatePorts = options.only ? [...new Set([basePort, watchedPort])] : [watchedPort];
352
+ const running = [];
353
+ for (const candidate of candidatePorts) {
354
+ if ((await tiltGetUiResources(candidate)) !== null)
355
+ running.push(candidate);
356
+ }
357
+ const decision = secondUpAction({
358
+ runningPorts: running,
359
+ force: options.force,
360
+ only: Boolean(options.only),
361
+ });
362
+ if (decision.action === "already-running") {
363
+ const message = `Environment is already running on port ${decision.ports.join(", ")}.`;
364
+ emit?.({
365
+ ok: true,
366
+ alreadyRunning: true,
367
+ tiltUrl: `http://localhost:${decision.ports[0]}`,
368
+ });
369
+ if (!options.quiet)
370
+ console.log(chalk.green(message));
371
+ return;
372
+ }
373
+ if (decision.action === "only-blocked") {
374
+ 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.`;
375
+ emit?.({ ok: false }, [{ code: "TILT_ALREADY_RUNNING", message }]);
376
+ showErrorAndExit(message);
377
+ }
319
378
  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
379
  for (const runningPort of running)
334
380
  stopTiltOnPort(runningPort);
335
381
  }
@@ -364,6 +410,7 @@ export const upCommand = new Command("up")
364
410
  const smokePlans = buildSmokePlans(servicesToStart, hostPortPlan.ingressHttp);
365
411
  let smokeFailed = false;
366
412
  let printedSuccess = false;
413
+ let startupFailed = false;
367
414
  const successOutput = (async () => {
368
415
  const uiReady = await waitForTiltUi(port);
369
416
  // JSON consumers get success only after every non-deferred resource is built and running, not when the UI port opens.
@@ -399,12 +446,39 @@ export const upCommand = new Command("up")
399
446
  ]);
400
447
  }
401
448
  }
402
- if (uiReady && !options.quiet) {
449
+ // The UI port opening only means Tilt started. Say "up" once every resource is built and running, and otherwise name
450
+ // what failed and what it blocks.
451
+ if (uiReady && !emit) {
452
+ const deferred = getDeferredResourceNames();
453
+ const dependsOn = Object.fromEntries(discoverResources().map((r) => [r.name, r.config?.dependsOn ?? []]));
454
+ const readiness = await waitForTiltResourcesReady(port, {
455
+ deferred,
456
+ isStalled: (text) => isStartupStalled(text, dependsOn, deferred),
457
+ });
458
+ if (!readiness.ready) {
459
+ startupFailed = true;
460
+ const resourcesJson = await tiltGetUiResources(port);
461
+ const report = resourcesJson
462
+ ? buildStartupReport(resourcesJson, dependsOn, deferred)
463
+ : {
464
+ failed: readiness.failures.map((failure) => ({
465
+ name: failure.name,
466
+ message: summarizeTiltBuildError(failure.message),
467
+ })),
468
+ blocked: [],
469
+ starting: [],
470
+ };
471
+ for (const line of formatStartupReport(report, readiness.timedOut)) {
472
+ console.error(chalk.red(line));
473
+ }
474
+ }
475
+ }
476
+ if (uiReady && !options.quiet && !startupFailed) {
403
477
  for (const line of formatUpSuccess(port))
404
478
  console.log(chalk.blue(line));
405
479
  printedSuccess = true;
406
480
  }
407
- if (uiReady && smokePlans.length > 0 && (!emit || jsonReady)) {
481
+ if (uiReady && smokePlans.length > 0 && (emit ? jsonReady : !startupFailed)) {
408
482
  if (!options.quiet) {
409
483
  console.log(chalk.gray(`Smoke check: ${smokePlans.map((p) => p.name).join(", ")}`));
410
484
  }
@@ -446,6 +520,9 @@ export const upCommand = new Command("up")
446
520
  if (result.exitCode !== 0) {
447
521
  handleTiltFailure("up", result.exitCode);
448
522
  }
523
+ if (startupFailed && result.exitCode === 0) {
524
+ process.exit(1);
525
+ }
449
526
  if (!options.quiet && result.exitCode === 0 && !printedSuccess) {
450
527
  for (const line of formatUpSuccess(port))
451
528
  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
@@ -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();
@@ -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";
@@ -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
@@ -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:
@@ -232,7 +234,7 @@ services:
232
234
  POSTGRES_PASSWORD: \${DB_PASSWORD}
233
235
  POSTGRES_DB: postgres
234
236
  ports:
235
- - "\${TDK_POSTGRES_PORT:-15432}:5432"
237
+ - "\${TDK_BIND_ADDRESS:-127.0.0.1}:\${TDK_POSTGRES_PORT:-15432}:5432"
236
238
  volumes:
237
239
  - ${name}_postgres_data:/var/lib/postgresql/data
238
240
  networks:
@@ -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
@@ -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
  : "";