@tdk-landscape/tdk-cli-core 1.3.116 → 1.3.118

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.
@@ -2,24 +2,26 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { useState } from "react";
4
4
  import { formatBytes } from "../utils/formatting.js";
5
+ import { useTUITheme } from "./ui-theme.js";
5
6
  const FILE_ICONS = {
6
- docker: "🐳",
7
- tilt: "⚙️",
8
- config: "🔧",
9
- prisma: "🗄️",
10
- generated: "📄",
11
- unknown: "📃",
7
+ docker: "\uD83D\uDC33",
8
+ tilt: "\u2699\uFE0F",
9
+ config: "\uD83D\uDD27",
10
+ prisma: "\uD83D\uDDC4\uFE0F",
11
+ generated: "\uD83D\uDCC4",
12
+ unknown: "\uD83D\uDCC3",
12
13
  };
13
- const FILE_COLORS = {
14
- docker: "blue",
15
- tilt: "cyan",
16
- config: "yellow",
17
- prisma: "magenta",
18
- generated: "gray",
19
- unknown: "white",
14
+ const ASCII_FILE_ICONS = {
15
+ docker: "[D]",
16
+ tilt: "[T]",
17
+ config: "[C]",
18
+ prisma: "[P]",
19
+ generated: "[G]",
20
+ unknown: "[?]",
20
21
  };
21
22
  // biome-ignore lint/correctness/noUnusedFunctionParameters: reserved callback prop kept in the component API
22
23
  export const FileTree = ({ nodes, onSelect, selectedPath }) => {
24
+ const theme = useTUITheme();
23
25
  const [expandedNodes, setExpandedNodes] = useState(new Set());
24
26
  const _toggleNode = (path) => {
25
27
  const newExpanded = new Set(expandedNodes);
@@ -36,11 +38,12 @@ export const FileTree = ({ nodes, onSelect, selectedPath }) => {
36
38
  const isSelected = selectedPath === node.path;
37
39
  const indent = " ".repeat(depth);
38
40
  if (node.type === "directory") {
39
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: isSelected ? "cyan" : "white", bold: isSelected, backgroundColor: isSelected ? "blue" : undefined, children: [indent, isExpanded ? "▼" : "▶", " ", node.name] }) }), isExpanded && node.children?.map((child) => renderNode(child, depth + 1))] }, node.path));
41
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.selectionForeground : theme.foreground, bold: isSelected, backgroundColor: isSelected ? theme.selectionBackground : undefined, children: [indent, theme.ascii ? (isExpanded ? "[-]" : "[+]") : isExpanded ? "\u25BC" : "\u25B6", " ", node.name] }) }), isExpanded && node.children?.map((child) => renderNode(child, depth + 1))] }, node.path));
40
42
  }
41
- const icon = node.fileType ? FILE_ICONS[node.fileType] : FILE_ICONS.unknown;
42
- const color = node.fileType ? FILE_COLORS[node.fileType] : FILE_COLORS.unknown;
43
- return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? "cyan" : color, bold: isSelected, backgroundColor: isSelected ? "blue" : undefined, children: [indent, " ", icon, " ", node.name] }), node.size && (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", "(", formatBytes(node.size), ")"] }))] }, node.path));
43
+ const fileType = node.fileType ?? "unknown";
44
+ const icon = theme.ascii ? ASCII_FILE_ICONS[fileType] : FILE_ICONS[fileType];
45
+ const color = theme.fileColors[fileType];
46
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? theme.selectionForeground : color, bold: isSelected, backgroundColor: isSelected ? theme.selectionBackground : undefined, children: [indent, " ", icon, " ", node.name] }), node.size && (_jsxs(Text, { color: theme.muted, dimColor: theme.dimMuted, children: [" ", "(", formatBytes(node.size), ")"] }))] }, node.path));
44
47
  };
45
48
  return (_jsx(Box, { flexDirection: "column", paddingX: 1, children: nodes.map((node) => renderNode(node, 0)) }));
46
49
  };
@@ -1,23 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, measureElement, Text, useInput } from "ink";
3
3
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import { useTUITheme } from "./ui-theme.js";
4
5
  // Keys match what ink-select-input handled: up/down or k/j (wrapping), 1-9 to pick, Enter to select.
