@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.
- package/CHANGELOG.md +1 -0
- package/README.md +21 -14
- package/cli/dist/cli.js +1 -1
- package/cli/dist/commands/config.js +6 -2
- package/cli/dist/commands/doctor.js +64 -7
- package/cli/dist/commands/help.js +17 -1
- package/cli/dist/commands/logs.js +34 -7
- package/cli/dist/commands/networks.js +1 -1
- package/cli/dist/commands/resources.js +1 -1
- package/cli/dist/commands/stack.js +94 -22
- package/cli/dist/commands/status.js +35 -2
- package/cli/dist/commands/ui.js +105 -9
- package/cli/dist/commands/up.js +105 -28
- package/cli/dist/components/ServiceIssues.js +12 -0
- package/cli/dist/components/TabBar.js +3 -2
- package/cli/dist/components/index.js +1 -0
- package/cli/dist/generator/__tests__/golden-helpers.js +42 -0
- package/cli/dist/generator/template-engine.js +5 -3
- package/cli/dist/utils/atomic-write.js +35 -0
- package/cli/dist/utils/bun-floor.js +8 -0
- package/cli/dist/utils/cold-preflight.js +6 -4
- package/cli/dist/utils/doctor-runtime.js +6 -6
- package/cli/dist/utils/doctor-wiring.js +62 -1
- package/cli/dist/utils/errors.js +20 -2
- package/cli/dist/utils/host-port-plan.js +3 -0
- package/cli/dist/utils/machine-output.js +8 -1
- package/cli/dist/utils/port-conflict-fix.js +24 -0
- package/cli/dist/utils/port-fallback-notice.js +18 -0
- package/cli/dist/utils/port-listener-probe.js +19 -0
- package/cli/dist/utils/secret-redaction.js +55 -0
- package/cli/dist/utils/service-config-checks.js +188 -0
- package/cli/dist/utils/service-runtime-state.js +67 -0
- package/cli/dist/utils/services.js +2 -15
- package/cli/dist/utils/single-flight.js +20 -0
- package/cli/dist/utils/stack-status.js +15 -0
- package/cli/dist/utils/startup-report.js +73 -0
- package/cli/dist/utils/suggestions.js +58 -0
- package/cli/dist/utils/tilt-events.js +195 -0
- package/cli/dist/utils/tilt-resource-state.js +12 -0
- package/cli/dist/utils/tilt-startup.js +7 -0
- package/cli/dist/utils/tilt-unreachable-message.js +8 -0
- package/cli/dist/utils/ui-service-state.js +35 -0
- package/cli/dist/utils/up-readiness.js +2 -0
- package/cli/package.json +1 -1
- package/cli/templates/.tiltignore.hbs +1 -2
- package/cli/templates/TILT_RESOURCE_DEFAULTS.star.hbs +1 -1
- package/cli/templates/TILT_TECH_STACK.star.hbs +5 -5
- package/cli/templates/Tiltfile.hbs +3 -3
- package/cli/templates/spec.master.hbs +1 -1
- package/engine/topologies/platform/docker/compose/compose.star +1 -86
- package/engine/topologies/platform/docker/compose/traefik_standalone.star +4 -4
- package/engine/topologies/platform/docker/networking/traefik.star +40 -7
- package/engine/topologies/platform/docker/networking/traefik_helpers.star +1 -1
- package/engine/topologies/tilt/resources/infra-loader.star +3 -2
- package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +20 -0
- package/package.json +1 -1
package/cli/dist/commands/ui.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
}
|
package/cli/dist/commands/up.js
CHANGED
|
@@ -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 {
|
|
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)
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
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)
|
|
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 =
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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 && (
|
|
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: "
|
|
9
|
-
{ id: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
?
|
|
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
|
: "";
|