@runloop/rl-cli 1.8.0 → 1.9.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 (62) hide show
  1. package/README.md +19 -5
  2. package/dist/cli.js +0 -0
  3. package/dist/commands/blueprint/delete.js +21 -0
  4. package/dist/commands/blueprint/list.js +226 -174
  5. package/dist/commands/blueprint/prune.js +13 -28
  6. package/dist/commands/devbox/create.js +41 -0
  7. package/dist/commands/devbox/list.js +125 -109
  8. package/dist/commands/devbox/tunnel.js +4 -19
  9. package/dist/commands/gateway-config/create.js +44 -0
  10. package/dist/commands/gateway-config/delete.js +21 -0
  11. package/dist/commands/gateway-config/get.js +15 -0
  12. package/dist/commands/gateway-config/list.js +493 -0
  13. package/dist/commands/gateway-config/update.js +60 -0
  14. package/dist/commands/snapshot/list.js +11 -2
  15. package/dist/commands/snapshot/prune.js +265 -0
  16. package/dist/components/BenchmarkMenu.js +23 -3
  17. package/dist/components/DetailedInfoView.js +20 -0
  18. package/dist/components/DevboxActionsMenu.js +9 -61
  19. package/dist/components/DevboxCreatePage.js +531 -14
  20. package/dist/components/DevboxDetailPage.js +27 -22
  21. package/dist/components/GatewayConfigCreatePage.js +265 -0
  22. package/dist/components/LogsViewer.js +6 -40
  23. package/dist/components/ResourceDetailPage.js +143 -160
  24. package/dist/components/ResourceListView.js +3 -33
  25. package/dist/components/ResourcePicker.js +220 -0
  26. package/dist/components/SecretCreatePage.js +2 -4
  27. package/dist/components/SettingsMenu.js +12 -2
  28. package/dist/components/StateHistory.js +1 -20
  29. package/dist/components/StatusBadge.js +9 -2
  30. package/dist/components/StreamingLogsViewer.js +8 -42
  31. package/dist/components/form/FormTextInput.js +4 -2
  32. package/dist/components/resourceDetailTypes.js +18 -0
  33. package/dist/hooks/useInputHandler.js +103 -0
  34. package/dist/router/Router.js +79 -2
  35. package/dist/screens/BenchmarkDetailScreen.js +163 -0
  36. package/dist/screens/BenchmarkJobCreateScreen.js +524 -0
  37. package/dist/screens/BenchmarkJobDetailScreen.js +614 -0
  38. package/dist/screens/BenchmarkJobListScreen.js +479 -0
  39. package/dist/screens/BenchmarkListScreen.js +266 -0
  40. package/dist/screens/BenchmarkMenuScreen.js +6 -0
  41. package/dist/screens/BenchmarkRunDetailScreen.js +258 -22
  42. package/dist/screens/BenchmarkRunListScreen.js +21 -1
  43. package/dist/screens/BlueprintDetailScreen.js +5 -1
  44. package/dist/screens/DevboxCreateScreen.js +2 -2
  45. package/dist/screens/GatewayConfigDetailScreen.js +236 -0
  46. package/dist/screens/GatewayConfigListScreen.js +7 -0
  47. package/dist/screens/ScenarioRunDetailScreen.js +6 -0
  48. package/dist/screens/SettingsMenuScreen.js +3 -0
  49. package/dist/screens/SnapshotDetailScreen.js +6 -0
  50. package/dist/services/agentService.js +42 -0
  51. package/dist/services/benchmarkJobService.js +122 -0
  52. package/dist/services/benchmarkService.js +47 -0
  53. package/dist/services/gatewayConfigService.js +114 -0
  54. package/dist/services/scenarioService.js +34 -0
  55. package/dist/store/benchmarkJobStore.js +66 -0
  56. package/dist/store/benchmarkStore.js +63 -0
  57. package/dist/store/gatewayConfigStore.js +83 -0
  58. package/dist/utils/browser.js +22 -0
  59. package/dist/utils/clipboard.js +41 -0
  60. package/dist/utils/commands.js +80 -0
  61. package/dist/utils/time.js +121 -0
  62. package/package.json +42 -43