5
- export const ResourceSelectInput = ({ items, onSelect, highlightedIndex, width, onLayout, }) => {
6
+ export const ResourceSelectInput = ({ items, onSelect, highlightedIndex, width, onLayout, isActive = true, maxVisibleItems = items.length, }) => {
7
+ const theme = useTUITheme();
6
8
  const listRef = useRef(null);
7
9
  const [selected, setSelected] = useState(() => Math.max(0, Math.min(highlightedIndex, items.length - 1)));
8
- const itemKeys = items.map((item) => item.value).join("\0");
10
+ const visibleCount = Math.max(1, maxVisibleItems);
11
+ const firstVisible = Math.min(Math.max(0, selected - visibleCount + 1), Math.max(0, items.length - visibleCount));
9
12
  useLayoutEffect(() => {
10
13
  if (listRef.current)
11
- onLayout?.(measureElement(listRef.current).y);
14
+ onLayout?.(measureElement(listRef.current).y, firstVisible);
12
15
  });
13
- // biome-ignore lint/correctness/useExhaustiveDependencies: reset only when the item list changes
14
- useEffect(() => setSelected(0), [itemKeys]);
16
+ useEffect(() => {
17
+ setSelected(Math.max(0, Math.min(highlightedIndex, items.length - 1)));
18
+ }, [highlightedIndex, items]);
15
19
  useInput((input, key) => {
16
20
  if (items.length === 0)
17
21
  return;
18
- if (input === "k" || key.upArrow)
22
+ const plainKey = !key.ctrl && !key.meta;
23
+ if ((plainKey && input === "k") || key.upArrow)
19
24
  setSelected((i) => (i === 0 ? items.length - 1 : i - 1));
20
- if (input === "j" || key.downArrow)
25
+ if ((plainKey && input === "j") || key.downArrow)
21
26
  setSelected((i) => (i === items.length - 1 ? 0 : i + 1));
22
27
  if (/^[1-9]$/.test(input)) {
23
28
  const item = items[Number(input) - 1];
@@ -26,10 +31,10 @@ export const ResourceSelectInput = ({ items, onSelect, highlightedIndex, width,
26
31
  }
27
32
  if (key.return)
28
33
  onSelect(items[selected]);
29
- });
30
- return (_jsx(Box, { ref: listRef, flexDirection: "column", width: width, children: items.map((item, index) => {
31
- const isSelected = index === selected;
32
- return (_jsx(Box, { width: width, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: isSelected ? "cyan" : undefined, children: isSelected ? "\u2593\u2592\u2591 " : " " }), _jsx(Text, { color: isSelected ? "cyan" : "white", bold: isSelected, backgroundColor: isSelected ? "black" : undefined, children: item.label })] }) }, item.value));
34
+ }, { isActive });
35
+ return (_jsx(Box, { ref: listRef, flexDirection: "column", width: width, children: items.slice(firstVisible, firstVisible + visibleCount).map((item, index) => {
36
+ const isSelected = firstVisible + index === selected;
37
+ return (_jsx(Box, { width: width, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: isSelected ? theme.selectedForeground : theme.muted, backgroundColor: isSelected ? theme.selectedBackground : undefined, children: isSelected ? `${theme.selectionMarker} ` : " " }), _jsx(Text, { color: isSelected ? theme.selectedForeground : theme.foreground, bold: isSelected || theme.highContrast, backgroundColor: isSelected ? theme.selectedBackground : undefined, children: item.label })] }) }, item.value));
33
38
  }) }));
34
39
  };
