@skyhook-io/radar-app 1.13.4 → 1.13.5

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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/RadarApp.tsx +5 -0
  3. package/src/api/client.ts +4 -0
  4. package/src/api/diagnose.ts +61 -15
  5. package/src/components/ConnectionErrorView.test.tsx +30 -0
  6. package/src/components/ConnectionErrorView.tsx +31 -19
  7. package/src/components/diagnose/AgentCase.tsx +131 -0
  8. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  9. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  10. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  11. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  12. package/src/components/diagnose/InvestigationView.tsx +222 -5
  13. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  14. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  15. package/src/components/diagnose/investigationCase.ts +439 -0
  16. package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
  17. package/src/components/diagnose/investigationEvidence.ts +831 -43
  18. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  19. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  20. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  21. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  22. package/src/components/diagnose/investigationMetrics.ts +393 -0
  23. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  24. package/src/components/diagnose/investigationState.test.ts +322 -0
  25. package/src/components/diagnose/investigationState.ts +147 -25
  26. package/src/components/diagnose/parts.test.tsx +482 -0
  27. package/src/components/diagnose/parts.tsx +418 -41
  28. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  29. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  30. package/src/components/resources/PodFilePreview.tsx +394 -0
  31. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  32. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  33. package/src/context/DiagnoseCustomization.tsx +14 -2
  34. package/src/index.ts +1 -0
  35. package/src/utils/shell-safe.test.ts +25 -1
  36. package/src/utils/shell-safe.ts +16 -0
