@runloop/rl-cli 1.2.0 → 1.3.0

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 (55) hide show
  1. package/README.md +28 -8
  2. package/dist/commands/blueprint/list.js +97 -28
  3. package/dist/commands/blueprint/prune.js +7 -19
  4. package/dist/commands/devbox/create.js +3 -0
  5. package/dist/commands/devbox/list.js +44 -65
  6. package/dist/commands/menu.js +2 -1
  7. package/dist/commands/network-policy/create.js +27 -0
  8. package/dist/commands/network-policy/delete.js +21 -0
  9. package/dist/commands/network-policy/get.js +15 -0
  10. package/dist/commands/network-policy/list.js +494 -0
  11. package/dist/commands/object/list.js +516 -24
  12. package/dist/commands/snapshot/list.js +90 -29
  13. package/dist/components/Banner.js +109 -8
  14. package/dist/components/ConfirmationPrompt.js +45 -0
  15. package/dist/components/DevboxActionsMenu.js +42 -6
  16. package/dist/components/DevboxCard.js +1 -1
  17. package/dist/components/DevboxCreatePage.js +95 -81
  18. package/dist/components/DevboxDetailPage.js +218 -272
  19. package/dist/components/LogsViewer.js +8 -1
  20. package/dist/components/MainMenu.js +35 -4
  21. package/dist/components/NavigationTips.js +24 -0
  22. package/dist/components/NetworkPolicyCreatePage.js +264 -0
  23. package/dist/components/OperationsMenu.js +9 -1
  24. package/dist/components/ResourceActionsMenu.js +5 -1
  25. package/dist/components/ResourceDetailPage.js +204 -0
  26. package/dist/components/ResourceListView.js +19 -2
  27. package/dist/components/StatusBadge.js +2 -2
  28. package/dist/components/Table.js +6 -8
  29. package/dist/components/form/FormActionButton.js +7 -0
  30. package/dist/components/form/FormField.js +7 -0
  31. package/dist/components/form/FormListManager.js +112 -0
  32. package/dist/components/form/FormSelect.js +34 -0
  33. package/dist/components/form/FormTextInput.js +8 -0
  34. package/dist/components/form/index.js +8 -0
  35. package/dist/hooks/useViewportHeight.js +38 -20
  36. package/dist/router/Router.js +23 -1
  37. package/dist/screens/BlueprintDetailScreen.js +337 -0
  38. package/dist/screens/MenuScreen.js +6 -0
  39. package/dist/screens/NetworkPolicyCreateScreen.js +7 -0
  40. package/dist/screens/NetworkPolicyDetailScreen.js +247 -0
  41. package/dist/screens/NetworkPolicyListScreen.js +7 -0
  42. package/dist/screens/ObjectDetailScreen.js +377 -0
  43. package/dist/screens/ObjectListScreen.js +7 -0
  44. package/dist/screens/SnapshotDetailScreen.js +208 -0
  45. package/dist/services/blueprintService.js +30 -11
  46. package/dist/services/networkPolicyService.js +108 -0
  47. package/dist/services/objectService.js +101 -0
  48. package/dist/services/snapshotService.js +39 -3
  49. package/dist/store/blueprintStore.js +4 -10
  50. package/dist/store/index.js +1 -0
  51. package/dist/store/networkPolicyStore.js +83 -0
  52. package/dist/store/objectStore.js +92 -0
  53. package/dist/store/snapshotStore.js +4 -8
  54. package/dist/utils/commands.js +47 -0
  55. package/package.json +2 -2
@@ -7,6 +7,7 @@ import React from "react";
7
7
  import { Box, Text, useInput } from "ink";
8
8
  import figures from "figures";
9
9
  import { Breadcrumb } from "./Breadcrumb.js";
10
+ import { NavigationTips } from "./NavigationTips.js";
10
11
  import { colors } from "../utils/theme.js";
11
12
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
12
13
  import { parseAnyLogEntry } from "../utils/logFormatter.js";
@@ -165,5 +166,11 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
165
166
  : fullMessage;
166
167
  return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: truncatedMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }, index));
167
168
  }