35
40
  //# sourceMappingURL=ResourceSelectInput.js.map
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { formatShortDate, getStatusColor, getStatusIcon, truncate } from "../utils/formatting.js";
4
+ import { getTUIStatusColor, getTUIStatusIcon, useTUITheme } from "./ui-theme.js";
4
5
  export const ResourceTable = ({ resources, maxWidth = 100 }) => {
6
+ const theme = useTUITheme();
5
7
  if (resources.length === 0) {
6
- return (_jsx(Box, { paddingY: 1, children: _jsx(Text, { color: "gray", children: "No resources found" }) }));
8
+ return (_jsx(Box, { paddingY: 1, children: _jsx(Text, { color: theme.muted, children: "No resources found" }) }));
7
9
  }
8
10
  const narrowMode = maxWidth < 80;
9
11
  const tableContentWidth = Math.max(1, Math.floor(maxWidth) - 4);
@@ -17,11 +19,16 @@ export const ResourceTable = ({ resources, maxWidth = 100 }) => {
17
19
  const createdWidth = narrowMode
18
20
  ? 0
19
21
  : tableContentWidth - nameWidth - stackWidth - typeWidth - statusWidth;
20
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Box, { width: nameWidth, children: _jsx(Text, { bold: true, children: "Name" }) }), _jsx(Box, { width: stackWidth, children: _jsx(Text, { bold: true, children: "Stack" }) }), _jsx(Box, { width: typeWidth, children: _jsx(Text, { bold: true, children: "Type" }) }), _jsx(Box, { width: statusWidth, children: _jsx(Text, { bold: true, children: "Status" }) }), !narrowMode && (_jsx(Box, { width: createdWidth, children: _jsx(Text, { bold: true, children: "Created" }) }))] }), resources.map((resource) => {
21
- const statusColor = getStatusColor(resource.status);
22
- const statusLabel = `${getStatusIcon(resource.status)} ${resource.status}`;
22
+ const borderProps = theme.ascii
23
+ ? {}
24
+ : { borderStyle: "single", borderColor: theme.border };
25
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", ...borderProps, paddingX: 1, children: [_jsx(Box, { width: nameWidth, children: _jsx(Text, { bold: true, color: theme.foreground, children: "Name" }) }), _jsx(Box, { width: stackWidth, children: _jsx(Text, { bold: true, color: theme.foreground, children: "Stack" }) }), _jsx(Box, { width: typeWidth, children: _jsx(Text, { bold: true, color: theme.foreground, children: "Type" }) }), _jsx(Box, { width: statusWidth, children: _jsx(Text, { bold: true, color: theme.foreground, children: "Status" }) }), !narrowMode && (_jsx(Box, { width: createdWidth, children: _jsx(Text, { bold: true, color: theme.foreground, children: "Created" }) }))] }), resources.map((resource) => {
26
+ const sourceStatusColor = getStatusColor(resource.status);
27
+ const statusColor = getTUIStatusColor(theme, sourceStatusColor);
28
+ const statusIcon = getTUIStatusIcon(theme, getStatusIcon(resource.status), sourceStatusColor);
29
+ const statusLabel = `${statusIcon} ${resource.status}`;
23
30
  const stackName = resource.stack && resource.stack !== "unknown" ? resource.stack : "-";
24
- return (_jsxs(Box, { flexDirection: "row", paddingX: 1, children: [_jsx(Box, { width: nameWidth, children: _jsx(Text, { children: truncate(resource.name, Math.max(3, nameWidth - 1)) }) }), _jsx(Box, { width: stackWidth, children: _jsx(Text, { color: "gray", children: truncate(stackName, Math.max(3, stackWidth - 1)) }) }), _jsx(Box, { width: typeWidth, children: _jsx(Text, { color: "cyan", children: truncate(resource.type, Math.max(3, typeWidth - 1)) }) }), _jsx(Box, { width: statusWidth, children: _jsx(Text, { color: statusColor, children: truncate(statusLabel, Math.max(3, statusWidth - 1)) }) }), !narrowMode && (_jsx(Box, { width: createdWidth, children: _jsx(Text, { color: "gray", children: truncate(formatShortDate(resource.createdAt), Math.max(3, createdWidth - 1)) }) }))] }, resource.name));
31
+ return (_jsxs(Box, { flexDirection: "row", paddingX: 1, children: [_jsx(Box, { width: nameWidth, children: _jsx(Text, { color: theme.foreground, children: truncate(resource.name, Math.max(3, nameWidth - 1)) }) }), _jsx(Box, { width: stackWidth, children: _jsx(Text, { color: theme.muted, children: truncate(stackName, Math.max(3, stackWidth - 1)) }) }), _jsx(Box, { width: typeWidth, children: _jsx(Text, { color: theme.accent, bold: theme.highContrast, children: truncate(resource.type, Math.max(3, typeWidth - 1)) }) }), _jsx(Box, { width: statusWidth, children: _jsx(Text, { color: statusColor, bold: theme.highContrast, children: truncate(statusLabel, Math.max(3, statusWidth - 1)) }) }), !narrowMode && (_jsx(Box, { width: createdWidth, children: _jsx(Text, { color: theme.muted, children: truncate(formatShortDate(resource.createdAt), Math.max(3, createdWidth - 1)) }) }))] }, resource.name));
25
32
  })] }));
26
33
  };
27
34
  //# sourceMappingURL=ResourceTable.js.map
