@runloop/rl-cli 1.2.0 → 1.4.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 (57) hide show
  1. package/README.md +29 -8
  2. package/dist/commands/blueprint/from-dockerfile.js +182 -0
  3. package/dist/commands/blueprint/list.js +97 -28
  4. package/dist/commands/blueprint/prune.js +7 -19
  5. package/dist/commands/devbox/create.js +3 -0
  6. package/dist/commands/devbox/list.js +44 -65
  7. package/dist/commands/menu.js +2 -1
  8. package/dist/commands/network-policy/create.js +27 -0
  9. package/dist/commands/network-policy/delete.js +21 -0
  10. package/dist/commands/network-policy/get.js +15 -0
  11. package/dist/commands/network-policy/list.js +494 -0
  12. package/dist/commands/object/list.js +516 -24
  13. package/dist/commands/snapshot/list.js +90 -29
  14. package/dist/components/Banner.js +109 -8
  15. package/dist/components/ConfirmationPrompt.js +45 -0
  16. package/dist/components/DevboxActionsMenu.js +42 -6
  17. package/dist/components/DevboxCard.js +1 -1
  18. package/dist/components/DevboxCreatePage.js +174 -168
  19. package/dist/components/DevboxDetailPage.js +218 -272
  20. package/dist/components/LogsViewer.js +8 -1
  21. package/dist/components/MainMenu.js +35 -4
  22. package/dist/components/NavigationTips.js +24 -0
  23. package/dist/components/NetworkPolicyCreatePage.js +263 -0
  24. package/dist/components/OperationsMenu.js +9 -1
  25. package/dist/components/ResourceActionsMenu.js +5 -1
  26. package/dist/components/ResourceDetailPage.js +204 -0
  27. package/dist/components/ResourceListView.js +19 -2
  28. package/dist/components/StatusBadge.js +2 -2
  29. package/dist/components/Table.js +6 -8
  30. package/dist/components/form/FormActionButton.js +7 -0
  31. package/dist/components/form/FormField.js +7 -0
  32. package/dist/components/form/FormListManager.js +112 -0
  33. package/dist/components/form/FormSelect.js +34 -0
  34. package/dist/components/form/FormTextInput.js +8 -0
  35. package/dist/components/form/index.js +8 -0
  36. package/dist/hooks/useViewportHeight.js +38 -20
  37. package/dist/router/Router.js +23 -1
  38. package/dist/screens/BlueprintDetailScreen.js +355 -0
  39. package/dist/screens/DevboxDetailScreen.js +4 -4
  40. package/dist/screens/MenuScreen.js +6 -0
  41. package/dist/screens/NetworkPolicyCreateScreen.js +7 -0
  42. package/dist/screens/NetworkPolicyDetailScreen.js +247 -0
  43. package/dist/screens/NetworkPolicyListScreen.js +7 -0
  44. package/dist/screens/ObjectDetailScreen.js +377 -0
  45. package/dist/screens/ObjectListScreen.js +7 -0
  46. package/dist/screens/SnapshotDetailScreen.js +208 -0
  47. package/dist/services/blueprintService.js +30 -11
  48. package/dist/services/networkPolicyService.js +108 -0
  49. package/dist/services/objectService.js +101 -0
  50. package/dist/services/snapshotService.js +39 -3
  51. package/dist/store/blueprintStore.js +4 -10
  52. package/dist/store/index.js +1 -0
  53. package/dist/store/networkPolicyStore.js +83 -0
  54. package/dist/store/objectStore.js +92 -0
  55. package/dist/store/snapshotStore.js +4 -8
  56. package/dist/utils/commands.js +65 -0
  57. 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,263 @@
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
+ // Main form input handler - active when not in hostnames expanded mode
80
+ useInput((input, key) => {
81
+ // Handle result screen
82
+ if (result) {
83
+ if (input === "q" || key.escape || key.return) {
84
+ if (onCreate) {
85
+ onCreate(result);
86
+ }
87
+ else {
88
+ onBack();
89
+ }
90
+ }
91
+ return;
92
+ }
93
+ // Handle error screen
94
+ if (error) {
95
+ if (input === "r" || key.return) {
96
+ // Retry - clear error and return to form
97
+ setError(null);
98
+ }
99
+ else if (input === "q" || key.escape) {
100
+ // Quit - go back to list
101
+ onBack();
102
+ }
103
+ return;
104
+ }
105
+ // Handle submitting state
106
+ if (submitting) {
107
+ return;
108
+ }
109
+ // Back to list
110
+ if (input === "q" || key.escape) {
111
+ onBack();
112
+ return;
113
+ }
114
+ // Submit form with Ctrl+S
115
+ if (input === "s" && key.ctrl) {
116
+ handleSubmit();
117
+ return;
118
+ }
119
+ // Handle Enter on hostnames field to expand
120
+ if (currentField === "allowed_hostnames" && key.return) {
121
+ setHostnamesExpanded(true);
122
+ return;
123
+ }
124
+ // Handle Enter on any field to submit (including text/select fields)
125
+ if (key.return) {
126
+ handleSubmit();
127
+ return;
128
+ }
129
+ // Handle select field navigation
130
+ if (handleAllowAllNav(input, key))
131
+ return;
132
+ if (handleDevboxNav(input, key))
133
+ return;
134
+ // Navigation between fields (up/down arrows and tab/shift+tab)
135
+ if ((key.upArrow || (key.tab && key.shift)) && currentFieldIndex > 0) {
136
+ setCurrentField(fields[currentFieldIndex - 1].key);
137
+ return;
138
+ }
139
+ if ((key.downArrow || (key.tab && !key.shift)) &&
140
+ currentFieldIndex < fields.length - 1) {
141
+ setCurrentField(fields[currentFieldIndex + 1].key);
142
+ return;
143
+ }
144
+ }, { isActive: !hostnamesExpanded });
145
+ const handleSubmit = async () => {
146
+ // Validate required fields
147
+ if (!formData.name.trim()) {
148
+ setValidationError("Name is required");
149
+ setCurrentField("name");
150
+ return;
151
+ }
152
+ setSubmitting(true);
153
+ setError(null);
154
+ setValidationError(null);
155
+ try {
156
+ const client = getClient();
157
+ const params = {};
158
+ // For create, name is always required
159
+ // For update, only include if changed
160
+ if (!isEditMode || formData.name.trim() !== initialPolicy?.name) {
161
+ params.name = formData.name.trim();
162
+ }
163
+ // Include description if set (or if clearing in edit mode)
164
+ if (formData.description.trim()) {
165
+ params.description = formData.description.trim();
166
+ }
167
+ else if (isEditMode && initialPolicy?.description) {
168
+ // Clear description if it was set before but now empty
169
+ params.description = "";
170
+ }
171
+ params.allow_all = formData.allow_all === "Yes";
172
+ params.allow_devbox_to_devbox = formData.allow_devbox_to_devbox === "Yes";
173
+ // For allowed_hostnames, always send the current list
174
+ // (empty array means no hostnames allowed)
175
+ params.allowed_hostnames = formData.allowed_hostnames;
176
+ let policy;
177
+ if (isEditMode && initialPolicy) {
178
+ policy = await client.networkPolicies.update(initialPolicy.id, params);
179
+ }
180
+ else {
181
+ // For create, name is required
182
+ policy = await client.networkPolicies.create({
183
+ ...params,
184
+ name: formData.name.trim(),
185
+ });
186
+ }
187
+ setResult(policy);
188
+ }
189
+ catch (err) {
190
+ setError(err);
191
+ }
192
+ finally {
193
+ setSubmitting(false);
194
+ }
195
+ };
196
+ const breadcrumbLabel = isEditMode ? initialPolicy?.name || "Edit" : "Create";
197
+ const actionLabel = isEditMode ? "Edit" : "Create";
198
+ // Result screen
199
+ if (result) {
200
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
201
+ { label: "Network Policies" },
202
+ { label: breadcrumbLabel, active: true },
203
+ ] }), _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: [
204
+ {
205
+ key: "Enter/q/esc",
206
+ label: isEditMode ? "Return to details" : "Return to list",
207
+ },
208
+ ] })] }));
209
+ }
210
+ // Error screen
211
+ if (error) {
212
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
213
+ { label: "Network Policies" },
214
+ { label: breadcrumbLabel, active: true },
215
+ ] }), _jsx(ErrorMessage, { message: `Failed to ${isEditMode ? "update" : "create"} network policy`, error: error }), _jsx(NavigationTips, { tips: [
216
+ { key: "Enter/r", label: "Retry" },
217
+ { key: "q/esc", label: "Cancel" },
218
+ ] })] }));
219
+ }
220
+ // Submitting screen
221
+ if (submitting) {
222
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
223
+ { label: "Network Policies" },
224
+ { label: breadcrumbLabel, active: true },
225
+ ] }), _jsx(SpinnerComponent, { message: `${isEditMode ? "Updating" : "Creating"} network policy...` })] }));
226
+ }
227
+ // Form screen
228
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
229
+ { label: "Network Policies" },
230
+ { label: breadcrumbLabel, active: true },
231
+ ] }), 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) => {
232
+ const isActive = currentField === field.key;
233
+ if (field.type === "action") {
234
+ return (_jsx(FormActionButton, { label: field.label, isActive: isActive, hint: `[Enter to ${isEditMode ? "update" : "create"}]` }, field.key));
235
+ }
236
+ if (field.type === "text") {
237
+ const value = formData[field.key];
238
+ const hasError = field.key === "name" && validationError;
239
+ return (_jsx(FormTextInput, { label: field.label, value: value, onChange: (newValue) => {
240
+ setFormData({ ...formData, [field.key]: newValue });
241
+ // Clear validation error when typing in the field with error
242
+ if (field.key === "name" && validationError) {
243
+ setValidationError(null);
244
+ }
245
+ }, onSubmit: handleSubmit, isActive: isActive, placeholder: field.key === "name"
246
+ ? "my-network-policy"
247
+ : field.key === "description"
248
+ ? "Policy description"
249
+ : "", error: hasError ? validationError : undefined }, field.key));
250
+ }
251
+ if (field.type === "select") {
252
+ const value = formData[field.key];
253
+ return (_jsx(FormSelect, { label: field.label, value: value, options: BOOLEAN_OPTIONS, onChange: (newValue) => setFormData({ ...formData, [field.key]: newValue }), isActive: isActive }, field.key));
254
+ }
255
+ if (field.type === "list") {
256
+ 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));
257
+ }
258
+ return null;
259
+ }) }), !hostnamesExpanded && (_jsx(NavigationTips, { showArrows: true, tips: [
260
+ { key: "Enter", label: `${actionLabel}/Expand` },
261
+ { key: "q", label: "Cancel" },
262
+ ] }))] }));
263
+ };
@@ -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
+ }