@@ -1,6 +1,6 @@
1
1
  import { createElement, useState, useRef, useEffect, useMemo, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { X, File, Link2, ChevronRight, AlertTriangle, Loader2, Search, Download, FolderOpen } from 'lucide-react'
3
+ import { X, File, Link2, ChevronRight, ArrowLeft, AlertTriangle, Loader2, Search, Download, FolderOpen } from 'lucide-react'
4
4
  import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
5
5
  import { clsx } from 'clsx'
6
6
  import type { FileNode } from '../../types'
@@ -11,6 +11,7 @@ import { isDesktopApp } from '../../utils/desktop-download'
11
11
  import { openFile, openFolder } from '../../utils/desktop-open-folder'
12
12
  import { useToast } from '../ui/Toast'
13
13
  import { Tooltip } from '../ui/Tooltip'
14
+ import { PodFilePreview, PREVIEW_BYTE_CAP } from './PodFilePreview'
14
15
 
15
16
  interface PodFilesystem {
16
17
  root: FileNode
@@ -68,6 +69,56 @@ async function savePodFileToDisk(
68
69
  return body.path
69
70
  }
70
71
 
72
+ type PodFileToast = Pick<ReturnType<typeof useToast>, 'showSuccess' | 'showError'>
73
+
74
+ // One download path for the listing row and the preview header. Desktop saves
75
+ // straight from the backend; the browser gets a blob.
76
+ async function downloadPodFile(
77
+ namespace: string,
78
+ podName: string,
79
+ container: string,
80
+ node: Pick<FileNode, 'path' | 'name'>,
81
+ { showSuccess, showError }: PodFileToast,
82
+ ) {
83
+ try {
84
+ if (await isDesktopApp()) {
85
+ const savedPath = await savePodFileToDisk(namespace, podName, container, node.path)
86
+ showSuccess(
87
+ 'File saved',
88
+ savedPath,
89
+ {
90
+ label: 'Show in Finder',
91
+ icon: createElement(FolderOpen, { className: 'w-3.5 h-3.5' }),
92
+ onClick: () => openFolder(savedPath),
93
+ },
94
+ () => openFile(savedPath),
95
+ )
96
+ return
97
+ }
98
+
99
+ const params = new URLSearchParams()
100
+ params.set('container', container)
101
+ params.set('path', node.path)
102
+
103
+ const response = await fetch(apiUrl(`/pods/${namespace}/${podName}/files/download?${params.toString()}`), {
104
+ credentials: getCredentialsMode(),
105
+ headers: getAuthHeaders(),
106
+ })
107
+ if (!response.ok) {
108
+ const err = await response.json().catch(() => ({ error: 'Download failed' }))
109
+ throw new Error(err.error || `HTTP ${response.status}`)
110
+ }
111
+
112
+ const blob = await response.blob()
113
+ await downloadBlob(blob, node.name)
114
+ } catch (err) {
115
+ const message = err instanceof Error ? err.message : String(err)
116
+ if (message !== 'cancelled') {
117
+ showError(`Could not download ${node.name}`, message)
118
+ }
119
+ }
120
+ }
121
+
71
122
  interface PodFilesystemModalProps {
72
123
  open: boolean
73
124
  onClose: () => void
@@ -94,8 +145,12 @@ export function PodFilesystemModal({
94
145
  const [filesystem, setFilesystem] = useState<PodFilesystem | null>(null)
95
146
  const [isLoading, setIsLoading] = useState(false)
96
147
  const [error, setError] = useState<string | null>(null)
148
+ // The file being viewed in place of the listing; null shows the directory.
149
+ const [previewFile, setPreviewFile] = useState<FileNode | null>(null)
150
+ const toast = useToast()
97
151
 
98
152
  const loadDirectory = useCallback(async (dirPath: string) => {
153
+ setPreviewFile(null)
99
154
  setIsLoading(true)
100
155
  setError(null)
101
156
  try {
@@ -124,19 +179,24 @@ export function PodFilesystemModal({
124
179
  setError(null)
125
180
  setIsLoading(false)
126
181
  setCurrentPath('/')
182
+ setPreviewFile(null)
127
183
  setSelectedContainer(initialContainer || containers[0] || '')
128
184
  }
129
185
  }, [open, initialContainer, containers])
130
186
 
131
- // Handle ESC key
187
+ // ESC steps back one level: out of a file to its listing, then out of the
188
+ // dialog. One listener owns both so the two can never race.
132
189
  useEffect(() => {
133
190
  if (!open) return
134
191
  const handleKeyDown = (e: KeyboardEvent) => {
135
- if (e.key === 'Escape') { e.stopPropagation(); onClose() }
192
+ if (e.key !== 'Escape') return
193
+ e.stopPropagation()
194
+ if (previewFile) setPreviewFile(null)
195
+ else onClose()
136
196
  }
137
197
  document.addEventListener('keydown', handleKeyDown, true)
138
198
  return () => document.removeEventListener('keydown', handleKeyDown, true)
139
- }, [open, onClose])
199
+ }, [open, onClose, previewFile])
140
200
 
141
201
  // Focus trap
142
202
  useEffect(() => {
@@ -149,10 +209,19 @@ export function PodFilesystemModal({
149
209
 
150
210
  const showFilesystem = filesystem && filesystem.root
151
211
 
152
- // Build breadcrumb segments
153
- const pathSegments = currentPath === '/'
212
+ // Breadcrumb: the directory path, plus the file when one is open. Clicking
213
+ // the current directory while viewing a file just returns to its listing.
214
+ const breadcrumbPath = previewFile ? previewFile.path : currentPath
215
+ const pathSegments = breadcrumbPath === '/'
154
216
  ? ['/']
155
- : ['/', ...currentPath.split('/').filter(Boolean)]
217
+ : ['/', ...breadcrumbPath.split('/').filter(Boolean)]
218
+ const goToDirectory = (dirPath: string) => {
219
+ if (dirPath === currentPath) setPreviewFile(null)
220
+ else loadDirectory(dirPath)
221
+ }
222
+ const downloadPreviewFile = () => {
223
+ if (previewFile) downloadPodFile(namespace, podName, selectedContainer, previewFile, toast)
224
+ }
156
225
 
157
226
  return createPortal(
158
227
  <div className="fixed inset-0 z-[100] flex items-center justify-center">
@@ -163,19 +232,47 @@ export function PodFilesystemModal({
163
232
  <div
164
233
  ref={dialogRef}
165
234
  tabIndex={-1}
166
- className="relative dialog w-full max-w-4xl mx-4 max-h-[85vh] flex flex-col outline-none"
235
+ className="relative dialog w-full max-w-4xl mx-4 h-[85vh] flex flex-col outline-none"
167
236
  >
168
237
  {/* Header */}
169
238
  <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
170
- <div className="flex-1 min-w-0">
171
- <h3 className="text-lg font-semibold text-theme-text-primary">Pod Files</h3>
172
- <p className="text-sm text-theme-text-secondary truncate mt-0.5">
173
- {namespace}/{podName}
174
- </p>
175
- </div>
239
+ {previewFile ? (
240
+ <>
241
+ <Tooltip content="Back to files">
242
+ <button
243
+ onClick={() => setPreviewFile(null)}
244
+ className="p-2 mr-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
245
+ >
246
+ <ArrowLeft className="w-5 h-5" />
247
+ </button>
248
+ </Tooltip>
249
+ <div className="flex-1 min-w-0">
250
+ <h3 className="text-lg font-semibold text-theme-text-primary truncate">{previewFile.name}</h3>
251
+ <p className="text-sm text-theme-text-secondary truncate mt-0.5">
252
+ {namespace}/{podName} · {selectedContainer}
253
+ {previewFile.size !== undefined ? ` · ${formatBytes(previewFile.size)}` : null}
254
+ </p>
255
+ </div>
256
+ <Tooltip content="Download file">
257
+ <button
258
+ onClick={downloadPreviewFile}
259
+ className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
260
+ >
261
+ <Download className="w-5 h-5" />
262
+ </button>
263
+ </Tooltip>
264
+ </>
265
+ ) : (
266
+ <div className="flex-1 min-w-0">
267
+ <h3 className="text-lg font-semibold text-theme-text-primary">Pod Files</h3>
268
+ <p className="text-sm text-theme-text-secondary truncate mt-0.5">
269
+ {namespace}/{podName}
270
+ </p>
271
+ </div>
272
+ )}
176
273
 
177
274
  {/* Container selector */}
178
- {containers.length > 1 && (
275
+ {!previewFile && containers.length > 1 && (
179
276
  <select
180
277
  value={selectedContainer}
181
278
  onChange={(e) => setSelectedContainer(e.target.value)}
@@ -206,7 +303,7 @@ export function PodFilesystemModal({
206
303
  <span key={segmentPath} className="flex items-center gap-1">
207
304
  {i > 0 && <ChevronRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />}
208
305
  <button
209
- onClick={() => !isLast && loadDirectory(segmentPath)}
306
+ onClick={() => !isLast && goToDirectory(segmentPath)}
210
307
  className={clsx(
211
308
  'truncate',
212
309
  isLast
@@ -222,7 +319,7 @@ export function PodFilesystemModal({
222
319
  </div>
223
320
 
224
321
  {/* Search */}
225
- {showFilesystem && (
322
+ {showFilesystem && !previewFile && (
226
323
  <div className="relative flex-1 min-w-[200px]">
227
324
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
228
325
  <Input
@@ -236,9 +333,22 @@ export function PodFilesystemModal({
236
333
  </div>
237
334
 
238
335
  {/* Content */}
239
- <div className="flex-1 overflow-y-auto p-4">
336
+ {previewFile ? (
337
+ <div className="flex-1 min-h-0 flex flex-col">
338
+ <PodFilePreview
339
+ key={previewFile.path}
340
+ namespace={namespace}
341
+ podName={podName}
342
+ container={selectedContainer}
343
+ filePath={previewFile.path}
344
+ fileName={previewFile.name}
345
+ onDownload={downloadPreviewFile}
346
+ />
347
+ </div>
348
+ ) : (
349
+ <div className="flex-1 min-h-0 overflow-y-auto p-4">
240
350
  {/* Loading */}
241
- {isLoading && <PaneLoader label="Loading files…" className="h-64" />}
351
+ {isLoading && <PaneLoader label="Loading files…" className="h-full" />}
242
352
 
243
353
  {/* Error */}
244
354
  {error && !isLoading && (
@@ -262,9 +372,11 @@ export function PodFilesystemModal({
262
372
  podName={podName}
263
373
  container={selectedContainer}
264
374
  onNavigate={loadDirectory}
375
+ onOpenFile={setPreviewFile}
265
376
  />
266
377
  )}
267
378
  </div>
379
+ )}
268
380
 
269
381
  {/* Footer with stats */}
270
382
  <div className="p-3 border-t border-theme-border text-xs text-theme-text-tertiary flex items-center gap-4 shrink-0">
@@ -300,9 +412,10 @@ interface PodFileTreeViewProps {
300
412
  podName: string
301
413
  container: string
302
414
  onNavigate: (path: string) => void
415
+ onOpenFile: (node: FileNode) => void
303
416
  }
304
417
 
305
- function PodFileTreeView({ root, searchQuery, namespace, podName, container, onNavigate }: PodFileTreeViewProps) {
418
+ function PodFileTreeView({ root, searchQuery, namespace, podName, container, onNavigate, onOpenFile }: PodFileTreeViewProps) {
306
419
  const filteredRoot = useMemo(() => {
307
420
  if (!searchQuery.trim()) return root
308
421
  return filterTree(root, searchQuery.toLowerCase())
@@ -326,6 +439,7 @@ function PodFileTreeView({ root, searchQuery, namespace, podName, container, onN
326
439
  podName={podName}
327
440
  container={container}
328
441
  onNavigate={onNavigate}
442
+ onOpenFile={onOpenFile}
329
443
  />
330
444
  ))}
331
445
  </div>
@@ -338,72 +452,48 @@ interface PodFileTreeNodeProps {
338
452
  podName: string
339
453
  container: string
340
454
  onNavigate: (path: string) => void
455
+ onOpenFile: (node: FileNode) => void
341
456
  }
342
457
 
343
- function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: PodFileTreeNodeProps) {
458
+ function PodFileTreeNode({ node, namespace, podName, container, onNavigate, onOpenFile }: PodFileTreeNodeProps) {
344
459
  const [downloading, setDownloading] = useState(false)
345
- const { showSuccess, showError } = useToast()
460
+ const toast = useToast()
346
461
  const isDir = node.type === 'dir'
347
462
  const isSymlink = node.type === 'symlink'
348
- const isDownloadable = !isDir // files and symlinks can be downloaded
463
+ // Size is the one preview rejection the listing can see coming. Whether a
464
+ // file is binary or readable is only known once its bytes are read.
465
+ const tooLarge = !isDir && (node.size ?? 0) > PREVIEW_BYTE_CAP
349
466
 
350
467
  const handleDownload = async (e: React.MouseEvent) => {
351
468
  e.stopPropagation()
352
469
  if (downloading) return
353
-
354
470
  setDownloading(true)
355
471
  try {
356
- if (await isDesktopApp()) {
357
- const savedPath = await savePodFileToDisk(namespace, podName, container, node.path)
358
- showSuccess(
359
- 'File saved',
360
- savedPath,
361
- {
362
- label: 'Show in Finder',
363
- icon: createElement(FolderOpen, { className: 'w-3.5 h-3.5' }),
364
- onClick: () => openFolder(savedPath),
365
- },
366
- () => openFile(savedPath),
367
- )
368
- return
369
- }
370
-
371
- const params = new URLSearchParams()
372
- params.set('container', container)
373
- params.set('path', node.path)
374
-
375
- const response = await fetch(apiUrl(`/pods/${namespace}/${podName}/files/download?${params.toString()}`), {
376
- credentials: getCredentialsMode(),
377
- headers: getAuthHeaders(),
378
- })
379
- if (!response.ok) {
380
- const err = await response.json().catch(() => ({ error: 'Download failed' }))
381
- throw new Error(err.error || `HTTP ${response.status}`)
382
- }
383
-
384
- const blob = await response.blob()
385
- await downloadBlob(blob, node.name)
386
- } catch (err) {
387
- const message = err instanceof Error ? err.message : String(err)
388
- if (message !== 'cancelled') {
389
- showError(`Could not download ${node.name}`, message)
390
- }
472
+ await downloadPodFile(namespace, podName, container, node, toast)
391
473
  } finally {
392
474
  setDownloading(false)
393
475
  }
394
476
  }
395
477
 
478
+ // Directories open in place; anything else opens in the viewer, which
479
+ // reports a symlink to a directory as "not a regular file" itself.
396
480
  const handleClick = () => {
397
- if (isDir) {
398
- onNavigate(node.path)
399
- }
481
+ if (isDir) onNavigate(node.path)
482
+ else if (!tooLarge) onOpenFile(node)
400
483
  }
401
484
 
402
485
  return (
486
+ <Tooltip
487
+ content={`Too large to preview (${formatBytes(node.size ?? 0)}) — download instead`}
488
+ disabled={!tooLarge}
489
+ wrapperClassName="flex w-full"
490
+ position="bottom"
491
+ >
403
492
  <div
404
493
  className={clsx(
405
- 'flex items-center gap-1 py-0.5 px-1 rounded hover:bg-theme-elevated',
406
- isDir && 'font-medium cursor-pointer'
494
+ 'flex flex-1 min-w-0 items-center gap-1 py-0.5 px-1 rounded hover:bg-theme-elevated',
495
+ isDir && 'font-medium',
496
+ tooLarge ? 'cursor-default' : 'cursor-pointer'
407
497
  )}
408
498
  onClick={handleClick}
409
499
  >
@@ -415,7 +505,7 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
415
505
  <File className="w-4 h-4 text-theme-text-tertiary shrink-0" />
416
506
  )}
417
507
 
418
- <span className="text-theme-text-primary truncate flex-1">{node.name}</span>
508
+ <span className={clsx('truncate flex-1', tooLarge ? 'text-theme-text-secondary' : 'text-theme-text-primary')}>{node.name}</span>
419
509
 
420
510
  {isSymlink && node.linkTarget && (
421
511
  <span className="text-xs text-cyan-400 truncate max-w-48">
@@ -435,7 +525,7 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
435
525
  </span>
436
526
  )}
437
527
 
438
- {isDownloadable && (
528
+ {!isDir && (
439
529
  <Tooltip content="Download file" wrapperClassName="ml-1">
440
530
  <button
441
531
  onClick={handleDownload}
@@ -451,6 +541,6 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
451
541
  </Tooltip>
452
542
  )}
453
543
  </div>
544
+ </Tooltip>
454
545
  )
455
546
  }
456
-
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { DiagnoseCustomizationProvider, useDiagnoseCustomization } from './DiagnoseCustomization';
4
+ import type { RunSummary } from '../api/diagnose';
5
+
6
+ const run = { id: 'r1' } as RunSummary;
7
+ const onRunUpdated = vi.fn();
8
+ function Actions() {
9
+ const { renderRunActions } = useDiagnoseCustomization();
10
+ return <>{renderRunActions?.({ run, onRunUpdated })}</>;
11
+ }
12
+
13
+ describe('investigation action slot', () => {
14
+ it('has no default host actions in OSS', () => {
15
+ expect(renderToStaticMarkup(<Actions />)).toBe('');
16
+ });
17
+ it('passes the run and update callback to the host', () => {
18
+ const renderRunActions = vi.fn(() => <button>Host action</button>);
19
+ expect(renderToStaticMarkup(
20
+ <DiagnoseCustomizationProvider value={undefined} renderRunActions={renderRunActions}>
21
+ <Actions />
22
+ </DiagnoseCustomizationProvider>,
23
+ )).toContain('Host action');
24
+ expect(renderRunActions).toHaveBeenCalledWith({ run, onRunUpdated });
25
+ });
26
+ });
@@ -11,6 +11,15 @@
11
11
  // agent-free.
12
12
  import { createContext, useContext, useMemo } from "react";
13
13
  import type { ReactNode } from "react";
14
+ import type { RunSummary } from "../api/diagnose";
15
+
16
+ /** Optional host controls beside shared run actions. Return a component element
17
+ * if hooks are needed: this callback is invoked conditionally. Call onRunUpdated
18
+ * after a mutation to refresh the shared run summary. */
19
+ export type RenderInvestigationRunActions = (props: {
20
+ run: RunSummary;
21
+ onRunUpdated: (run: RunSummary) => void;
22
+ }) => ReactNode;
14
23
 
15
24
  /** Render prop for the resource-level Investigate action. */
16
25
  export type RenderDiagnoseAction = (ctx: {
@@ -52,6 +61,7 @@ export type DiagnoseConsentCopy = {
52
61
  // set once at mount, so per-value re-render isolation buys nothing.
53
62
  export interface DiagnoseCustomization {
54
63
  renderAction: RenderDiagnoseAction | undefined;
64
+ renderRunActions?: RenderInvestigationRunActions;
55
65
  consentCopy: DiagnoseConsentCopy | undefined;
56
66
  // undefined = default (CustomEvent → Radar's own Settings dialog);
57
67
  // null = hide the settings affordances.
@@ -70,19 +80,21 @@ const DiagnoseCustomizationContext =
70
80
  export function DiagnoseCustomizationProvider({
71
81
  value,
72
82
  consentCopy,
83
+ renderRunActions,
73
84
  onOpenSettings,
74
85
  children,
75
86
  }: {
76
87
  value: RenderDiagnoseAction | undefined;
77
88
  consentCopy?: DiagnoseConsentCopy;
89
+ renderRunActions?: RenderInvestigationRunActions;
78
90
  /** Where "AI settings" affordances lead. Omit for Radar's own Settings
79
91
  * dialog; pass `null` to hide them. */
80
92
  onOpenSettings?: (() => void) | null;
81
93
  children: ReactNode;
82
94
  }) {
83
95
  const ctx = useMemo(
84
- () => ({ renderAction: value, consentCopy, onOpenSettings }),
85
- [value, consentCopy, onOpenSettings],
96
+ () => ({ renderAction: value, consentCopy, onOpenSettings, renderRunActions }),
97
+ [value, consentCopy, onOpenSettings, renderRunActions],
86
98
  );
87
99
  return (
88
100
  <DiagnoseCustomizationContext.Provider value={ctx}>
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export type {
28
28
  } from './api/timelineSource';
29
29
  export type {
30
30
  RenderDiagnoseAction,
31
+ RenderInvestigationRunActions,
31
32
  DiagnoseConsentCopy,
32
33
  } from './context/DiagnoseCustomization';
33
34
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { allShellSafe, isShellSafeValue } from './shell-safe'
3
+ import { allShellSafe, isShellSafeAWSProfile, isShellSafeValue } from './shell-safe'
4
4
 
5
5
  describe('isShellSafeValue', () => {
6
6
  it('accepts the values real provider context names produce', () => {
@@ -53,3 +53,27 @@ describe('allShellSafe', () => {
53
53
  expect(allShellSafe('prod', null)).toBe(false)
54
54
  })
55
55
  })
56
+
57
+ describe('isShellSafeAWSProfile', () => {
58
+ it('accepts simple and slash-separated profile names', () => {
59
+ expect(isShellSafeAWSProfile('default')).toBe(true)
60
+ expect(isShellSafeAWSProfile('myorg/my-account/my-role')).toBe(true)
61
+ expect(isShellSafeAWSProfile('prod-account')).toBe(true)
62
+ })
63
+
64
+ it('rejects shell metacharacters', () => {
65
+ expect(isShellSafeAWSProfile('prod; id')).toBe(false)
66
+ expect(isShellSafeAWSProfile('prod$(id)')).toBe(false)
67
+ expect(isShellSafeAWSProfile('prod profile')).toBe(false)
68
+ })
69
+
70
+ it('rejects leading dash', () => {
71
+ expect(isShellSafeAWSProfile('-rf')).toBe(false)
72
+ })
73
+
74
+ it('rejects empty and absent values', () => {
75
+ expect(isShellSafeAWSProfile('')).toBe(false)
76
+ expect(isShellSafeAWSProfile(null)).toBe(false)
77
+ expect(isShellSafeAWSProfile(undefined)).toBe(false)
78
+ })
79
+ })
@@ -19,3 +19,19 @@ export function isShellSafeValue(value: string | null | undefined): value is str
19
19
  export function allShellSafe(...values: (string | null | undefined)[]): boolean {
20
20
  return values.every(isShellSafeValue)
21
21
  }
22
+
23
+ // AWS SSO profile names may contain `/` as a path separator
24
+ // (e.g. "myorg/my-account/my-role") — safe in shell arguments since `/`
25
+ // has no special meaning to the shell itself.
26
+ const SHELL_SAFE_AWS_PROFILE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/
27
+
28
+ export function isShellSafeAWSProfile(value: string | null | undefined): value is string {
29
+ return typeof value === 'string' && SHELL_SAFE_AWS_PROFILE.test(value)
30
+ }
31
+
32
+ // Returns ` --profile <name>` for embedding in an aws CLI hint, or '' when no
33
+ // profile is pinned or the name fails the allowlist — the hint then falls
34
+ // back to the ambient profile rather than offering nothing.
35
+ export function awsProfileFlag(profile: string | null | undefined): string {
36
+ return isShellSafeAWSProfile(profile) ? ` --profile ${profile}` : ''
37
+ }