@@ -0,0 +1,25 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useTUITheme } from "./ui-theme.js";
4
+ export const TUIHeader = ({ projectRoot, resourceCount, terminalWidth, version, }) => {
5
+ const theme = useTUITheme();
6
+ const compact = terminalWidth < 80;
7
+ const title = compact
8
+ ? `TDK v${version}`
9
+ : `${theme.bannerStart} TDK NEON EDITION v${version} ${theme.bannerEnd}`;
10
+ const resourceLabel = `${resourceCount} resource${resourceCount === 1 ? "" : "s"} discovered`;
11
+ const paddingX = terminalWidth >= 28 ? 1 : 0;
12
+ const contentWidth = Math.max(1, terminalWidth - paddingX * 2);
13
+ if (compact) {
14
+ const splitHeading = title.length + " | ".length + resourceLabel.length > contentWidth;
15
+ return (_jsxs(Box, { flexDirection: "column", paddingX: paddingX, width: terminalWidth, children: [splitHeading ? (_jsxs(_Fragment, { children: [_jsx(Box, { width: contentWidth, children: _jsx(Text, { color: theme.accent, bold: true, wrap: "truncate-end", children: title }) }), _jsx(Box, { width: contentWidth, children: _jsx(Text, { color: theme.success, wrap: "truncate-end", children: resourceLabel }) })] })) : (_jsx(Box, { width: contentWidth, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.accent, bold: true, children: title }), _jsx(Text, { color: theme.muted, children: " | " }), _jsx(Text, { color: theme.success, children: resourceLabel })] }) })), _jsx(Box, { width: contentWidth, children: _jsx(Text, { color: theme.foreground, wrap: "truncate-end", children: projectRoot }) })] }));
16
+ }
17
+ const separatorsWidth = " | ".length * 2;
18
+ const headingWidth = title.length + resourceLabel.length + separatorsWidth;
19
+ if (headingWidth >= contentWidth) {
20
+ return (_jsxs(Box, { flexDirection: "column", paddingX: paddingX, width: terminalWidth, children: [_jsx(Box, { width: contentWidth, children: _jsx(Text, { color: theme.accent, bold: true, wrap: "truncate-end", children: title }) }), _jsx(Box, { width: contentWidth, children: _jsx(Text, { color: theme.success, wrap: "truncate-end", children: resourceLabel }) }), _jsx(Box, { width: contentWidth, children: _jsx(Text, { color: theme.foreground, wrap: "truncate-end", children: projectRoot }) })] }));
21
+ }
22
+ const rootWidth = contentWidth - headingWidth;
23
+ return (_jsxs(Box, { flexDirection: "row", paddingX: paddingX, width: terminalWidth, children: [_jsx(Text, { color: theme.accent, bold: true, children: title }), _jsx(Text, { color: theme.muted, children: " | " }), _jsx(Box, { flexShrink: 0, width: rootWidth, children: _jsx(Text, { color: theme.foreground, wrap: "truncate-end", children: projectRoot }) }), _jsx(Text, { color: theme.muted, children: " | " }), _jsx(Text, { color: theme.success, children: resourceLabel })] }));
24
+ };
25
+ //# sourceMappingURL=TUIHeader.js.map
@@ -1,25 +1,30 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { getTabBarDensity, getTerminalRuleWidth } from "../utils/terminal-layout.js";
4
- const TABS = [
4
+ import { useTUITheme } from "./ui-theme.js";
5
+ export const TABS = [
5
6
  { id: "overview", label: "OVERVIEW", shortcut: "1" },
6
7
  { id: "resources", label: "RESOURCES", shortcut: "2" },
7
- { id: "events", label: "EVENTS", shortcut: "3" },
8
- { id: "files", label: "FILES", shortcut: "4" },
9
- { id: "config", label: "CONFIG", shortcut: "5" },
8
+ { id: "files", label: "FILES", shortcut: "3" },
9
+ { id: "config", label: "CONFIG", shortcut: "4" },
10
10
  ];
11
11
  export const TabBar = ({ activeTab, compact = false, terminalWidth }) => {
12
+ const theme = useTUITheme();
12
13
  const responsive = terminalWidth !== undefined;
13
14
  const density = terminalWidth === undefined ? "wide" : getTabBarDensity(terminalWidth);
14
15
  const compactLayout = responsive && density === "compact";
15
16
  const wide = !responsive || density === "wide";
16
17
  const ruleWidth = responsive ? getTerminalRuleWidth(terminalWidth) : compact ? 60 : 80;
17
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "gray", children: "\u2500".repeat(ruleWidth) }) }), _jsx(Box, { flexDirection: "row", justifyContent: "space-between", paddingX: 1, children: TABS.map((tab) => {
18
+ const divider = (theme.ascii ? "-" : "\u2500").repeat(ruleWidth);
19
+ const selectedBorderProps = theme.ascii
20
+ ? {}
21
+ : { borderStyle: "single", borderColor: theme.accent };
22
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: divider }) }), _jsx(Box, { flexDirection: "row", justifyContent: "space-between", paddingX: 1, children: TABS.map((tab) => {
18
23
  const isActive = activeTab === tab.id;
19
24
  const label = compactLayout || (!responsive && compact && !isActive)
20
25
  ? tab.label.slice(0, 4)
21
26
  : tab.label;
22
- return (_jsx(Box, { children: compactLayout ? (_jsxs(Text, { color: isActive ? "cyan" : "gray", bold: isActive, children: ["[", tab.shortcut, "] ", label] })) : isActive ? (_jsx(Box, { borderStyle: "single", borderColor: "cyan", paddingX: wide ? 1 : 0, backgroundColor: "black", children: _jsxs(Text, { children: [wide && _jsx(Text, { color: "cyan", children: "\u2593\u2592\u2591" }), _jsxs(Text, { color: "cyan", bold: true, children: [" ", "[", tab.shortcut, "] ", label, " "] }), wide && _jsx(Text, { color: "cyan", children: "\u2591\u2592\u2593" })] }) })) : (_jsx(Box, { paddingX: wide ? 1 : 0, children: _jsxs(Text, { color: "gray", dimColor: true, children: ["[", tab.shortcut, "] ", label] }) })) }, tab.id));
23
- }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "\u2500".repeat(ruleWidth) }) })] }));
27
+ return (_jsx(Box, { children: compactLayout ? (_jsxs(Text, { color: isActive ? theme.selectedForeground : theme.muted, bold: isActive || theme.highContrast, backgroundColor: isActive ? theme.selectedBackground : undefined, children: [isActive ? `${theme.selectionMarker} ` : "", "[", tab.shortcut, "] ", label] })) : isActive ? (_jsx(Box, { ...selectedBorderProps, paddingX: wide ? 1 : 0, backgroundColor: theme.selectedBackground, children: _jsxs(Text, { children: [wide && _jsx(Text, { color: theme.selectedForeground, children: theme.bannerStart }), _jsxs(Text, { color: theme.selectedForeground, bold: true, children: [" ", "[", tab.shortcut, "] ", label, " "] }), wide && _jsx(Text, { color: theme.selectedForeground, children: theme.bannerEnd })] }) })) : (_jsx(Box, { paddingX: wide ? 1 : 0, children: _jsxs(Text, { color: theme.muted, dimColor: theme.dimMuted, bold: theme.highContrast, children: ["[", tab.shortcut, "] ", label] }) })) }, tab.id));
28
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: divider }) })] }));
24
29
  };