168
- })) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, logs.length), " of", " ", logs.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: logsWrapMode ? colors.success : colors.textDim, bold: logsWrapMode, children: logsWrapMode ? "Wrap: ON" : "Wrap: OFF" }), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [g] Top \u2022 [G] Bottom \u2022 [w] Toggle Wrap \u2022 [c] Copy \u2022 [Enter], [q], or [esc] Back"] }) })] }));
169
+ })) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, logs.length), " of", " ", logs.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: logsWrapMode ? colors.success : colors.textDim, bold: logsWrapMode, children: logsWrapMode ? "Wrap: ON" : "Wrap: OFF" }), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(NavigationTips, { showArrows: true, tips: [
170
+ { key: "g", label: "Top" },
171
+ { key: "G", label: "Bottom" },
172
+ { key: "w", label: "Toggle Wrap" },
173
+ { key: "c", label: "Copy" },
174
+ { key: "Enter/q/esc", label: "Back" },
175
+ ] })] }));
169
176
  };
@@ -4,6 +4,7 @@ import { Box, Text, useInput, useApp } from "ink";
4
4
  import figures from "figures";
5
5
  import { Banner } from "./Banner.js";
6
6
  import { Breadcrumb } from "./Breadcrumb.js";
7
+ import { NavigationTips } from "./NavigationTips.js";
7
8
  import { VERSION } from "../version.js";
8
9
  import { colors } from "../utils/theme.js";
9
10
  import { execCommand } from "../utils/exec.js";
@@ -32,6 +33,20 @@ const menuItems = [
32
33
  icon: "◈",
33
34
  color: colors.accent3,
34
35
  },
36
+ {
37
+ key: "objects",
38
+ label: "Storage Objects",
39
+ description: "Manage files and data in cloud storage",
40
+ icon: "▤",
41
+ color: colors.secondary,
42
+ },
43
+ {
44
+ key: "network-policies",
45
+ label: "Network Policies",
46
+ description: "Manage egress network access rules",
47
+ icon: "◇",
48
+ color: colors.info,
49
+ },
35
50
  ];
36
51
  export const MainMenu = ({ onSelect }) => {
37
52
  const { exit } = useApp();
@@ -64,6 +79,12 @@ export const MainMenu = ({ onSelect }) => {
64
79
  else if (input === "s" || input === "3") {
65
80
  onSelect("snapshots");
66
81
  }
82
+ else if (input === "o" || input === "4") {
83
+ onSelect("objects");
84
+ }
85
+ else if (input === "n" || input === "5") {
86
+ onSelect("network-policies");
87
+ }
67
88
  else if (input === "u" && updateAvailable) {
68
89
  // Release terminal and exec into update command (never returns)
69
90
  execCommand("sh", [
@@ -75,13 +96,23 @@ export const MainMenu = ({ onSelect }) => {
75
96
  // Use compact layout if terminal height is less than 20 lines (memoized)
76
97
  const useCompactLayout = terminalHeight < 20;
77
98
  if (useCompactLayout) {
78
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Cloud development environments \u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
99
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Cloud development environments \u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
79
100
  const isSelected = index === selectedIndex;
80
101
  return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " }), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "- ", item.description] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
81
- }) }), _jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit", updateAvailable && " • [u] Update"] }) })] }));
102
+ }) }), _jsx(NavigationTips, { showArrows: true, paddingX: 2, tips: [
103
+ { key: "1-5", label: "Quick select" },
104
+ { key: "Enter", label: "Select" },
105
+ { key: "Esc", label: "Quit" },
106
+ { key: "u", label: "Update", condition: !!updateAvailable },
107
+ ] })] }));
82
108
  }
83
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, flexGrow: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), menuItems.map((item, index) => {
109
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), menuItems.map((item, index) => {
84
110
  const isSelected = index === selectedIndex;
85
111
  return (_jsxs(Box, { paddingX: 2, paddingY: 0, borderStyle: "single", borderColor: isSelected ? item.color : colors.border, marginTop: index === 0 ? 1 : 0, flexShrink: 0, children: [isSelected && (_jsxs(_Fragment, { children: [_jsx(Text, { color: item.color, bold: true, children: figures.pointer }), _jsx(Text, { children: " " })] })), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsx(Text, { color: colors.textDim, children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: item.description }), _jsx(Text, { children: " " }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["[", index + 1, "]"] })] }, item.key));
86
- })] }), _jsx(Box, { paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit", updateAvailable && " • [u] Update"] }) }) })] }));
112
+ })] }), _jsx(NavigationTips, { showArrows: true, tips: [
113
+ { key: "1-5", label: "Quick select" },
114
+ { key: "Enter", label: "Select" },
115
+ { key: "Esc", label: "Quit" },
116
+ { key: "u", label: "Update", condition: !!updateAvailable },
117
+ ] })] }));
87
118
  };