@@ -12,26 +12,7 @@ import { ResourceDetailPage, } from "./ResourceDetailPage.js";
12
12
  import { getDevboxUrl } from "../utils/url.js";
13
13
  import { colors } from "../utils/theme.js";
14
14
  import { getDevbox } from "../services/devboxService.js";
15
- // Format time ago in a succinct way
16
- const formatTimeAgo = (timestamp) => {
17
- const seconds = Math.floor((Date.now() - timestamp) / 1000);
18
- if (seconds < 60)
19
- return `${seconds}s ago`;
20
- const minutes = Math.floor(seconds / 60);
21
- if (minutes < 60)
22
- return `${minutes}m ago`;
23
- const hours = Math.floor(minutes / 60);
24
- if (hours < 24)
25
- return `${hours}h ago`;
26
- const days = Math.floor(hours / 24);
27
- if (days < 30)
28
- return `${days}d ago`;
29
- const months = Math.floor(days / 30);
30
- if (months < 12)
31
- return `${months}mo ago`;
32
- const years = Math.floor(months / 12);
33
- return `${years}y ago`;
34
- };
15
+ import { formatTimeAgo } from "../utils/time.js";
35
16
  export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
36
17
  const [showActions, setShowActions] = React.useState(false);
37
18
  const [selectedOperationKey, setSelectedOperationKey] = React.useState(null);
@@ -210,10 +191,28 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
210
191
  });
211
192
  }
212
193
  // Source
213
- if (devbox.blueprint_id || devbox.snapshot_id) {
194
+ if (devbox.blueprint_id) {
195
+ detailFields.push({
196
+ label: "Source",
197
+ value: _jsx(Text, { color: colors.success, children: devbox.blueprint_id }),
198
+ action: {
199
+ type: "navigate",
200
+ screen: "blueprint-detail",
201
+ params: { blueprintId: devbox.blueprint_id },
202
+ hint: "View Blueprint",
203
+ },
204
+ });
205
+ }
206
+ else if (devbox.snapshot_id) {
214
207
  detailFields.push({
215
208
  label: "Source",
216
- value: (_jsx(Text, { color: colors.success, children: devbox.blueprint_id || devbox.snapshot_id })),
209
+ value: _jsx(Text, { color: colors.success, children: devbox.snapshot_id }),
210
+ action: {
211
+ type: "navigate",
212
+ screen: "snapshot-detail",
213
+ params: { snapshotId: devbox.snapshot_id },
214
+ hint: "View Snapshot",
215
+ },
217
216
  });
218
217
  }
219
218
  // Network Policy
@@ -221,6 +220,12 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
221
220
  detailFields.push({
222
221
  label: "Network Policy",
223
222
  value: _jsx(Text, { color: colors.info, children: lp.network_policy_id }),
223
+ action: {
224
+ type: "navigate",
225
+ screen: "network-policy-detail",
226
+ params: { networkPolicyId: lp.network_policy_id },
227
+ hint: "View Policy",
228
+ },
224
229
  });
225
230
  }
226
231
  // Initiator