25
30
  //# sourceMappingURL=TabBar.js.map
@@ -5,4 +5,5 @@ export { ResourceSelectInput } from "./ResourceSelectInput.js";
5
5
  export { ResourceTable } from "./ResourceTable.js";
6
6
  export { TabBar } from "./TabBar.js";
7
7
  export { TOOLTIPS } from "./Tooltip.js";
8
+ export { TUIHeader } from "./TUIHeader.js";
8
9
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,114 @@
1
+ import { createContext, useContext } from "react";
2
+ const FILE_TYPES = ["docker", "tilt", "config", "prisma", "generated", "unknown"];
3
+ const DEFAULT_FILE_COLORS = {
4
+ docker: "blue",
5
+ tilt: "cyan",
6
+ config: "yellow",
7
+ prisma: "magenta",
8
+ generated: "gray",
9
+ unknown: "white",
10
+ };
11
+ const HIGH_CONTRAST_FILE_COLORS = {
12
+ docker: "blueBright",
13
+ tilt: "cyanBright",
14
+ config: "yellowBright",
15
+ prisma: "magentaBright",
16
+ generated: "whiteBright",
17
+ unknown: "whiteBright",
18
+ };
19
+ /** Build the TUI palette from the requested theme and terminal environment. */
20
+ export function createTUITheme(highContrast, environment = process.env) {
21
+ const ascii = environment.NO_COLOR !== undefined ||
22
+ environment.TERM?.toLowerCase().startsWith("dumb") === true;
23
+ const palette = highContrast
24
+ ? {
25
+ accent: "cyanBright",
26
+ border: "whiteBright",
27
+ muted: "white",
28
+ foreground: "whiteBright",
29
+ success: "greenBright",
30
+ warning: "yellowBright",
31
+ error: "redBright",
32
+ info: "blueBright",
33
+ secondary: "magentaBright",
34
+ selectedBackground: "cyanBright",
35
+ selectedForeground: "black",
36
+ selectionBackground: "blueBright",
37
+ selectionForeground: "whiteBright",
38
+ dimMuted: false,
39
+ fileColors: HIGH_CONTRAST_FILE_COLORS,
40
+ }
41
+ : {
42
+ accent: "cyan",
43
+ border: "gray",
44
+ muted: "gray",
45
+ foreground: "white",
46
+ success: "green",
47
+ warning: "yellow",
48
+ error: "red",
49
+ info: "blue",
50
+ secondary: "magenta",
51
+ selectedBackground: "black",
52
+ selectedForeground: "cyan",
53
+ selectionBackground: "blue",
54
+ selectionForeground: "cyan",
55
+ dimMuted: true,
56
+ fileColors: DEFAULT_FILE_COLORS,
57
+ };
58
+ const colorless = ascii
59
+ ? {
60
+ accent: undefined,
61
+ border: undefined,
62
+ muted: undefined,
63
+ foreground: undefined,
64
+ success: undefined,
65
+ warning: undefined,
66
+ error: undefined,
67
+ info: undefined,
68
+ secondary: undefined,
69
+ selectedBackground: undefined,
70
+ selectedForeground: undefined,
71
+ selectionBackground: undefined,
72
+ selectionForeground: undefined,
73
+ dimMuted: false,
74
+ fileColors: Object.fromEntries(FILE_TYPES.map((type) => [type, undefined])),
75
+ }
76
+ : {};
77
+ return {
78
+ ...palette,
79
+ ...colorless,
80
+ highContrast: highContrast && !ascii,
81
+ ascii,
82
+ bannerStart: ascii ? ">>>" : "\u2593\u2592\u2591",
83
+ bannerEnd: ascii ? "<<<" : "\u2591\u2592\u2593",
84
+ selectionMarker: ascii ? ">>>" : "\u2593\u2592\u2591",
85
+ };
86
+ }
87
+ const STATUS_COLOR_THEMES = {
88
+ green: "success",
89
+ yellow: "warning",
90
+ red: "error",
91
+ };
92
+ /** Map a formatter status color to the active theme palette. */
93
+ export function getTUIStatusColor(theme, color) {
94
+ const themeKey = STATUS_COLOR_THEMES[color ?? ""];
95
+ return themeKey ? theme[themeKey] : theme.muted;
96
+ }
97
+ /** Return a status marker suited to the active terminal character set. */
98
+ export function getTUIStatusIcon(theme, icon, color) {
99
+ if (!theme.ascii)
100
+ return icon;
101
+ if (color === "green")
102
+ return "[+]";
103
+ if (color === "yellow")
104
+ return "[~]";
105
+ if (color === "red")
106
+ return "[x]";
107
+ return "[?]";
108
+ }
109
+ export const TUIThemeContext = createContext(createTUITheme(false));
110
+ /** Read the theme provided to the current TUI subtree. */
111
+ export function useTUITheme() {
112
+ return useContext(TUIThemeContext);
113
+ }
114
+ //# sourceMappingURL=ui-theme.js.map
@@ -5,6 +5,7 @@ import Handlebars from "handlebars";
5
5
  import { PLATFORM_STANDARDS } from "../config/platform-standards.js";
