@checkstack/ui 1.11.0 → 1.13.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.
- package/.storybook/main.ts +43 -0
- package/CHANGELOG.md +326 -0
- package/package.json +23 -18
- package/scripts/generate-stdlib-types.ts +23 -0
- package/src/components/Accordion.tsx +17 -9
- package/src/components/ActionCard.tsx +99 -11
- package/src/components/BrandIcon.tsx +57 -0
- package/src/components/CodeEditor/CodeEditor.tsx +159 -14
- package/src/components/CodeEditor/TypefoxEditor.tsx +537 -168
- package/src/components/CodeEditor/editorTheme.test.ts +41 -0
- package/src/components/CodeEditor/editorTheme.ts +26 -0
- package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
- package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
- package/src/components/CodeEditor/importSpecifiers.ts +267 -0
- package/src/components/CodeEditor/index.ts +26 -0
- package/src/components/CodeEditor/monacoGuard.ts +76 -0
- package/src/components/CodeEditor/monacoTsService.ts +185 -0
- package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
- package/src/components/CodeEditor/popoutTitle.ts +31 -0
- package/src/components/CodeEditor/scriptContext.test.ts +15 -7
- package/src/components/CodeEditor/scriptContext.ts +12 -18
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/types.ts +79 -0
- package/src/components/CodeEditor/validateScripts.ts +172 -0
- package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
- package/src/components/ConfirmationModal.tsx +7 -1
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +119 -47
- package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
- package/src/components/DynamicForm/FormField.tsx +183 -15
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +78 -2
- package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
- package/src/components/DynamicForm/index.ts +20 -0
- package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
- package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
- package/src/components/DynamicForm/types.ts +134 -1
- package/src/components/DynamicForm/utils.test.ts +38 -0
- package/src/components/DynamicForm/utils.ts +54 -0
- package/src/components/DynamicForm/validation.logic.test.ts +255 -0
- package/src/components/DynamicForm/validation.logic.ts +210 -0
- package/src/components/DynamicIcon.tsx +39 -17
- package/src/components/Markdown.tsx +68 -2
- package/src/components/Popover.tsx +6 -1
- package/src/components/ScriptTestPanel.logic.test.ts +139 -0
- package/src/components/ScriptTestPanel.logic.ts +137 -0
- package/src/components/ScriptTestPanel.tsx +394 -0
- package/src/components/Sheet.tsx +21 -6
- package/src/components/Spinner.tsx +56 -0
- package/src/components/StatusBadge.tsx +78 -0
- package/src/components/StrategyConfigCard.tsx +3 -3
- package/src/components/Tabs.tsx +7 -1
- package/src/components/TimeOfDayInput.tsx +116 -0
- package/src/components/UserMenu.logic.test.ts +37 -0
- package/src/components/UserMenu.logic.ts +30 -0
- package/src/components/UserMenu.tsx +40 -12
- package/src/components/comboboxInteraction.ts +39 -0
- package/src/components/iconRegistry.tsx +27 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/index.ts +7 -0
- package/stories/ActionCard.stories.tsx +60 -0
- package/stories/CodeEditor.stories.tsx +47 -2
- package/stories/DurationInput.stories.tsx +59 -0
- package/stories/Introduction.mdx +1 -1
- package/stories/Markdown.stories.tsx +56 -0
- package/stories/ScriptTestPanel.stories.tsx +106 -0
- package/stories/SecretEnvEditor.stories.tsx +80 -0
- package/stories/Spinner.stories.tsx +90 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Plus, Trash2, KeyRound, Search, AlertTriangle } from "lucide-react";
|
|
3
|
+
import { Button } from "../Button";
|
|
4
|
+
import { Input } from "../Input";
|
|
5
|
+
import { Popover, PopoverAnchor, PopoverContent } from "../Popover";
|
|
6
|
+
import { usePerformance } from "../PerformanceProvider";
|
|
7
|
+
import {
|
|
8
|
+
comboboxAnchorProps,
|
|
9
|
+
isAnchorInteraction,
|
|
10
|
+
} from "../comboboxInteraction";
|
|
11
|
+
import {
|
|
12
|
+
objectToRows,
|
|
13
|
+
rowsToObject,
|
|
14
|
+
unknownSecretNames,
|
|
15
|
+
type SecretEnvRow,
|
|
16
|
+
} from "./secretEnv.logic";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Editor for a secret -> env mapping (`{ ENV_NAME: "${{ secrets.NAME }}" }`).
|
|
20
|
+
*
|
|
21
|
+
* Each row is an env-var name (free text) plus the referenced secret name.
|
|
22
|
+
* The secret field is a searchable combobox (modeled on `VariablePicker` /
|
|
23
|
+
* `PackageNameCombobox`): type to filter the `secretNames` supplied by the
|
|
24
|
+
* secrets plugin's `listSecretNames`, pick one, or free-type a name that
|
|
25
|
+
* isn't there yet (it still round-trips). The stored value is always the
|
|
26
|
+
* canonical `${{ secrets.NAME }}` template.
|
|
27
|
+
*
|
|
28
|
+
* When a row references a name that the loaded `secretNames` doesn't contain,
|
|
29
|
+
* the row shows a non-blocking warning (red border + message) — the secret
|
|
30
|
+
* may have been deleted/renamed or will be created later, so save is not
|
|
31
|
+
* prevented. No warning is shown while `secretNames` is still loading
|
|
32
|
+
* (undefined/empty).
|
|
33
|
+
*
|
|
34
|
+
* No infinite animations or blurs, so it degrades fine on low-power devices.
|
|
35
|
+
*/
|
|
36
|
+
export interface SecretEnvEditorProps {
|
|
37
|
+
/** Unique id prefix for inputs. */
|
|
38
|
+
id: string;
|
|
39
|
+
/** Current mapping `{ ENV_NAME: "${{ secrets.NAME }}" }`. */
|
|
40
|
+
value: Record<string, string>;
|
|
41
|
+
/** Callback when the mapping changes. */
|
|
42
|
+
onChange: (next: Record<string, string>) => void;
|
|
43
|
+
/** Available secret names for the value picker (names only, never values). */
|
|
44
|
+
secretNames?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const SecretEnvEditor: React.FC<SecretEnvEditorProps> = ({
|
|
48
|
+
id,
|
|
49
|
+
value,
|
|
50
|
+
onChange,
|
|
51
|
+
secretNames,
|
|
52
|
+
}) => {
|
|
53
|
+
// Internal row state allows incomplete rows while editing; serialization
|
|
54
|
+
// to the parent drops them (mirrors KeyValueEditor).
|
|
55
|
+
const [rows, setRows] = React.useState<SecretEnvRow[]>(() => objectToRows(value));
|
|
56
|
+
const isInternalChangeRef = React.useRef(false);
|
|
57
|
+
|
|
58
|
+
React.useEffect(() => {
|
|
59
|
+
if (isInternalChangeRef.current) {
|
|
60
|
+
isInternalChangeRef.current = false;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (Object.keys(value).length > 0) {
|
|
64
|
+
setRows(objectToRows(value));
|
|
65
|
+
}
|
|
66
|
+
}, [value]);
|
|
67
|
+
|
|
68
|
+
const notify = (next: SecretEnvRow[]) => {
|
|
69
|
+
isInternalChangeRef.current = true;
|
|
70
|
+
setRows(next);
|
|
71
|
+
onChange(rowsToObject(next));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Referenced names not in the loaded list — drives the per-row warning.
|
|
75
|
+
// Empty while loading (undefined/empty list), so we don't warn early.
|
|
76
|
+
const unknown = React.useMemo(
|
|
77
|
+
() => unknownSecretNames({ rows, secretNames }),
|
|
78
|
+
[rows, secretNames],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="space-y-2">
|
|
83
|
+
{rows.length === 0 && (
|
|
84
|
+
<p className="text-xs text-muted-foreground italic">
|
|
85
|
+
No secrets mapped. Add an environment variable backed by a secret.
|
|
86
|
+
</p>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{rows.map((row, index) => {
|
|
90
|
+
const isUnknown =
|
|
91
|
+
row.secretName.trim() !== "" && unknown.has(row.secretName.trim());
|
|
92
|
+
return (
|
|
93
|
+
<div key={index} className="space-y-1">
|
|
94
|
+
<div className="flex items-center gap-2">
|
|
95
|
+
<Input
|
|
96
|
+
id={`${id}-env-${index}`}
|
|
97
|
+
value={row.envName}
|
|
98
|
+
onChange={(e) => {
|
|
99
|
+
const next = [...rows];
|
|
100
|
+
next[index] = { ...next[index], envName: e.target.value };
|
|
101
|
+
notify(next);
|
|
102
|
+
}}
|
|
103
|
+
placeholder="ENV_NAME"
|
|
104
|
+
className="flex-1 font-mono text-sm"
|
|
105
|
+
/>
|
|
106
|
+
<span className="text-muted-foreground">=</span>
|
|
107
|
+
<SecretNameCombobox
|
|
108
|
+
id={`${id}-secret-${index}`}
|
|
109
|
+
value={row.secretName}
|
|
110
|
+
secretNames={secretNames}
|
|
111
|
+
invalid={isUnknown}
|
|
112
|
+
onChange={(secretName) => {
|
|
113
|
+
const next = [...rows];
|
|
114
|
+
next[index] = { ...next[index], secretName };
|
|
115
|
+
notify(next);
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
<Button
|
|
119
|
+
type="button"
|
|
120
|
+
variant="ghost"
|
|
121
|
+
size="icon"
|
|
122
|
+
onClick={() => {
|
|
123
|
+
const next = [...rows];
|
|
124
|
+
next.splice(index, 1);
|
|
125
|
+
notify(next);
|
|
126
|
+
}}
|
|
127
|
+
className="h-8 w-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive/90"
|
|
128
|
+
>
|
|
129
|
+
<Trash2 className="h-4 w-4" />
|
|
130
|
+
</Button>
|
|
131
|
+
</div>
|
|
132
|
+
{isUnknown && (
|
|
133
|
+
<p className="flex items-center gap-1 pl-1 text-xs text-destructive">
|
|
134
|
+
<AlertTriangle className="h-3 w-3 shrink-0" />
|
|
135
|
+
<span>
|
|
136
|
+
No secret named{" "}
|
|
137
|
+
<code className="font-mono">{row.secretName.trim()}</code> — it
|
|
138
|
+
may have been deleted or renamed.
|
|
139
|
+
</span>
|
|
140
|
+
</p>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
})}
|
|
145
|
+
|
|
146
|
+
<Button
|
|
147
|
+
type="button"
|
|
148
|
+
variant="outline"
|
|
149
|
+
size="sm"
|
|
150
|
+
onClick={() => notify([...rows, { envName: "", secretName: "" }])}
|
|
151
|
+
className="h-8 gap-1"
|
|
152
|
+
>
|
|
153
|
+
<Plus className="h-4 w-4" />
|
|
154
|
+
Add secret
|
|
155
|
+
</Button>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
interface SecretNameComboboxProps {
|
|
161
|
+
id: string;
|
|
162
|
+
value: string;
|
|
163
|
+
secretNames?: string[];
|
|
164
|
+
invalid?: boolean;
|
|
165
|
+
onChange: (next: string) => void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Searchable secret-name field. Composes `Popover` + `Input` (no Combobox
|
|
170
|
+
* primitive exists) and mirrors `PackageNameCombobox`: a scrollable,
|
|
171
|
+
* type-to-filter list with keyboard navigation. Picking sets the value;
|
|
172
|
+
* a free-typed name not in the list still round-trips (a secret may be
|
|
173
|
+
* created later). `isLowPower`-aware: no entry transition on the list.
|
|
174
|
+
*/
|
|
175
|
+
const SecretNameCombobox: React.FC<SecretNameComboboxProps> = ({
|
|
176
|
+
id,
|
|
177
|
+
value,
|
|
178
|
+
secretNames,
|
|
179
|
+
invalid,
|
|
180
|
+
onChange,
|
|
181
|
+
}) => {
|
|
182
|
+
const { isLowPower } = usePerformance();
|
|
183
|
+
const [open, setOpen] = React.useState(false);
|
|
184
|
+
const [activeIndex, setActiveIndex] = React.useState(0);
|
|
185
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
186
|
+
|
|
187
|
+
const hasNames = (secretNames?.length ?? 0) > 0;
|
|
188
|
+
|
|
189
|
+
const filtered = React.useMemo(() => {
|
|
190
|
+
const names = secretNames ?? [];
|
|
191
|
+
const q = value.trim().toLowerCase();
|
|
192
|
+
if (q === "") return names;
|
|
193
|
+
return names.filter((n) => n.toLowerCase().includes(q));
|
|
194
|
+
}, [secretNames, value]);
|
|
195
|
+
|
|
196
|
+
// Keep the highlighted row in range as the filter changes.
|
|
197
|
+
React.useEffect(() => {
|
|
198
|
+
setActiveIndex(0);
|
|
199
|
+
}, [value]);
|
|
200
|
+
|
|
201
|
+
const pick = (name: string) => {
|
|
202
|
+
onChange(name);
|
|
203
|
+
setOpen(false);
|
|
204
|
+
inputRef.current?.focus();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
208
|
+
if (e.key === "Escape") {
|
|
209
|
+
setOpen(false);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!open || filtered.length === 0) return;
|
|
213
|
+
switch (e.key) {
|
|
214
|
+
case "ArrowDown": {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "ArrowUp": {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
setActiveIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "Enter": {
|
|
225
|
+
const candidate = filtered[activeIndex];
|
|
226
|
+
if (candidate) {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
pick(candidate);
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div className="relative flex-1">
|
|
237
|
+
<Popover open={open && hasNames} onOpenChange={setOpen}>
|
|
238
|
+
<PopoverAnchor asChild>
|
|
239
|
+
<div className="relative">
|
|
240
|
+
<KeyRound className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
241
|
+
<Input
|
|
242
|
+
ref={inputRef}
|
|
243
|
+
id={id}
|
|
244
|
+
value={value}
|
|
245
|
+
onChange={(e) => {
|
|
246
|
+
onChange(e.target.value);
|
|
247
|
+
if (hasNames) setOpen(true);
|
|
248
|
+
}}
|
|
249
|
+
onFocus={() => {
|
|
250
|
+
if (hasNames) setOpen(true);
|
|
251
|
+
}}
|
|
252
|
+
onKeyDown={handleKeyDown}
|
|
253
|
+
placeholder="secret name"
|
|
254
|
+
autoComplete="off"
|
|
255
|
+
aria-invalid={invalid || undefined}
|
|
256
|
+
className={`pl-8 font-mono text-sm ${
|
|
257
|
+
invalid
|
|
258
|
+
? "border-destructive focus-visible:ring-destructive"
|
|
259
|
+
: ""
|
|
260
|
+
}`}
|
|
261
|
+
{...comboboxAnchorProps}
|
|
262
|
+
/>
|
|
263
|
+
</div>
|
|
264
|
+
</PopoverAnchor>
|
|
265
|
+
<PopoverContent
|
|
266
|
+
align="start"
|
|
267
|
+
className="w-[--radix-popover-trigger-width] p-0"
|
|
268
|
+
// Keep focus in the input so typing continues uninterrupted, and
|
|
269
|
+
// don't yank focus on close (would re-trigger the anchor's onFocus).
|
|
270
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
271
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
272
|
+
// The anchor input lives OUTSIDE the floating content, so the very
|
|
273
|
+
// focus/click that opens the list is otherwise seen by Radix's
|
|
274
|
+
// dismissable layer as an outside interaction and closes it on the
|
|
275
|
+
// same frame. Guard both handlers against anchor-origin events.
|
|
276
|
+
onPointerDownOutside={(e) => {
|
|
277
|
+
if (isAnchorInteraction(e.target)) e.preventDefault();
|
|
278
|
+
}}
|
|
279
|
+
onFocusOutside={(e) => {
|
|
280
|
+
if (isAnchorInteraction(e.target)) e.preventDefault();
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<div className="max-h-60 overflow-y-auto py-1">
|
|
284
|
+
{filtered.length === 0 ? (
|
|
285
|
+
<p className="px-3 py-3 text-center text-xs italic text-muted-foreground">
|
|
286
|
+
No matching secrets.
|
|
287
|
+
</p>
|
|
288
|
+
) : (
|
|
289
|
+
filtered.map((name, index) => (
|
|
290
|
+
<button
|
|
291
|
+
key={name}
|
|
292
|
+
type="button"
|
|
293
|
+
// mousedown so the pick lands before the input's blur shuffle.
|
|
294
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
295
|
+
onClick={() => pick(name)}
|
|
296
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
297
|
+
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left ${
|
|
298
|
+
index === activeIndex
|
|
299
|
+
? "bg-accent text-accent-foreground"
|
|
300
|
+
: ""
|
|
301
|
+
} ${isLowPower ? "" : "transition-colors"} hover:bg-accent hover:text-accent-foreground`}
|
|
302
|
+
>
|
|
303
|
+
<Search className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
304
|
+
<code className="flex-1 truncate font-mono text-xs">
|
|
305
|
+
{name}
|
|
306
|
+
</code>
|
|
307
|
+
</button>
|
|
308
|
+
))
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
</PopoverContent>
|
|
312
|
+
</Popover>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
};
|
|
@@ -4,6 +4,10 @@ export { DynamicForm } from "./DynamicForm";
|
|
|
4
4
|
// Sub-components for advanced usage
|
|
5
5
|
export { MultiTypeEditorField } from "./MultiTypeEditorField";
|
|
6
6
|
export { KeyValueEditor, type KeyValuePair } from "./KeyValueEditor";
|
|
7
|
+
export {
|
|
8
|
+
SecretEnvEditor,
|
|
9
|
+
type SecretEnvEditorProps,
|
|
10
|
+
} from "./SecretEnvEditor";
|
|
7
11
|
|
|
8
12
|
// Types for external consumers
|
|
9
13
|
export type {
|
|
@@ -15,6 +19,7 @@ export type {
|
|
|
15
19
|
EditorType,
|
|
16
20
|
ShellEnvVar,
|
|
17
21
|
EditorStarterTemplates,
|
|
22
|
+
ScriptTestRenderer,
|
|
18
23
|
} from "./types";
|
|
19
24
|
|
|
20
25
|
// Utility functions
|
|
@@ -23,4 +28,19 @@ export {
|
|
|
23
28
|
parseFormData,
|
|
24
29
|
detectEditorType,
|
|
25
30
|
EDITOR_TYPE_LABELS,
|
|
31
|
+
findSecretEnvSibling,
|
|
26
32
|
} from "./utils";
|
|
33
|
+
|
|
34
|
+
// Validation logic (pure, DOM-free) for inline field errors and the
|
|
35
|
+
// keep-existing-secret rule. Consumers map server zod issues to fields and
|
|
36
|
+
// strip blank keep-existing secrets before submit.
|
|
37
|
+
export {
|
|
38
|
+
deriveClientFieldErrors,
|
|
39
|
+
deriveServerFieldErrors,
|
|
40
|
+
parseServerValidationData,
|
|
41
|
+
omitKeepExistingSecrets,
|
|
42
|
+
listSecretFieldKeys,
|
|
43
|
+
serverValidationDataSchema,
|
|
44
|
+
type FieldErrorMap,
|
|
45
|
+
type ServerValidationData,
|
|
46
|
+
} from "./validation.logic";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseSecretName,
|
|
4
|
+
toSecretTemplate,
|
|
5
|
+
objectToRows,
|
|
6
|
+
rowsToObject,
|
|
7
|
+
unknownSecretNames,
|
|
8
|
+
type SecretEnvRow,
|
|
9
|
+
} from "./secretEnv.logic";
|
|
10
|
+
|
|
11
|
+
describe("parseSecretName", () => {
|
|
12
|
+
it("extracts the name from a ${{ secrets.NAME }} template", () => {
|
|
13
|
+
expect(parseSecretName("${{ secrets.jira_token }}")).toBe("jira_token");
|
|
14
|
+
expect(parseSecretName("${{secrets.x}}")).toBe("x");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("accepts a legacy bare secret name and returns it as-is", () => {
|
|
18
|
+
// Bare names are tolerated on the wire (normalized to a template by the
|
|
19
|
+
// schema), so the picker shows the same name for both forms.
|
|
20
|
+
expect(parseSecretName("plain")).toBe("plain");
|
|
21
|
+
expect(parseSecretName("SECRET")).toBe("SECRET");
|
|
22
|
+
expect(parseSecretName("my-secret")).toBe("my-secret");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns empty for junk that is neither a template nor a bare name", () => {
|
|
26
|
+
// Inline interpolation is not a whole-value secret reference.
|
|
27
|
+
expect(parseSecretName("u:${{ secrets.pw }}@host")).toBe("");
|
|
28
|
+
// A leading digit / spaces are not a valid secret name.
|
|
29
|
+
expect(parseSecretName("1bad")).toBe("");
|
|
30
|
+
expect(parseSecretName("not a name")).toBe("");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("toSecretTemplate", () => {
|
|
35
|
+
it("wraps a name in the canonical template", () => {
|
|
36
|
+
expect(toSecretTemplate("api")).toBe("${{ secrets.api }}");
|
|
37
|
+
});
|
|
38
|
+
it("returns empty for an empty name", () => {
|
|
39
|
+
expect(toSecretTemplate("")).toBe("");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("objectToRows / rowsToObject round-trip", () => {
|
|
44
|
+
it("converts a mapping to rows and back", () => {
|
|
45
|
+
const mapping = {
|
|
46
|
+
API_TOKEN: "${{ secrets.jira_token }}",
|
|
47
|
+
DB: "${{ secrets.db_pass }}",
|
|
48
|
+
};
|
|
49
|
+
const rows = objectToRows(mapping);
|
|
50
|
+
expect(rows).toEqual([
|
|
51
|
+
{ envName: "API_TOKEN", secretName: "jira_token" },
|
|
52
|
+
{ envName: "DB", secretName: "db_pass" },
|
|
53
|
+
]);
|
|
54
|
+
expect(rowsToObject(rows)).toEqual(mapping);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("drops incomplete rows (empty env name or secret) on serialize", () => {
|
|
58
|
+
const rows = [
|
|
59
|
+
{ envName: "A", secretName: "alpha" },
|
|
60
|
+
{ envName: "", secretName: "beta" },
|
|
61
|
+
{ envName: "C", secretName: "" },
|
|
62
|
+
];
|
|
63
|
+
expect(rowsToObject(rows)).toEqual({ A: "${{ secrets.alpha }}" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("trims whitespace in env and secret names", () => {
|
|
67
|
+
const rows = [{ envName: " TOKEN ", secretName: " tok " }];
|
|
68
|
+
expect(rowsToObject(rows)).toEqual({ TOKEN: "${{ secrets.tok }}" });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("unknownSecretNames", () => {
|
|
73
|
+
const rows: SecretEnvRow[] = [
|
|
74
|
+
{ envName: "A", secretName: "alpha" },
|
|
75
|
+
{ envName: "B", secretName: "beta" },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
it("returns the referenced names that are not in the available list", () => {
|
|
79
|
+
const result = unknownSecretNames({ rows, secretNames: ["alpha"] });
|
|
80
|
+
expect(result).toEqual(new Set(["beta"]));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns an empty set when the list is undefined (still loading)", () => {
|
|
84
|
+
expect(unknownSecretNames({ rows, secretNames: undefined })).toEqual(
|
|
85
|
+
new Set(),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns an empty set when the list is empty (treated as unknown list)", () => {
|
|
90
|
+
expect(unknownSecretNames({ rows, secretNames: [] })).toEqual(new Set());
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns an empty set when every referenced name is known", () => {
|
|
94
|
+
expect(
|
|
95
|
+
unknownSecretNames({ rows, secretNames: ["alpha", "beta", "gamma"] }),
|
|
96
|
+
).toEqual(new Set());
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("ignores blank / whitespace-only secret names", () => {
|
|
100
|
+
const withBlank: SecretEnvRow[] = [
|
|
101
|
+
{ envName: "A", secretName: "" },
|
|
102
|
+
{ envName: "B", secretName: " " },
|
|
103
|
+
{ envName: "C", secretName: "ghost" },
|
|
104
|
+
];
|
|
105
|
+
expect(
|
|
106
|
+
unknownSecretNames({ rows: withBlank, secretNames: ["alpha"] }),
|
|
107
|
+
).toEqual(new Set(["ghost"]));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("de-duplicates a name referenced by multiple rows", () => {
|
|
111
|
+
const dupes: SecretEnvRow[] = [
|
|
112
|
+
{ envName: "A", secretName: "ghost" },
|
|
113
|
+
{ envName: "B", secretName: "ghost" },
|
|
114
|
+
];
|
|
115
|
+
expect(unknownSecretNames({ rows: dupes, secretNames: ["alpha"] })).toEqual(
|
|
116
|
+
new Set(["ghost"]),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("trims whitespace before comparing against the list", () => {
|
|
121
|
+
const padded: SecretEnvRow[] = [{ envName: "A", secretName: " alpha " }];
|
|
122
|
+
expect(
|
|
123
|
+
unknownSecretNames({ rows: padded, secretNames: ["alpha"] }),
|
|
124
|
+
).toEqual(new Set());
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parse/serialize helpers for {@link SecretEnvEditor}. Kept separate
|
|
3
|
+
* so they can be unit-tested without rendering React.
|
|
4
|
+
*
|
|
5
|
+
* The stored shape is `{ ENV_NAME: "${{ secrets.NAME }}" }`; the editor
|
|
6
|
+
* works with rows of `{ envName, secretName }`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SecretEnvRow {
|
|
10
|
+
envName: string;
|
|
11
|
+
secretName: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TEMPLATE_RE = /^\s*\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}\s*$/;
|
|
15
|
+
// A pure bare secret name (mirrors SECRET_NAME_REGEX in @checkstack/secrets-common).
|
|
16
|
+
const BARE_NAME_RE = /^\s*([a-zA-Z][a-zA-Z0-9_-]*)\s*$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract the secret name for display from a stored mapping value. Accepts
|
|
20
|
+
* either the canonical `${{ secrets.NAME }}` template OR a legacy / YAML
|
|
21
|
+
* shorthand bare secret name (the schema tolerates and normalizes bare names
|
|
22
|
+
* on write, so existing data may still carry one). Returns "" for anything
|
|
23
|
+
* that is neither (e.g. inline interpolation), so the picker stays empty.
|
|
24
|
+
*/
|
|
25
|
+
export function parseSecretName(template: string): string {
|
|
26
|
+
const templateMatch = TEMPLATE_RE.exec(template);
|
|
27
|
+
if (templateMatch) return templateMatch[1];
|
|
28
|
+
const bareMatch = BARE_NAME_RE.exec(template);
|
|
29
|
+
return bareMatch?.[1] ?? "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Render a secret name as its canonical `${{ secrets.NAME }}` template. */
|
|
33
|
+
export function toSecretTemplate(secretName: string): string {
|
|
34
|
+
return secretName ? `\${{ secrets.${secretName} }}` : "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function objectToRows(
|
|
38
|
+
value: Record<string, string>,
|
|
39
|
+
): SecretEnvRow[] {
|
|
40
|
+
return Object.entries(value).map(([envName, template]) => ({
|
|
41
|
+
envName,
|
|
42
|
+
secretName: parseSecretName(template),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function rowsToObject(rows: SecretEnvRow[]): Record<string, string> {
|
|
47
|
+
const out: Record<string, string> = {};
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
// Drop incomplete rows (empty env name or secret) on serialize.
|
|
50
|
+
if (row.envName.trim() === "" || row.secretName.trim() === "") continue;
|
|
51
|
+
out[row.envName.trim()] = toSecretTemplate(row.secretName.trim());
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Given the editor rows and the list of secret names known to exist (from
|
|
58
|
+
* the secrets plugin's `listSecretNames`), return the de-duplicated set of
|
|
59
|
+
* referenced secret names that are NOT in that list.
|
|
60
|
+
*
|
|
61
|
+
* Used to surface a non-blocking "this secret doesn't exist" warning on a
|
|
62
|
+
* row — the name may have been deleted/renamed, or be created later, so it
|
|
63
|
+
* round-trips regardless. Returns an empty set when:
|
|
64
|
+
* - `secretNames` is `undefined` (still loading; we don't warn early), or
|
|
65
|
+
* - `secretNames` is empty (treated as "list unknown", same as loading), or
|
|
66
|
+
* - every referenced name is known.
|
|
67
|
+
*
|
|
68
|
+
* Blank secret names are ignored (a half-typed row isn't an error yet).
|
|
69
|
+
*/
|
|
70
|
+
export function unknownSecretNames({
|
|
71
|
+
rows,
|
|
72
|
+
secretNames,
|
|
73
|
+
}: {
|
|
74
|
+
rows: SecretEnvRow[];
|
|
75
|
+
secretNames: string[] | undefined;
|
|
76
|
+
}): Set<string> {
|
|
77
|
+
// No list to validate against → never warn (loading / unavailable).
|
|
78
|
+
if (!secretNames || secretNames.length === 0) return new Set();
|
|
79
|
+
const known = new Set(secretNames);
|
|
80
|
+
const unknown = new Set<string>();
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
const name = row.secretName.trim();
|
|
83
|
+
if (name === "") continue;
|
|
84
|
+
if (!known.has(name)) unknown.add(name);
|
|
85
|
+
}
|
|
86
|
+
return unknown;
|
|
87
|
+
}
|