@@ -0,0 +1,265 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import figures from "figures";
5
+ import { getClient } from "../utils/client.js";
6
+ import { SpinnerComponent } from "./Spinner.js";
7
+ import { ErrorMessage } from "./ErrorMessage.js";
8
+ import { SuccessMessage } from "./SuccessMessage.js";
9
+ import { Breadcrumb } from "./Breadcrumb.js";
10
+ import { NavigationTips } from "./NavigationTips.js";
11
+ import { FormTextInput, FormSelect, FormActionButton, useFormSelectNavigation, } from "./form/index.js";
12
+ import { colors } from "../utils/theme.js";
13
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
14
+ const authTypes = ["bearer", "header"];
15
+ export const GatewayConfigCreatePage = ({ onBack, onCreate, initialConfig, }) => {
16
+ const isEditing = !!initialConfig?.id;
17
+ const [currentField, setCurrentField] = React.useState("create");
18
+ // Normalize auth type from API to match our options (lowercase)
19
+ const normalizeAuthType = (type) => {
20
+ const normalized = (type || "").toLowerCase();
21
+ if (normalized === "header" || normalized === "bearer") {
22
+ return normalized;
23
+ }
24
+ return "bearer"; // default
25
+ };
26
+ const [formData, setFormData] = React.useState({
27
+ name: initialConfig?.name || "",
28
+ endpoint: initialConfig?.endpoint || "",
29
+ auth_type: normalizeAuthType(initialConfig?.auth_mechanism?.type),
30
+ auth_key: initialConfig?.auth_mechanism?.key || "",
31
+ description: initialConfig?.description || "",
32
+ });
33
+ const [creating, setCreating] = React.useState(false);
34
+ const [result, setResult] = React.useState(null);
35
+ const [error, setError] = React.useState(null);
36
+ const fields = [
37
+ {
38
+ key: "create",
39
+ label: isEditing ? "Update Gateway Config" : "Create Gateway Config",
40
+ type: "action",
41
+ },
42
+ { key: "name", label: "Name", type: "text", placeholder: "my-gateway" },
43
+ {
44
+ key: "endpoint",
45
+ label: "Endpoint URL",
46
+ type: "text",
47
+ placeholder: "https://api.example.com",
48
+ },
49
+ { key: "auth_type", label: "Auth Type", type: "select" },
50
+ {
51
+ key: "auth_key",
52
+ label: "Auth Header Key (for header type)",
53
+ type: "text",
54
+ placeholder: "x-api-key",
55
+ },
56
+ {
57
+ key: "description",
58
+ label: "Description (optional)",
59
+ type: "text",
60
+ placeholder: "Gateway for...",
61
+ },
62
+ ];
63
+ const currentFieldIndex = fields.findIndex((f) => f.key === currentField);
64
+ // Handle Ctrl+C to exit
65
+ useExitOnCtrlC();
66
+ // Select navigation handlers using shared hook
67
+ const handleAuthTypeNav = useFormSelectNavigation(formData.auth_type, authTypes, (value) => {
68
+ setFormData({
69
+ ...formData,
70
+ auth_type: value,
71
+ // Clear auth_key if switching from header to bearer
72
+ auth_key: value !== "header" ? "" : formData.auth_key,
73
+ });
74
+ // If switching away from header and currently on auth_key field, move to next field
75
+ if (value !== "header" && currentField === "auth_key") {
76
+ setCurrentField("description");
77
+ }
78
+ }, currentField === "auth_type");
79
+ // Main form input handler
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 creating state
106
+ if (creating) {
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
+ handleCreate();
117
+ return;
118
+ }
119
+ // Handle Enter on any field to submit
120
+ if (key.return) {
121
+ handleCreate();
122
+ return;
123
+ }
124
+ // Handle select field navigation using shared hooks
125
+ if (handleAuthTypeNav(input, key))
126
+ return;
127
+ // Navigation (up/down arrows and tab/shift+tab)
128
+ // Skip auth_key field if auth_type is not "header"
129
+ const getNextField = (direction) => {
130
+ let nextIndex = direction === "up" ? currentFieldIndex - 1 : currentFieldIndex + 1;
131
+ while (nextIndex >= 0 && nextIndex < fields.length) {
132
+ const nextField = fields[nextIndex].key;
133
+ // Skip auth_key if auth_type is not header
134
+ if (nextField === "auth_key" && formData.auth_type !== "header") {
135
+ nextIndex = direction === "up" ? nextIndex - 1 : nextIndex + 1;
136
+ continue;
137
+ }
138
+ return nextField;
139
+ }
140
+ return null;
141
+ };
142
+ if ((key.upArrow || (key.tab && key.shift)) && currentFieldIndex > 0) {
143
+ const nextField = getNextField("up");
144
+ if (nextField) {
145
+ setCurrentField(nextField);
146
+ }
147
+ return;
148
+ }
149
+ if ((key.downArrow || (key.tab && !key.shift)) &&
150
+ currentFieldIndex < fields.length - 1) {
151
+ const nextField = getNextField("down");
152
+ if (nextField) {
153
+ setCurrentField(nextField);
154
+ }
155
+ return;
156
+ }
157
+ }, { isActive: true });
158
+ const handleCreate = async () => {
159
+ // Validate required fields
160
+ if (!formData.name.trim()) {
161
+ setError(new Error("Name is required"));
162
+ return;
163
+ }
164
+ if (!formData.endpoint.trim()) {
165
+ setError(new Error("Endpoint URL is required"));
166
+ return;
167
+ }
168
+ if (formData.auth_type === "header" && !formData.auth_key.trim()) {
169
+ setError(new Error("Auth header key is required for header auth type"));
170
+ return;
171
+ }
172
+ setCreating(true);
173
+ setError(null);
174
+ try {
175
+ const client = getClient();
176
+ const authMechanism = {
177
+ type: formData.auth_type,
178
+ };
179
+ if (formData.auth_type === "header" && formData.auth_key.trim()) {
180
+ authMechanism.key = formData.auth_key.trim();
181
+ }
182
+ let config;
183
+ if (isEditing && initialConfig?.id) {
184
+ // Update existing config
185
+ config = await client.gatewayConfigs.update(initialConfig.id, {
186
+ name: formData.name.trim(),
187
+ endpoint: formData.endpoint.trim(),
188
+ auth_mechanism: authMechanism,
189
+ description: formData.description.trim() || undefined,
190
+ });
191
+ }
192
+ else {
193
+ // Create new config
194
+ config = await client.gatewayConfigs.create({
195
+ name: formData.name.trim(),
196
+ endpoint: formData.endpoint.trim(),
197
+ auth_mechanism: authMechanism,
198
+ description: formData.description.trim() || undefined,
199
+ });
200
+ }
201
+ setResult(config);
202
+ }
203
+ catch (err) {
204
+ setError(err);
205
+ }
206
+ finally {
207
+ setCreating(false);
208
+ }
209
+ };
210
+ // Result screen
211
+ if (result) {
212
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
213
+ { label: "Gateway Configs" },
214
+ { label: isEditing ? "Update" : "Create", active: true },
215
+ ] }), _jsx(SuccessMessage, { message: `Gateway config ${isEditing ? "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 || "(none)"] }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Endpoint: ", result.endpoint] }) })] }), _jsx(NavigationTips, { tips: [{ key: "Enter/q/esc", label: "Return to list" }] })] }));
216
+ }
217
+ // Error screen
218
+ if (error) {
219
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
220
+ { label: "Gateway Configs" },
221
+ { label: isEditing ? "Update" : "Create", active: true },
222
+ ] }), _jsx(ErrorMessage, { message: `Failed to ${isEditing ? "update" : "create"} gateway config`, error: error }), _jsx(NavigationTips, { tips: [
223
+ { key: "Enter/r", label: "Retry" },
224
+ { key: "q/esc", label: "Cancel" },
225
+ ] })] }));
226
+ }
227
+ // Creating screen
228
+ if (creating) {
229
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
230
+ { label: "Gateway Configs" },
231
+ { label: isEditing ? "Update" : "Create", active: true },
232
+ ] }), _jsx(SpinnerComponent, { message: `${isEditing ? "Updating" : "Creating"} gateway config...` })] }));
233
+ }
234
+ // Form screen
235
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
236
+ { label: "Gateway Configs" },
237
+ { label: isEditing ? "Update" : "Create", active: true },
238
+ ] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: fields.map((field) => {
239
+ const isActive = currentField === field.key;
240
+ const fieldData = formData[field.key];
241
+ if (field.type === "action") {
242
+ return (_jsx(FormActionButton, { label: field.label, isActive: isActive, hint: `[Enter to ${isEditing ? "update" : "create"}]` }, field.key));
243
+ }
244
+ if (field.type === "text") {
245
+ // Skip auth_key field if auth type is bearer
246
+ if (field.key === "auth_key" && formData.auth_type !== "header") {
247
+ return null;
248
+ }
249
+ return (_jsx(FormTextInput, { label: field.label, value: String(fieldData || ""), onChange: (value) => setFormData({ ...formData, [field.key]: value }), onSubmit: handleCreate, isActive: isActive, placeholder: field.placeholder }, field.key));
250
+ }
251
+ if (field.type === "select") {
252
+ const value = fieldData;
253
+ return (_jsx(FormSelect, { label: field.label, value: value || "", options: authTypes, onChange: (newValue) => setFormData({
254
+ ...formData,
255
+ [field.key]: newValue,
256
+ // Clear auth_key if switching from header to bearer
257
+ auth_key: newValue !== "header" ? "" : formData.auth_key,
258
+ }), isActive: isActive }, field.key));
259
+ }
260
+ return null;
261
+ }) }), _jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.info, " Auth Types:"] }) }), _jsxs(Box, { marginLeft: 4, flexDirection: "column", children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: "\u2022 bearer: Uses Bearer token authentication" }), _jsx(Text, { color: colors.textDim, dimColor: true, children: "\u2022 header: Uses custom header (specify header key)" })] }), _jsx(NavigationTips, { showArrows: true, tips: [
262
+ { key: "Enter", label: isEditing ? "Update" : "Create" },
263
+ { key: "q", label: "Cancel" },
264
+ ] })] }));
265
+ };
@@ -9,6 +9,7 @@ import figures from "figures";
9
9
  import { Breadcrumb } from "./Breadcrumb.js";
10
10
  import { NavigationTips } from "./NavigationTips.js";
11
11
  import { colors } from "../utils/theme.js";
12
+ import { copyToClipboard } from "../utils/clipboard.js";
12
13
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
13
14
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
14
15
  import { parseAnyLogEntry } from "../utils/logFormatter.js";
@@ -71,42 +72,10 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
71
72
  return `${parts.timestamp} ${parts.level} [${parts.source}] ${shell}${cmd}${parts.message} ${exitCode}`.trim();
72
73
  })
73
74
  .join("\n");
74
- const copyToClipboard = async (text) => {
75
- const { spawn } = await import("child_process");
76
- const platform = process.platform;
77
- let command;
78
- let args;
79
- if (platform === "darwin") {
80
- command = "pbcopy";
81
- args = [];
82
- }
83
- else if (platform === "win32") {
84
- command = "clip";
85
- args = [];
86
- }
87
- else {
88
- command = "xclip";
89
- args = ["-selection", "clipboard"];
90
- }
91
- const proc = spawn(command, args);
92
- proc.stdin.write(text);
93
- proc.stdin.end();
94
- proc.on("exit", (code) => {
95
- if (code === 0) {
96
- setCopyStatus("Copied to clipboard!");
97
- setTimeout(() => setCopyStatus(null), 2000);
98
- }
99
- else {
100
- setCopyStatus("Failed to copy");
101
- setTimeout(() => setCopyStatus(null), 2000);
102
- }
103
- });
104
- proc.on("error", () => {
105
- setCopyStatus("Copy not supported");
106
- setTimeout(() => setCopyStatus(null), 2000);
107
- });
108
- };
109
- copyToClipboard(logsText);
75
+ copyToClipboard(logsText).then((status) => {
76
+ setCopyStatus(status);
77
+ setTimeout(() => setCopyStatus(null), 2000);
78
+ });
110
79
  }
111
80
  else if (input === "q" || key.escape || key.return) {
112
81
  onBack();
@@ -121,9 +90,7 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
121
90
  // Helper to sanitize log message
122
91
  const sanitizeMessage = (message) => {
123
92
  // Strip ANSI escape sequences (colors, cursor movement, etc.)
124
- const strippedAnsi = message.replace(
125
- // eslint-disable-next-line no-control-regex
126
- /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
93
+ const strippedAnsi = message.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
127
94
  // Replace control characters with spaces
128
95
  return (strippedAnsi
129
96
  .replace(/\r\n/g, " ")
@@ -131,7 +98,6 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
131
98
  .replace(/\r/g, " ")
132
99
  .replace(/\t/g, " ")
133
100
  // Remove any other control characters (ASCII 0-31 except space)
134
- // eslint-disable-next-line no-control-regex
135
101
  .replace(/[\x00-\x1F]/g, ""));
136
102
  };
137
103
  // Helper to calculate how many lines a log entry will take when wrapped