@@ -0,0 +1,24 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import figures from "figures";
4
+ import { colors } from "../utils/theme.js";
5
+ /**
6
+ * Renders a responsive navigation tips bar that wraps on small screens
7
+ */
8
+ export const NavigationTips = ({ tips, showArrows = false, arrowLabel = "Navigate", paddingX = 1, marginTop = 1, }) => {
9
+ // Filter tips by condition (undefined condition means always show)
10
+ const visibleTips = tips.filter((tip) => tip.condition === undefined || tip.condition === true);
11
+ // Build the tips array, prepending arrows if requested
12
+ const allTips = [];
13
+ if (showArrows) {
14
+ allTips.push({
15
+ icon: `${figures.arrowUp}${figures.arrowDown}`,
16
+ label: arrowLabel,
17
+ });
18
+ }
19
+ allTips.push(...visibleTips);
20
+ if (allTips.length === 0) {
21
+ return null;
22
+ }
23
+ return (_jsx(Box, { marginTop: marginTop, paddingX: paddingX, flexWrap: "wrap", children: allTips.map((tip, index) => (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [index > 0 && " • ", tip.icon && `${tip.icon} `, tip.key && `[${tip.key}] `, tip.label] }, index))) }));
24
+ };
@@ -0,0 +1,264 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * NetworkPolicyCreatePage - Form for creating or editing a network policy
4
+ */
5
+ import React from "react";
6
+ import { Box, Text, useInput } from "ink";
7
+ import figures from "figures";
8
+ import { getClient } from "../utils/client.js";
9
+ import { SpinnerComponent } from "./Spinner.js";
10
+ import { ErrorMessage } from "./ErrorMessage.js";
11
+ import { SuccessMessage } from "./SuccessMessage.js";
12
+ import { Breadcrumb } from "./Breadcrumb.js";
13
+ import { NavigationTips } from "./NavigationTips.js";
14
+ import { FormTextInput, FormSelect, FormActionButton, FormListManager, useFormSelectNavigation, } from "./form/index.js";
15
+ import { colors } from "../utils/theme.js";
16
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
17
+ const BOOLEAN_OPTIONS = ["Yes", "No"];
18
+ export const NetworkPolicyCreatePage = ({ onBack, onCreate, initialPolicy, }) => {
19
+ const isEditMode = !!initialPolicy;
20
+ const [currentField, setCurrentField] = React.useState("submit");
21
+ const [formData, setFormData] = React.useState(() => {
22
+ if (initialPolicy) {
23
+ return {
24
+ name: initialPolicy.name || "",
25
+ description: initialPolicy.description || "",
26
+ allow_all: initialPolicy.egress.allow_all ? "Yes" : "No",
27
+ allow_devbox_to_devbox: initialPolicy.egress.allow_devbox_to_devbox
28
+ ? "Yes"
29
+ : "No",
30
+ allowed_hostnames: initialPolicy.egress.allowed_hostnames || [],
31
+ };
32
+ }
33
+ return {
34
+ name: "",
35
+ description: "",
36
+ allow_all: "No",
37
+ allow_devbox_to_devbox: "No",
38
+ allowed_hostnames: [],
39
+ };
40
+ });
41
+ const [hostnamesExpanded, setHostnamesExpanded] = React.useState(false);
42
+ const [submitting, setSubmitting] = React.useState(false);
43
+ const [result, setResult] = React.useState(null);
44
+ const [error, setError] = React.useState(null);
45
+ const [validationError, setValidationError] = React.useState(null);
46
+ const allFields = [
47
+ {
48
+ key: "submit",
49
+ label: isEditMode ? "Update Network Policy" : "Create Network Policy",
50
+ type: "action",
51
+ },
52
+ { key: "name", label: "Name (required)", type: "text" },
53
+ { key: "description", label: "Description", type: "text" },
54
+ { key: "allow_all", label: "Allow All Egress", type: "select" },
55
+ {
56
+ key: "allow_devbox_to_devbox",
57
+ label: "Allow Devbox-to-Devbox",
58
+ type: "select",
59
+ },
60
+ { key: "allowed_hostnames", label: "Allowed Hostnames", type: "list" },
61
+ ];
62
+ // Hide allowed_hostnames when allow_all is enabled
63
+ const fields = formData.allow_all === "Yes"
64
+ ? allFields.filter((f) => f.key !== "allowed_hostnames")
65
+ : allFields;
66
+ const currentFieldIndex = fields.findIndex((f) => f.key === currentField);
67
+ // Handle Ctrl+C to exit
68
+ useExitOnCtrlC();
69
+ // Select navigation handlers
70
+ const handleAllowAllNav = useFormSelectNavigation(formData.allow_all, BOOLEAN_OPTIONS, (value) => {
71
+ setFormData({ ...formData, allow_all: value });
72
+ // If enabling allow_all and currently on hostnames field, move to previous field
73
+ if (value === "Yes" && currentField === "allowed_hostnames") {
74
+ setCurrentField("allow_devbox_to_devbox");
75
+ setHostnamesExpanded(false);
76
+ }
77
+ }, currentField === "allow_all");
78
+ const handleDevboxNav = useFormSelectNavigation(formData.allow_devbox_to_devbox, BOOLEAN_OPTIONS, (value) => setFormData({ ...formData, allow_devbox_to_devbox: value }), currentField === "allow_devbox_to_devbox");
79
+ useInput((input, key) => {
80
+ // Handle result screen
81
+ if (result) {
82
+ if (input === "q" || key.escape || key.return) {
83
+ if (onCreate) {
84
+ onCreate(result);
85
+ }
86
+ onBack();
87
+ }
88
+ return;
89
+ }
90
+ // Handle error screen
91
+ if (error) {
92
+ if (input === "r" || key.return) {
93
+ // Retry - clear error and return to form
94
+ setError(null);
95
+ }
96
+ else if (input === "q" || key.escape) {
97
+ // Quit - go back to list
98
+ onBack();
99
+ }
100
+ return;
101
+ }
102
+ // Handle submitting state
103
+ if (submitting) {
104
+ return;
105
+ }
106
+ // Handle hostnames expanded mode - let FormListManager handle input
107
+ if (hostnamesExpanded) {
108
+ return;
109
+ }
110
+ // Back to list
111
+ if (input === "q" || key.escape) {
112
+ onBack();
113
+ return;
114
+ }
115
+ // Submit form with Ctrl+S
116
+ if (input === "s" && key.ctrl) {
117
+ handleSubmit();
118
+ return;
119
+ }
120
+ // Handle Enter on submit field
121
+ if (currentField === "submit" && key.return) {
122
+ handleSubmit();
123
+ return;
124
+ }
125
+ // Handle Enter on hostnames field to expand
126
+ if (currentField === "allowed_hostnames" && key.return) {
127
+ setHostnamesExpanded(true);
128
+ return;
129
+ }
130
+ // Handle select field navigation
131
+ if (handleAllowAllNav(input, key))
132
+ return;
133
+ if (handleDevboxNav(input, key))
134
+ return;
135
+ // Navigation between fields (up/down arrows and tab/shift+tab)
136
+ if ((key.upArrow || (key.tab && key.shift)) && currentFieldIndex > 0) {
137
+ setCurrentField(fields[currentFieldIndex - 1].key);
138
+ return;
139
+ }
140
+ if ((key.downArrow || (key.tab && !key.shift)) &&
141
+ currentFieldIndex < fields.length - 1) {
142
+ setCurrentField(fields[currentFieldIndex + 1].key);
143
+ return;
144
+ }
145
+ });
146
+ const handleSubmit = async () => {
147
+ // Validate required fields
148
+ if (!formData.name.trim()) {
149
+ setValidationError("Name is required");
150
+ setCurrentField("name");
151
+ return;
152
+ }
153
+ setSubmitting(true);
154
+ setError(null);
155
+ setValidationError(null);
156
+ try {
157
+ const client = getClient();
158
+ const params = {};
159
+ // For create, name is always required
160
+ // For update, only include if changed
161
+ if (!isEditMode || formData.name.trim() !== initialPolicy?.name) {
162
+ params.name = formData.name.trim();
163
+ }
164
+ // Include description if set (or if clearing in edit mode)
165
+ if (formData.description.trim()) {
166
+ params.description = formData.description.trim();
167
+ }
168
+ else if (isEditMode && initialPolicy?.description) {
169
+ // Clear description if it was set before but now empty
170
+ params.description = "";
171
+ }
172
+ params.allow_all = formData.allow_all === "Yes";
173
+ params.allow_devbox_to_devbox = formData.allow_devbox_to_devbox === "Yes";
174
+ // For allowed_hostnames, always send the current list
175
+ // (empty array means no hostnames allowed)
176
+ params.allowed_hostnames = formData.allowed_hostnames;
177
+ let policy;
178
+ if (isEditMode && initialPolicy) {
179
+ policy = await client.networkPolicies.update(initialPolicy.id, params);
180
+ }
181
+ else {
182
+ // For create, name is required
183
+ policy = await client.networkPolicies.create({
184
+ ...params,
185
+ name: formData.name.trim(),
186
+ });
187
+ }
188
+ setResult(policy);
189
+ }
190
+ catch (err) {
191
+ setError(err);
192
+ }
193
+ finally {
194
+ setSubmitting(false);
195
+ }
196
+ };
197
+ const breadcrumbLabel = isEditMode ? initialPolicy?.name || "Edit" : "Create";
198
+ const actionLabel = isEditMode ? "Edit" : "Create";
199
+ // Result screen
200
+ if (result) {
201
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
202
+ { label: "Network Policies" },
203
+ { label: breadcrumbLabel, active: true },
204
+ ] }), _jsx(SuccessMessage, { message: `Network policy ${isEditMode ? "updated" : "created"} successfully!` }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["ID:", " "] }), _jsx(Text, { color: colors.idColor, children: result.id })] }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Name: ", result.name] }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Allow All: ", result.egress.allow_all ? "Yes" : "No"] }) })] }), isEditMode && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsxs(Text, { color: colors.warning, children: [figures.info, " Changes are eventually consistent and may take a few moments to propagate."] }) })), _jsx(NavigationTips, { tips: [
205
+ {
206
+ key: "Enter/q/esc",
207
+ label: isEditMode ? "Return to details" : "Return to list",
208
+ },
209
+ ] })] }));
210
+ }
211
+ // Error screen
212
+ if (error) {
213
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
214
+ { label: "Network Policies" },
215
+ { label: breadcrumbLabel, active: true },
216
+ ] }), _jsx(ErrorMessage, { message: `Failed to ${isEditMode ? "update" : "create"} network policy`, error: error }), _jsx(NavigationTips, { tips: [
217
+ { key: "Enter/r", label: "Retry" },
218
+ { key: "q/esc", label: "Cancel" },
219
+ ] })] }));
220
+ }
221
+ // Submitting screen
222
+ if (submitting) {
223
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
224
+ { label: "Network Policies" },
225
+ { label: breadcrumbLabel, active: true },
226
+ ] }), _jsx(SpinnerComponent, { message: `${isEditMode ? "Updating" : "Creating"} network policy...` })] }));
227
+ }
228
+ // Form screen
229
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
230
+ { label: "Network Policies" },
231
+ { label: breadcrumbLabel, active: true },
232
+ ] }), isEditMode && (_jsx(Box, { borderStyle: "round", borderColor: colors.warning, paddingX: 1, paddingY: 0, marginBottom: 1, children: _jsxs(Text, { color: colors.warning, children: [figures.warning, " ", _jsx(Text, { bold: true, children: "Note:" }), " Network policy updates are", " ", _jsx(Text, { bold: true, children: "eventually consistent" }), ". Changes may take a few moments to propagate to all devboxes using this policy."] }) })), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: fields.map((field) => {
233
+ const isActive = currentField === field.key;
234
+ if (field.type === "action") {
235
+ return (_jsx(FormActionButton, { label: field.label, isActive: isActive, hint: `[Enter to ${isEditMode ? "update" : "create"}]` }, field.key));
236
+ }
237
+ if (field.type === "text") {
238
+ const value = formData[field.key];
239
+ const hasError = field.key === "name" && validationError;
240
+ return (_jsx(FormTextInput, { label: field.label, value: value, onChange: (newValue) => {
241
+ setFormData({ ...formData, [field.key]: newValue });
242
+ // Clear validation error when typing in the field with error
243
+ if (field.key === "name" && validationError) {
244
+ setValidationError(null);
245
+ }
246
+ }, isActive: isActive, placeholder: field.key === "name"
247
+ ? "my-network-policy"
248
+ : field.key === "description"
249
+ ? "Policy description"
250
+ : "", error: hasError ? validationError : undefined }, field.key));
251
+ }
252
+ if (field.type === "select") {
253
+ const value = formData[field.key];
254
+ return (_jsx(FormSelect, { label: field.label, value: value, options: BOOLEAN_OPTIONS, onChange: (newValue) => setFormData({ ...formData, [field.key]: newValue }), isActive: isActive }, field.key));
255
+ }
256
+ if (field.type === "list") {
257
+ return (_jsx(FormListManager, { title: field.label, items: formData.allowed_hostnames, onItemsChange: (items) => setFormData({ ...formData, allowed_hostnames: items }), isActive: isActive, isExpanded: hostnamesExpanded, onExpandedChange: setHostnamesExpanded, itemPlaceholder: "github.com or *.npmjs.org", addLabel: "+ Add hostname", collapsedLabel: "hostname(s)" }, field.key));
258
+ }
259
+ return null;
260
+ }) }), !hostnamesExpanded && (_jsx(NavigationTips, { showArrows: true, tips: [
261
+ { key: "Enter", label: `${actionLabel}/Expand` },
262
+ { key: "q", label: "Cancel" },
263
+ ] }))] }));
264
+ };
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Box, Text } from "ink";
3
3
  import figures from "figures";