6
6
  import { writeTextFile } from "../utils/file-helpers.js";
7
7
  import { assertTdkGeneratedPath } from "../utils/generated-paths.js";
8
+ import { DEFAULT_ALWAYS_ENABLED_INFRA } from "../utils/project-config-defaults.js";
8
9
  import { validateServiceManifestFile } from "../utils/service-manifest.js";
9
10
  import { discoverResourcesFromRoot, discoverServiceManifestPaths } from "../utils/services.js";
10
11
  import { isStackFeatureEnabledInStacks } from "../utils/stack-features.js";
@@ -148,7 +149,7 @@ export class TemplateEngine {
148
149
  runtime: PLATFORM_STANDARDS.runtime,
149
150
  project: projectConfig.project,
150
151
  phases: dedupePhaseEnabledStacks(projectConfig.phases),
151
- alwaysEnabledInfra: projectConfig.always_enabled_infra ?? ["database-management", "proxy"],
152
+ alwaysEnabledInfra: projectConfig.always_enabled_infra ?? [...DEFAULT_ALWAYS_ENABLED_INFRA],
152
153
  optionalInfra: projectConfig.optional_infra,
153
154
  serviceDescriptions: RESOURCE_DESCRIPTIONS,
154
155
  infraDescriptions: INFRA_DESCRIPTIONS,
@@ -7,6 +7,7 @@ import { formatCount } from "./formatting.js";
7
7
  import { findProjectRoot } from "./paths.js";
8
8
  import { isApiServiceType } from "./resource-kind.js";
9
9
  import { discoverResourcesFromRoot } from "./services.js";
10
+ import { evaluateSharedPlatformPostgres } from "./shared-platform-postgres.js";
10
11
  /**
11
12
  * Checks for wiring mistakes that only show up minutes into `tdk up`: an image
12
13
  * build that cannot find package.json, a token check that cannot reach its
@@ -16,6 +17,7 @@ import { discoverResourcesFromRoot } from "./services.js";
16
17
  const EXEC_TIMEOUT_MS = 10_000;
17
18
  const SOURCE_EXTENSIONS = /\.(?:[cm]?[jt]sx?|vue)$/;
18
19
  const TEST_FILE = /(?:\.test\.|\.spec\.|\/tests?\/|\/__tests__\/)/;
20
+ const PRISMA_MAJOR = 7;
19
21
  const SKIPPED_DIRS = new Set(["node_modules", ".autogenerated", "dist", "tests", "__tests__"]);
20
22
  function projectName(projectRoot) {
21
23
  try {
@@ -35,6 +37,126 @@ function readParams(configPath) {
35
37
  return {};
36
38
  }
37
39
  }
40
+ function packageJson(resourcePath) {
41
+ try {
42
+ const value = JSON.parse(readFileSync(join(resourcePath, "package.json"), "utf-8"));
43
+ return value && typeof value === "object" ? value : {};
44
+ }
45
+ catch {
46
+ return {};
47
+ }
48
+ }
49
+ function dependencyMajor(value) {
50
+ if (typeof value !== "string")
51
+ return undefined;
52
+ const match = value.match(/(?:^|[^0-9])(\d+)(?:\.|$)/);
53
+ return match ? Number(match[1]) : undefined;
54
+ }
55
+ function prismaSchemaFindings(path) {
56
+ const raw = readText(path);
57
+ if (raw === undefined)
58
+ return ["missing prisma/schema.prisma"];
59
+ const text = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
60
+ const findings = [];
61
+ const datasource = text.match(/datasource\s+\w+\s*\{([^}]*)\}/)?.[1] ?? "";
62
+ if (!/provider\s*=\s*["']postgresql["']/.test(datasource))
63
+ findings.push("schema provider is not postgresql");
64
+ if (/\b(?:url|directUrl)\s*=/.test(text))
65
+ findings.push("schema still declares url/directUrl");
66
+ return findings;
67
+ }
68
+ function prismaConfigFindings(resourcePath) {
69
+ const generated = join(resourcePath, ".autogenerated", "prisma.config.autogenerated.ts");
70
+ const candidates = [join(resourcePath, "prisma.config.ts"), generated];
71
+ const path = candidates.find((candidate) => existsSync(candidate));
72
+ if (!path)
73
+ return ["missing prisma.config.ts or generated Prisma config"];
74
+ const text = readText(path) ?? "";
75
+ const datasource = text.match(/datasource\s*:\s*\{([^}]*)\}/)?.[1] ?? "";
76
+ if (!/url\s*:\s*(?:env\(["']DATABASE_URL["']\)|process\.env\.DATABASE_URL)\s*,?/.test(datasource)) {
77
+ return ['Prisma config datasource.url is not env("DATABASE_URL")'];
78
+ }
79
+ return [];
80
+ }
81
+ /** Enforces the Prisma 7 project contract before `tdk up` can run. */
82
+ export function checkPrismaConsistency(projectRoot = findProjectRoot() ?? process.cwd()) {
83
+ const resources = discoverResourcesFromRoot(projectRoot);
84
+ const prismaResources = resources.filter((resource) => resource.config?.featuresEnabled?.includes("prisma"));
85
+ const findings = [];
86
+ const migrators = resources.filter((resource) => resource.config?.appType === "migrator");
87
+ for (const resource of resources) {
88
+ const legacyFeatures = resource.config?.features;
89
+ if (Array.isArray(legacyFeatures) &&
90
+ legacyFeatures.includes("prisma") &&
91
+ !resource.config?.featuresEnabled?.includes("prisma")) {
92
+ findings.push(`${resource.name}: use featuresEnabled, not features, for Prisma`);
93
+ }
94
+ }
95
+ if (prismaResources.length === 0) {
96
+ return findings.length === 0
97
+ ? {
98
+ name: "Prisma 7 consistency",
99
+ didPass: true,
100
+ isSkipped: true,
101
+ message: "No resources enable Prisma; Prisma consistency checks were skipped",
102
+ }
103
+ : {
104
+ name: "Prisma 7 consistency",
105
+ didPass: false,
106
+ message: `${formatCount(findings.length, "Prisma consistency error")}:\n ${findings.join("\n ")}`,
107
+ fix: "Use featuresEnabled to opt into the validated Prisma 7 shape",
108
+ };
109
+ }
110
+ for (const resource of prismaResources) {
111
+ const dependencies = packageJson(resource.path).dependencies;
112
+ const devDependencies = packageJson(resource.path).devDependencies;
113
+ const packages = { ...(devDependencies ?? {}), ...(dependencies ?? {}) };
114
+ const prismaMajor = dependencyMajor(packages.prisma);
115
+ const clientMajor = dependencyMajor(packages["@prisma/client"]);
116
+ if (prismaMajor === undefined || clientMajor === undefined)
117
+ findings.push(`${resource.name}: prisma and @prisma/client are required`);
118
+ else if (prismaMajor !== clientMajor || prismaMajor !== PRISMA_MAJOR)
119
+ findings.push(`${resource.name}: prisma packages must both be major 7`);
120
+ findings.push(...prismaSchemaFindings(join(resource.path, "prisma", "schema.prisma")).map((finding) => `${resource.name}: ${finding}`));
121
+ findings.push(...prismaConfigFindings(resource.path).map((finding) => `${resource.name}: ${finding}`));
122
+ const appType = resource.config?.appType ?? resource.type;
123
+ if (appType === "migrator") {
124
+ const postgresDependency = resource.config?.dependsOn?.some((name) => name === "postgres" || name === "database-management");
125
+ if (!postgresDependency)
126
+ findings.push(`${resource.name}: migrator must depend on postgres or database-management`);
127
+ }
128
+ else {
129
+ const migratorDependency = resource.config?.dependsOn?.some((name) => migrators.some((candidate) => candidate.name === name));
130
+ if (!migratorDependency)
131
+ findings.push(`${resource.name}: API must depend on its Prisma migrator`);
132
+ const scripts = packageJson(resource.path).scripts;
133
+ if (Object.entries(scripts ?? {}).some(([name, command]) => START_SCRIPTS.has(name) &&
134
+ typeof command === "string" &&
135
+ /prisma\s+migrate/.test(command))) {
136
+ findings.push(`${resource.name}: API start script must not run prisma migrate`);
137
+ }
138
+ const dockerfile = join(resource.path, resource.config?.dockerfile ?? "Dockerfile");
139
+ if (fileMigrations(dockerfile, true)) {
140
+ findings.push(`${resource.name}: API image/startup must not run Prisma migrations`);
141
+ }
142
+ }
143
+ }
144
+ if (prismaResources.length > 0 && !evaluateSharedPlatformPostgres(projectRoot).willStart) {
145
+ findings.push("shared platform Postgres will not start for the Prisma resources");
146
+ }
147
+ return findings.length === 0
148
+ ? {
149
+ name: "Prisma 7 consistency",
150
+ didPass: true,
151
+ message: "Prisma resources use the validated Prisma 7 shape",
152
+ }
153
+ : {
154
+ name: "Prisma 7 consistency",
155
+ didPass: false,
156
+ message: `${formatCount(findings.length, "Prisma consistency error")}:\n ${findings.join("\n ")}`,
157
+ fix: "Use Prisma 7 packages and config, put migrations in a migrator, and make the API depend on that migrator",
158
+ };
159
+ }
38
160
  /** Backends are the only resources that listen on their `port`. */
39
161
  function backendPorts(resources) {
40
162
  const ports = new Map();
@@ -356,6 +478,76 @@ export function checkNatsBroker(projectRoot = findProjectRoot() ?? process.cwd()
356
478
  fix: `Create it with a "nats" service (image nats with -js, container_name ${name}_nats, network ${name}_backend, external) and a "redis" service, which TDK registers alongside it`,
357
479
  };
358
480
  }
481
+ /**
482
+ * Shared platform Postgres will-start reporting.
483
+ *
484
+ * Uses the same evaluateSharedPlatformPostgres predicate as `tdk config verify`,
485
+ * which reads project.json **and** the generated Tiltfile/spec.master when present.
486
+ * Resource set is the project discovery set those commands already inspect —
487
+ * not a `tdk up` filter. A resource that depends on Postgres while you bring up
488
+ * a different one still makes this report will-start; the engine start path is
489
+ * selection-bounded and may not start Postgres for that run. Unknown dependsOn
490
+ * names (typos like `postgress`) stay errors. Does NOT report
491
+ * postgres/database-management as missing services and does NOT claim Prisma
492
+ * will start.
493
+ *
494
+ * Preflight loop: edit service.json → `tdk config regenerate` → `tdk doctor`.
495
+ * Doctor is the gate; one real `tdk up` after doctor is green confirms the run.
496
+ */
497
+ export function checkSharedPlatformPostgres(projectRoot = findProjectRoot() ?? process.cwd()) {
498
+ const evaluation = evaluateSharedPlatformPostgres(projectRoot);
499
+ const projectScopeNote = " (project resource set; tdk up may select a subset and not start Postgres)";
500
+ const focusWarning = evaluation.focusWouldEnableDatabaseManagement
501
+ ? " Warning: generated Tiltfile default focus expands CORE_INFRA to database-management while project.json has it off; default tdk up takes the feature path."
502
+ : "";
503
+ if (evaluation.unknownDependsOnNames.length > 0) {
504
+ const list = evaluation.unknownDependsOnNames
505
+ .map(({ resource, name }) => `"${name}" (from ${resource})`)
506
+ .join(", ");
507
+ const willStartSuffix = evaluation.willStart
508
+ ? ` Postgres will start because ${evaluation.reason === "feature"
509
+ ? "database-management is enabled"
510
+ : `resource(s) ${evaluation.dependsOnUsers.join(", ")} depend on postgres/database-management`}${projectScopeNote}`
511
+ : " Postgres will not start for this project.json/generated Tiltfile state.";
512
+ return {
513
+ name: "Shared platform Postgres",
514
+ didPass: false,
515
+ message: `${formatCount(evaluation.unknownDependsOnNames.length, "unknown dependsOn name")}: ${list}. These are not known services or stacks, and are not the shared platform database names (postgres, database-management).${willStartSuffix}${focusWarning}`,
516
+ fix: 'Use "postgres" or "database-management" in dependsOn for the shared platform database, or fix the name to match an existing service',
517
+ };
518
+ }
519
+ if (!evaluation.willStart) {
520
+ return {
521
+ name: "Shared platform Postgres",
522
+ didPass: true,
523
+ isWarning: Boolean(focusWarning),
524
+ isSkipped: !focusWarning,
525
+ message: focusWarning
526
+ ? focusWarning.trim()
527
+ : "Shared platform Postgres will not start: database-management is off and no resource depends on postgres or database-management.",
528
+ };
529
+ }
530
+ if (evaluation.reason === "feature" && evaluation.dependsOnUsers.length === 0) {
531
+ const source = evaluation.featureOnFromTiltfile || evaluation.featureOnFromSpecMaster
532
+ ? " (generated Tiltfile/spec.master)"
533
+ : "";
534
+ return {
535
+ name: "Shared platform Postgres",
536
+ didPass: true,
537
+ isWarning: Boolean(focusWarning),
538
+ message: `Shared platform Postgres will start because the database-management feature is enabled${source} (project-wide; not a tdk up selection).${focusWarning}`,
539
+ };
540
+ }
541
+ const reason = evaluation.reason === "feature"
542
+ ? `the database-management feature is enabled and resource(s) ${evaluation.dependsOnUsers.join(", ")} depend on postgres/database-management`
543
+ : `resource(s) ${evaluation.dependsOnUsers.join(", ")} depend on postgres/database-management`;
544
+ return {
545
+ name: "Shared platform Postgres",
546
+ didPass: true,
547
+ isWarning: Boolean(focusWarning),
548
+ message: `Shared platform Postgres will start because ${reason}${projectScopeNote}${focusWarning}`,
549
+ };
550
+ }
359
551
  export function parseTiltProcesses(psOutput) {
360
552
  const processes = [];
361
553
  for (const line of psOutput.split("\n")) {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Defaults shared by project generation and CLI predicates.
3
+ *
4
+ * Source of truth for `always_enabled_infra` when `.tdk/project.json` omits
5
+ * the field. The generator bakes this into the Tiltfile
6
+ * (`Tiltfile.hbs` → `ALWAYS_ENABLED_INFRA`); `should_enable` then returns
7
+ * `RESOURCE_DEFAULTS.get(name, True)` for names in that list — so an omitted
8
+ * field means database-management is ON for `tdk up`.
9
+ *
10
+ * `tdk doctor` / `tdk config verify` must use the same default, otherwise
11
+ * they report Postgres-will-start while the engine does not start it (or
12
+ * the reverse). See shared-platform-postgres.ts.
13
+ */
14
+ export const DEFAULT_ALWAYS_ENABLED_INFRA = ["database-management", "proxy"];
15
+ //# sourceMappingURL=project-config-defaults.js.map