4
4
  import { colors } from "../utils/theme.js";
5
+ import { NavigationTips } from "./NavigationTips.js";
5
6
  /**
6
7
  * Reusable operations menu component for detail pages
7
8
  * Displays a list of available operations with keyboard navigation
@@ -10,7 +11,14 @@ export const OperationsMenu = ({ operations, selectedIndex, onNavigate: _onNavig
10
11
  return (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Operations"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
11
12
  const isSelected = index === selectedIndex;
12
13
  return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? op.color : colors.textDim, bold: isSelected, children: [op.icon, " ", op.label] })] }, op.key));
13
- }) })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [Enter] Select \u2022", additionalActions.map((action) => ` [${action.key}] ${action.label} •`), " ", "[q] Back"] }) })] }));
14
+ }) })] }), _jsx(NavigationTips, { showArrows: true, paddingX: 0, tips: [
15
+ { key: "Enter", label: "Select" },
16
+ ...additionalActions.map((action) => ({
17
+ key: action.key,
18
+ label: action.label,
19
+ })),
20
+ { key: "q", label: "Back" },
21
+ ] })] }));
14
22
  };
15
23
  /**
16
24
  * Helper to filter operations based on conditions
@@ -4,6 +4,7 @@ import { Box, Text, useInput } from "ink";
4
4
  import figures from "figures";
5
5
  import { colors } from "../utils/theme.js";
6
6
  import { Breadcrumb } from "./Breadcrumb.js";
7
+ import { NavigationTips } from "./NavigationTips.js";
7
8
  import { ActionsPopup } from "./ActionsPopup.js";
8
9
  import { DevboxActionsMenu } from "./DevboxActionsMenu.js";
9
10
  export const ResourceActionsMenu = (props) => {
@@ -104,7 +105,10 @@ export const ResourceActionsMenu = (props) => {
104
105
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: operationError ? colors.error : colors.success, children: operationError ? `${label} failed` : `${label} completed` }), !!operationResult && (_jsx(Text, { color: colors.textDim, dimColor: true, children: operationResult })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.pointerSmall, " Press [Enter] to go back"] })] })] }));
105
106
  }
106
107
  if (executingOperation && selectedOp?.needsInput) {
107
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: colors.textDim, children: [selectedOp.inputPrompt || "Input:", " "] }), _jsxs(Text, { children: [" ", operationInput] }), _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter] to execute \u2022 [q or esc] Cancel" })] })] }));
108
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: colors.textDim, children: [selectedOp.inputPrompt || "Input:", " "] }), _jsxs(Text, { children: [" ", operationInput] }), _jsx(NavigationTips, { marginTop: 0, paddingX: 0, tips: [
109
+ { key: "Enter", label: "Execute" },
110
+ { key: "q/esc", label: "Cancel" },
111
+ ] })] })] }));
108
112
  }
109
113
  // Operations menu
110
114
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: resource, operations: operations.map((op) => ({
@@ -0,0 +1,204 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * ResourceDetailPage - Generic detail page component for resources
4
+ * Can be used for devboxes, blueprints, snapshots, etc.
5
+ */
6
+ import React from "react";
7
+ import { Box, Text, useInput } from "ink";
8
+ import figures from "figures";
9
+ import { Header } from "./Header.js";
10
+ import { StatusBadge } from "./StatusBadge.js";
11
+ import { Breadcrumb } from "./Breadcrumb.js";
12
+ import { NavigationTips } from "./NavigationTips.js";
13
+ import { colors } from "../utils/theme.js";
14
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
15
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
16
+ // Format time ago in a succinct way
17
+ const formatTimeAgo = (timestamp) => {
18
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
19
+ if (seconds < 60)
20
+ return `${seconds}s ago`;
21
+ const minutes = Math.floor(seconds / 60);
22
+ if (minutes < 60)
23
+ return `${minutes}m ago`;
24
+ const hours = Math.floor(minutes / 60);
25
+ if (hours < 24)
26
+ return `${hours}h ago`;
27
+ const days = Math.floor(hours / 24);
28
+ if (days < 30)
29
+ return `${days}d ago`;
30
+ const months = Math.floor(days / 30);
31
+ if (months < 12)
32
+ return `${months}mo ago`;
33
+ const years = Math.floor(months / 12);
34
+ return `${years}y ago`;
35
+ };
36
+ // Truncate long strings to prevent layout issues
37
+ const truncateString = (str, maxLength) => {
38
+ if (str.length <= maxLength)
39
+ return str;
40
+ return str.substring(0, maxLength - 3) + "...";
41
+ };
42
+ export function ResourceDetailPage({ resource: initialResource, resourceType, getDisplayName, getId, getStatus, getUrl, breadcrumbPrefix = [], detailSections, operations, onOperation, onBack, buildDetailLines, additionalContent, pollResource, pollInterval = 3000, }) {
43
+ const isMounted = React.useRef(true);
44
+ // Track mounted state
45
+ React.useEffect(() => {
46
+ isMounted.current = true;
47
+ return () => {
48
+ isMounted.current = false;
49
+ };
50
+ }, []);
51
+ // Local state for resource data (updated by polling)
52
+ const [currentResource, setCurrentResource] = React.useState(initialResource);
53
+ const [showDetailedInfo, setShowDetailedInfo] = React.useState(false);
54
+ const [detailScroll, setDetailScroll] = React.useState(0);
55
+ const [selectedOperation, setSelectedOperation] = React.useState(0);
56
+ // Background polling for resource details
57
+ React.useEffect(() => {
58
+ if (!pollResource || showDetailedInfo)
59
+ return;
60
+ const interval = setInterval(async () => {
61
+ if (isMounted.current) {
62
+ try {
63
+ const updatedResource = await pollResource();
64
+ if (isMounted.current) {
65
+ setCurrentResource(updatedResource);
66
+ }
67
+ }
68
+ catch {
69
+ // Silently ignore polling errors
70
+ }
71
+ }
72
+ }, pollInterval);
73
+ return () => clearInterval(interval);
74
+ }, [pollResource, pollInterval, showDetailedInfo]);
75
+ // Calculate viewport for detailed info view
76
+ const detailViewport = useViewportHeight({ overhead: 18, minHeight: 10 });
77
+ const displayName = getDisplayName(currentResource);
78
+ const resourceId = getId(currentResource);
79
+ const status = getStatus(currentResource);
80
+ // Handle Ctrl+C to exit
81
+ useExitOnCtrlC();
82
+ useInput((input, key) => {
83
+ if (!isMounted.current)
84
+ return;
85
+ // Handle detailed info mode
86
+ if (showDetailedInfo) {
87
+ if (input === "q" || key.escape) {
88
+ setShowDetailedInfo(false);
89
+ setDetailScroll(0);
90
+ }
91
+ else if (input === "j" || input === "s" || key.downArrow) {
92
+ setDetailScroll(detailScroll + 1);
93
+ }
94
+ else if (input === "k" || input === "w" || key.upArrow) {
95
+ setDetailScroll(Math.max(0, detailScroll - 1));
96
+ }
97
+ else if (key.pageDown) {
98
+ setDetailScroll(detailScroll + 10);
99
+ }
100
+ else if (key.pageUp) {
101
+ setDetailScroll(Math.max(0, detailScroll - 10));
102
+ }
103
+ return;
104
+ }
105
+ // Main view input handling
106
+ if (input === "q" || key.escape) {
107
+ onBack();
108
+ }
109
+ else if (input === "i" && buildDetailLines) {
110
+ setShowDetailedInfo(true);
111
+ setDetailScroll(0);
112
+ }
113
+ else if (key.upArrow && selectedOperation > 0) {
114
+ setSelectedOperation(selectedOperation - 1);
115
+ }
116
+ else if (key.downArrow && selectedOperation < operations.length - 1) {
117
+ setSelectedOperation(selectedOperation + 1);
118
+ }
119
+ else if (key.return) {
120
+ const op = operations[selectedOperation];
121
+ if (op) {
122
+ onOperation(op.key, currentResource);
123
+ }
124
+ }
125
+ else if (input) {
126
+ // Check if input matches any operation shortcut
127
+ const matchedOpIndex = operations.findIndex((op) => op.shortcut === input);
128
+ if (matchedOpIndex !== -1) {
129
+ setSelectedOperation(matchedOpIndex);
130
+ onOperation(operations[matchedOpIndex].key, currentResource);
131
+ }
132
+ }
133
+ if (input === "o" && getUrl) {
134
+ const url = getUrl(currentResource);
135
+ const openBrowser = async () => {
136
+ const { exec } = await import("child_process");
137
+ const platform = process.platform;
138
+ let openCommand;
139
+ if (platform === "darwin") {
140
+ openCommand = `open "${url}"`;
141
+ }
142
+ else if (platform === "win32") {
143
+ openCommand = `start "${url}"`;
144
+ }
145
+ else {
146
+ openCommand = `xdg-open "${url}"`;
147
+ }
148
+ exec(openCommand);
149
+ };
150
+ openBrowser();
151
+ }
152
+ });
153
+ // Detailed info mode - full screen
154
+ if (showDetailedInfo && buildDetailLines) {
155
+ const detailLines = buildDetailLines(currentResource);
156
+ const viewportHeight = detailViewport.viewportHeight;
157
+ const maxScroll = Math.max(0, detailLines.length - viewportHeight);
158
+ const actualScroll = Math.min(detailScroll, maxScroll);
159
+ const visibleLines = detailLines.slice(actualScroll, actualScroll + viewportHeight);
160
+ const hasMore = actualScroll + viewportHeight < detailLines.length;
161
+ const hasLess = actualScroll > 0;
162
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
163
+ ...breadcrumbPrefix,
164
+ { label: resourceType },
165
+ { label: displayName },
166
+ { label: "Full Details", active: true },
167
+ ] }), _jsx(Header, { title: `${displayName} - Complete Information` }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsxs(Box, { marginBottom: 1, children: [_jsx(StatusBadge, { status: status }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.idColor, children: resourceId })] }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, borderStyle: "round", borderColor: colors.border, paddingX: 2, paddingY: 1, children: _jsx(Box, { flexDirection: "column", children: visibleLines }) }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Scroll \u2022 Line ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, detailLines.length), " of", " ", detailLines.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [q or esc] Back to Details"] })] })] }));
168
+ }
169
+ // Main detail view
170
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
171
+ ...breadcrumbPrefix,
172
+ { label: resourceType },
173
+ { label: displayName, active: true },
174
+ ] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", flexWrap: "wrap", children: [_jsx(Text, { color: colors.primary, bold: true, children: truncateString(displayName, Math.max(20, detailViewport.terminalWidth - 35)) }), displayName !== resourceId && (_jsxs(Text, { color: colors.idColor, children: [" \u2022 ", resourceId] }))] }), _jsx(Box, { children: _jsx(StatusBadge, { status: status, fullText: true }) })] }), detailSections.map((section, sectionIndex) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: section.color || colors.warning, bold: true, children: [section.icon || figures.squareSmallFilled, " ", section.title] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: section.fields
175
+ .filter((field) => field.value !== undefined && field.value !== null)
176
+ .map((field, fieldIndex) => (_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, children: [field.label, " "] }), typeof field.value === "string" ? (_jsx(Text, { color: field.color, dimColor: !field.color, children: field.value })) : (field.value)] }, fieldIndex))) })] }, sectionIndex))), additionalContent, operations.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Actions"] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: operations.map((op, index) => {
177
+ const isSelected = index === selectedOperation;
178
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? op.color : colors.textDim, bold: isSelected, children: [op.icon, " ", op.label] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", op.shortcut, "]"] })] }, op.key));
179
+ }) })] })), _jsx(NavigationTips, { showArrows: true, tips: [
180
+ { key: "Enter", label: "Execute" },
181
+ { key: "i", label: "Full Details", condition: !!buildDetailLines },
182
+ { key: "o", label: "Browser", condition: !!getUrl },
183
+ { key: "q", label: "Back" },
184
+ ] })] }));
185
+ }
186
+ // Helper to format timestamp as "time (ago)"
187
+ export function formatTimestamp(timestamp) {
188
+ if (!timestamp)
189
+ return undefined;
190
+ const formatted = new Date(timestamp).toLocaleString();
191
+ const ago = formatTimeAgo(timestamp);
192
+ return `${formatted} (${ago})`;
193
+ }
194
+ // Helper to format create time with arrow to end time
195
+ export function formatTimeRange(createTime, endTime) {
196
+ if (!createTime)
197
+ return undefined;
198
+ const start = new Date(createTime).toLocaleString();
199
+ if (endTime) {
200
+ const end = new Date(endTime).toLocaleString();
201
+ return `${start} → ${end}`;
202
+ }
203
+ return `${start} (${formatTimeAgo(createTime)})`;
204
+ }