@a9s/cli 0.0.1 → 0.1.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 (95) hide show
  1. package/README.md +167 -2
  2. package/dist/scripts/seed.js +310 -0
  3. package/dist/src/App.js +476 -0
  4. package/dist/src/adapters/ServiceAdapter.js +1 -0
  5. package/dist/src/adapters/capabilities/ActionCapability.js +1 -0
  6. package/dist/src/adapters/capabilities/DetailCapability.js +1 -0
  7. package/dist/src/adapters/capabilities/EditCapability.js +1 -0
  8. package/dist/src/adapters/capabilities/YankCapability.js +42 -0
  9. package/dist/src/adapters/capabilities/YankCapability.test.js +29 -0
  10. package/dist/src/components/AdvancedTextInput.js +200 -0
  11. package/dist/src/components/AdvancedTextInput.test.js +190 -0
  12. package/dist/src/components/AutocompleteInput.js +29 -0
  13. package/dist/src/components/DetailPanel.js +12 -0
  14. package/dist/src/components/DiffViewer.js +17 -0
  15. package/dist/src/components/ErrorStatePanel.js +5 -0
  16. package/dist/src/components/HUD.js +31 -0
  17. package/dist/src/components/HelpPanel.js +33 -0
  18. package/dist/src/components/ModeBar.js +43 -0
  19. package/dist/src/components/Table/index.js +109 -0
  20. package/dist/src/components/Table/widths.js +19 -0
  21. package/dist/src/components/TableSkeleton.js +25 -0
  22. package/dist/src/components/YankHelpPanel.js +43 -0
  23. package/dist/src/constants/commands.js +15 -0
  24. package/dist/src/constants/keybindings.js +530 -0
  25. package/dist/src/constants/keys.js +37 -0
  26. package/dist/src/features/AppMainView.integration.test.js +133 -0
  27. package/dist/src/features/AppMainView.js +95 -0
  28. package/dist/src/hooks/inputEvents.js +1 -0
  29. package/dist/src/hooks/mainInputScopes.js +68 -0
  30. package/dist/src/hooks/mainInputScopes.test.js +24 -0
  31. package/dist/src/hooks/useActionController.js +78 -0
  32. package/dist/src/hooks/useAppController.js +102 -0
  33. package/dist/src/hooks/useAppController.test.js +54 -0
  34. package/dist/src/hooks/useAppData.js +48 -0
  35. package/dist/src/hooks/useAwsContext.js +77 -0
  36. package/dist/src/hooks/useAwsProfiles.js +53 -0
  37. package/dist/src/hooks/useAwsRegions.js +105 -0
  38. package/dist/src/hooks/useCommandRouter.js +56 -0
  39. package/dist/src/hooks/useCommandRouter.test.js +27 -0
  40. package/dist/src/hooks/useDetailController.js +57 -0
  41. package/dist/src/hooks/useDetailController.test.js +32 -0
  42. package/dist/src/hooks/useHelpPanel.js +65 -0
  43. package/dist/src/hooks/useHierarchyState.js +39 -0
  44. package/dist/src/hooks/useInputEventProcessor.js +450 -0
  45. package/dist/src/hooks/useInputEventProcessor.test.js +174 -0
  46. package/dist/src/hooks/useKeyChord.js +83 -0
  47. package/dist/src/hooks/useMainInput.js +18 -0
  48. package/dist/src/hooks/useNavigation.js +47 -0
  49. package/dist/src/hooks/usePendingAction.js +8 -0
  50. package/dist/src/hooks/usePickerManager.js +130 -0
  51. package/dist/src/hooks/usePickerState.js +47 -0
  52. package/dist/src/hooks/usePickerTable.js +20 -0
  53. package/dist/src/hooks/useServiceView.js +226 -0
  54. package/dist/src/hooks/useUiHints.js +60 -0
  55. package/dist/src/hooks/useYankMode.js +24 -0
  56. package/dist/src/hooks/yankHeaderMarkers.js +23 -0
  57. package/dist/src/hooks/yankHeaderMarkers.test.js +49 -0
  58. package/dist/src/index.js +30 -0
  59. package/dist/src/services.js +12 -0
  60. package/dist/src/state/atoms.js +27 -0
  61. package/dist/src/types.js +12 -0
  62. package/dist/src/utils/aws.js +39 -0
  63. package/dist/src/utils/debugLogger.js +34 -0
  64. package/dist/src/utils/secretDisplay.js +45 -0
  65. package/dist/src/utils/withFullscreen.js +38 -0
  66. package/dist/src/views/dynamodb/adapter.js +22 -0
  67. package/dist/src/views/iam/adapter.js +258 -0
  68. package/dist/src/views/iam/capabilities/detailCapability.js +93 -0
  69. package/dist/src/views/iam/capabilities/editCapability.js +59 -0
  70. package/dist/src/views/iam/capabilities/yankCapability.js +6 -0
  71. package/dist/src/views/iam/capabilities/yankOptions.js +15 -0
  72. package/dist/src/views/iam/schema.js +7 -0
  73. package/dist/src/views/iam/types.js +1 -0
  74. package/dist/src/views/iam/utils.js +21 -0
  75. package/dist/src/views/route53/adapter.js +22 -0
  76. package/dist/src/views/s3/adapter.js +154 -0
  77. package/dist/src/views/s3/capabilities/actionCapability.js +172 -0
  78. package/dist/src/views/s3/capabilities/detailCapability.js +115 -0
  79. package/dist/src/views/s3/capabilities/editCapability.js +35 -0
  80. package/dist/src/views/s3/capabilities/yankCapability.js +6 -0
  81. package/dist/src/views/s3/capabilities/yankOptions.js +55 -0
  82. package/dist/src/views/s3/client.js +12 -0
  83. package/dist/src/views/s3/fetcher.js +86 -0
  84. package/dist/src/views/s3/schema.js +6 -0
  85. package/dist/src/views/s3/utils.js +19 -0
  86. package/dist/src/views/secretsmanager/adapter.js +188 -0
  87. package/dist/src/views/secretsmanager/capabilities/actionCapability.js +193 -0
  88. package/dist/src/views/secretsmanager/capabilities/detailCapability.js +46 -0
  89. package/dist/src/views/secretsmanager/capabilities/editCapability.js +116 -0
  90. package/dist/src/views/secretsmanager/capabilities/yankCapability.js +7 -0
  91. package/dist/src/views/secretsmanager/capabilities/yankOptions.js +68 -0
  92. package/dist/src/views/secretsmanager/schema.js +28 -0
  93. package/dist/src/views/secretsmanager/types.js +1 -0
  94. package/package.json +68 -5
  95. package/index.js +0 -1
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useLayoutEffect, useMemo } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { useAtomValue } from "jotai";
5
+ import { Table } from "../components/Table/index.js";
6
+ import { HelpPanel } from "../components/HelpPanel.js";
7
+ import { YankHelpPanel } from "../components/YankHelpPanel.js";
8
+ import { DetailPanel } from "../components/DetailPanel.js";
9
+ import { ErrorStatePanel } from "../components/ErrorStatePanel.js";
10
+ import { TableSkeleton } from "../components/TableSkeleton.js";
11
+ import { DiffViewer } from "../components/DiffViewer.js";
12
+ import { debugLog } from "../utils/debugLogger.js";
13
+ import { revealSecretsAtom } from "../state/atoms.js";
14
+ import { truncateSecretForTable } from "../utils/secretDisplay.js";
15
+ import { getCellValue } from "../types.js";
16
+ export function AppMainView({ helpPanel, helpTabs, pickers, error, describeState, isLoading, filteredRows, uploadPending, uploadPreview, columns, selectedIndex, scrollOffset, filterText, adapter, termCols, tableHeight, headerMarkers, yankHelpOpen, yankOptions, yankHelpRow, panelScrollOffset, }) {
17
+ const revealSecrets = useAtomValue(revealSecretsAtom);
18
+ // Format secret values for display ONLY - original rows (via filteredRows) stay unchanged for editing
19
+ const displayRows = useMemo(() => {
20
+ return filteredRows.map((row) => {
21
+ const hasSecrets = Object.values(row.cells).some((cell) => {
22
+ return typeof cell === "object" && cell?.type === "secret";
23
+ });
24
+ if (!hasSecrets) {
25
+ return row;
26
+ }
27
+ // Format secret cells for display
28
+ return {
29
+ ...row,
30
+ cells: Object.fromEntries(Object.entries(row.cells).map(([key, cell]) => {
31
+ if (typeof cell === "object" && cell?.type === "secret") {
32
+ return [
33
+ key,
34
+ {
35
+ ...cell,
36
+ displayName: truncateSecretForTable(cell.displayName, revealSecrets, 50),
37
+ },
38
+ ];
39
+ }
40
+ return [key, cell];
41
+ })),
42
+ };
43
+ });
44
+ }, [filteredRows, revealSecrets]);
45
+ useLayoutEffect(() => {
46
+ debugLog(adapter.id, `AppMainView render`, {
47
+ isLoading,
48
+ filteredRowsCount: filteredRows.length,
49
+ columnsCount: columns.length,
50
+ isHelp: helpPanel.helpOpen,
51
+ hasDescribe: !!describeState,
52
+ hasPicker: !!pickers.activePicker,
53
+ hasYankHelp: yankHelpOpen,
54
+ hasError: !!error,
55
+ });
56
+ }, [
57
+ adapter.id,
58
+ isLoading,
59
+ filteredRows.length,
60
+ columns.length,
61
+ helpPanel.helpOpen,
62
+ describeState,
63
+ pickers.activePicker,
64
+ yankHelpOpen,
65
+ error,
66
+ ]);
67
+ if (helpPanel.helpOpen) {
68
+ return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: "blue", children: _jsx(HelpPanel, { title: "Keyboard Help", scopeLabel: "All modes reference", tabs: helpTabs, activeTab: helpPanel.helpTabIndex, terminalWidth: termCols, maxRows: helpPanel.helpVisibleRows, scrollOffset: helpPanel.helpScrollOffset }) }));
69
+ }
70
+ if (pickers.activePicker) {
71
+ const ap = pickers.activePicker;
72
+ // Pickers don't show secrets, use unformatted rows
73
+ return (_jsx(Table, { rows: ap.filteredRows, columns: ap.columns, selectedIndex: ap.selectedIndex, filterText: ap.filter, terminalWidth: termCols, maxHeight: tableHeight, scrollOffset: ap.scrollOffset, contextLabel: ap.contextLabel }));
74
+ }
75
+ if (yankHelpOpen) {
76
+ return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: "cyan", children: _jsx(YankHelpPanel, { options: yankOptions, row: yankHelpRow }) }));
77
+ }
78
+ if (uploadPending) {
79
+ // Overhead: border 2 + header 4 + separators 2 + DiffViewer header+divider 2 = 10
80
+ const diffVisibleLines = Math.max(1, tableHeight - 10);
81
+ return (_jsxs(Box, { width: "100%", borderStyle: "round", borderColor: "yellow", flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, paddingY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "yellow", children: "\u26A0 Overwrite Secret on AWS?" }), _jsx(Text, { color: "gray", children: "This will update the secret permanently." })] }), _jsx(Box, { paddingX: 1, paddingY: 1, borderTop: true, borderColor: "gray", children: uploadPreview ? (_jsx(DiffViewer, { oldValue: uploadPreview.old, newValue: uploadPreview.new, scrollOffset: panelScrollOffset, visibleLines: diffVisibleLines })) : (_jsx(Text, { color: "gray", children: "Loading preview..." })) }), _jsx(Box, { paddingX: 1, paddingY: 1, borderTop: true, borderColor: "gray", children: _jsxs(Text, { children: ["Press", " ", _jsx(Text, { bold: true, color: "green", children: "y" }), " ", "to confirm or", " ", _jsx(Text, { bold: true, color: "red", children: "n" }), " ", "to cancel"] }) })] }));
82
+ }
83
+ if (describeState) {
84
+ // Overhead: border 2 + title 1 + separator 1 + footer 2 = 6
85
+ const detailVisibleLines = Math.max(1, tableHeight - 6);
86
+ return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: "gray", children: _jsx(DetailPanel, { title: getCellValue(describeState.row.cells.name) ?? describeState.row.id, fields: describeState.fields ?? [], isLoading: describeState.loading, scrollOffset: panelScrollOffset, visibleLines: detailVisibleLines }) }));
87
+ }
88
+ if (isLoading) {
89
+ return (_jsx(TableSkeleton, { columns: columns, terminalWidth: termCols, rows: 1, contextLabel: adapter.getContextLabel?.() ?? "" }));
90
+ }
91
+ if (error) {
92
+ return (_jsx(ErrorStatePanel, { title: `Failed to load ${adapter.label}`, message: error, hint: "Press r to retry" }));
93
+ }
94
+ return (_jsx(Table, { rows: displayRows, columns: columns, selectedIndex: selectedIndex, filterText: filterText, terminalWidth: termCols, maxHeight: tableHeight, scrollOffset: scrollOffset, contextLabel: adapter.getContextLabel?.() ?? "", ...(headerMarkers ? { headerMarkers } : {}) }));
95
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import { KB } from "../constants/keys.js";
2
+ export function resolveHelpScopeAction(input, action) {
3
+ switch (action) {
4
+ case KB.HELP_CLOSE:
5
+ return { type: "close" };
6
+ case KB.HELP_PREV_TAB:
7
+ return { type: "prevTab" };
8
+ case KB.HELP_NEXT_TAB:
9
+ return { type: "nextTab" };
10
+ case KB.HELP_SCROLL_UP:
11
+ return { type: "scrollUp" };
12
+ case KB.HELP_SCROLL_DOWN:
13
+ return { type: "scrollDown" };
14
+ default:
15
+ return /^[1-9]$/.test(input) ? { type: "goToTab", input } : { type: "none" };
16
+ }
17
+ }
18
+ export function resolvePickerScopeAction(key, pickerMode, action) {
19
+ if (pickerMode === "search" && !key.escape) {
20
+ return { type: "consume" };
21
+ }
22
+ switch (action) {
23
+ case KB.PICKER_CLOSE:
24
+ return { type: "close" };
25
+ case KB.PICKER_FILTER:
26
+ return { type: "search" };
27
+ case KB.PICKER_DOWN:
28
+ return { type: "down" };
29
+ case KB.PICKER_UP:
30
+ return { type: "up" };
31
+ case KB.PICKER_TOP:
32
+ return { type: "top" };
33
+ case KB.PICKER_BOTTOM:
34
+ return { type: "bottom" };
35
+ case KB.PICKER_CONFIRM:
36
+ return { type: "confirm" };
37
+ default:
38
+ return { type: "none" };
39
+ }
40
+ }
41
+ export function resolveNavigateScopeAction(action) {
42
+ switch (action) {
43
+ case KB.SEARCH_MODE:
44
+ return { type: "search" };
45
+ case KB.COMMAND_MODE:
46
+ return { type: "command" };
47
+ case KB.QUIT:
48
+ return { type: "quit" };
49
+ case KB.REFRESH:
50
+ return { type: "refresh" };
51
+ case KB.REVEAL_TOGGLE:
52
+ return { type: "reveal" };
53
+ case KB.YANK_MODE:
54
+ return { type: "yank" };
55
+ case KB.DETAILS:
56
+ return { type: "details" };
57
+ case KB.EDIT:
58
+ return { type: "edit" };
59
+ case KB.GO_BOTTOM:
60
+ return { type: "bottom" };
61
+ case KB.GO_TOP:
62
+ return { type: "top" };
63
+ case KB.NAVIGATE_INTO:
64
+ return { type: "enter" };
65
+ default:
66
+ return { type: "none" };
67
+ }
68
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { KB } from "../constants/keys.js";
3
+ import { resolveHelpScopeAction, resolveNavigateScopeAction, resolvePickerScopeAction, } from "./mainInputScopes.js";
4
+ describe("mainInputScopes", () => {
5
+ const plainKey = { escape: false };
6
+ const escapeKey = { escape: true };
7
+ it("resolves help actions", () => {
8
+ expect(resolveHelpScopeAction("", KB.HELP_CLOSE)).toEqual({ type: "close" });
9
+ expect(resolveHelpScopeAction("2", null)).toEqual({ type: "goToTab", input: "2" });
10
+ });
11
+ it("resolves picker search consume behavior", () => {
12
+ expect(resolvePickerScopeAction(plainKey, "search", null)).toEqual({
13
+ type: "consume",
14
+ });
15
+ expect(resolvePickerScopeAction(escapeKey, "search", KB.PICKER_CLOSE)).toEqual({
16
+ type: "close",
17
+ });
18
+ });
19
+ it("maps navigate key actions", () => {
20
+ expect(resolveNavigateScopeAction(KB.SEARCH_MODE)).toEqual({ type: "search" });
21
+ expect(resolveNavigateScopeAction(KB.DETAILS)).toEqual({ type: "details" });
22
+ expect(resolveNavigateScopeAction(null)).toEqual({ type: "none" });
23
+ });
24
+ });
@@ -0,0 +1,78 @@
1
+ import { useCallback } from "react";
2
+ export function useActionController({ adapter, refresh, setPendingAction, pushFeedback, }) {
3
+ const handleSingleEffect = useCallback((effect, row) => {
4
+ switch (effect.type) {
5
+ case "none":
6
+ return;
7
+ case "refresh":
8
+ void refresh();
9
+ setPendingAction(null);
10
+ return;
11
+ case "feedback":
12
+ pushFeedback(effect.message, 2500);
13
+ setPendingAction(null);
14
+ return;
15
+ case "clipboard":
16
+ pushFeedback(effect.feedback, 2500);
17
+ setPendingAction(null);
18
+ return;
19
+ case "error":
20
+ pushFeedback(effect.message, 3000);
21
+ setPendingAction(null);
22
+ return;
23
+ case "prompt":
24
+ setPendingAction({
25
+ effect,
26
+ row,
27
+ inputValue: effect.defaultValue ?? "",
28
+ accumulatedData: effect.data ?? {},
29
+ });
30
+ return;
31
+ case "confirm":
32
+ setPendingAction({
33
+ effect,
34
+ row,
35
+ inputValue: "",
36
+ accumulatedData: effect.data ?? {},
37
+ });
38
+ }
39
+ }, [pushFeedback, refresh, setPendingAction]);
40
+ const handleActionEffect = useCallback((effect, row) => {
41
+ if (effect.type === "multi") {
42
+ for (const next of effect.effects) {
43
+ handleSingleEffect(next, row);
44
+ }
45
+ return;
46
+ }
47
+ handleSingleEffect(effect, row);
48
+ }, [handleSingleEffect]);
49
+ const submitPendingAction = useCallback((pendingAction, confirmed) => {
50
+ if (!pendingAction || !adapter.capabilities?.actions)
51
+ return;
52
+ const effect = pendingAction.effect;
53
+ if (effect.type === "confirm" && !confirmed) {
54
+ setPendingAction(null);
55
+ return;
56
+ }
57
+ const nextData = {
58
+ ...pendingAction.accumulatedData,
59
+ path: pendingAction.inputValue,
60
+ };
61
+ void adapter.capabilities.actions
62
+ .executeAction(effect.nextActionId, {
63
+ row: pendingAction.row,
64
+ data: nextData,
65
+ })
66
+ .then((nextEffect) => {
67
+ handleActionEffect(nextEffect, pendingAction.row);
68
+ })
69
+ .catch((err) => {
70
+ pushFeedback(`Action failed: ${err.message}`, 3000);
71
+ setPendingAction(null);
72
+ });
73
+ }, [adapter.capabilities?.actions, handleActionEffect, pushFeedback, setPendingAction]);
74
+ return {
75
+ handleActionEffect,
76
+ submitPendingAction,
77
+ };
78
+ }
@@ -0,0 +1,102 @@
1
+ import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
2
+ export const initialAppControllerState = {
3
+ mode: "navigate",
4
+ filterText: "",
5
+ commandText: "",
6
+ searchEntryFilter: null,
7
+ commandCursorToEndToken: 0,
8
+ yankMode: false,
9
+ yankHelpOpen: false,
10
+ yankFeedbackMessage: null,
11
+ uploadPending: null,
12
+ describeState: null,
13
+ pendingAction: null,
14
+ };
15
+ export function appControllerReducer(state, action) {
16
+ switch (action.type) {
17
+ case "setMode":
18
+ return { ...state, mode: action.mode };
19
+ case "setFilterText":
20
+ return { ...state, filterText: action.value };
21
+ case "setCommandText":
22
+ return { ...state, commandText: action.value };
23
+ case "setSearchEntryFilter":
24
+ return { ...state, searchEntryFilter: action.value };
25
+ case "bumpCommandCursorToEnd":
26
+ return {
27
+ ...state,
28
+ commandCursorToEndToken: state.commandCursorToEndToken + 1,
29
+ };
30
+ case "setYankMode":
31
+ return { ...state, yankMode: action.value };
32
+ case "setYankHelpOpen":
33
+ return { ...state, yankHelpOpen: action.value };
34
+ case "setYankFeedback":
35
+ return { ...state, yankFeedbackMessage: action.value };
36
+ case "setUploadPending":
37
+ return { ...state, uploadPending: action.value };
38
+ case "setDescribeState":
39
+ return {
40
+ ...state,
41
+ describeState: typeof action.value === "function" ? action.value(state.describeState) : action.value,
42
+ };
43
+ case "setPendingAction":
44
+ return { ...state, pendingAction: action.value };
45
+ case "setPendingInputValue":
46
+ return state.pendingAction
47
+ ? {
48
+ ...state,
49
+ pendingAction: {
50
+ ...state.pendingAction,
51
+ inputValue: action.value,
52
+ },
53
+ }
54
+ : state;
55
+ default:
56
+ return state;
57
+ }
58
+ }
59
+ export function useAppController() {
60
+ const [state, dispatch] = useReducer(appControllerReducer, initialAppControllerState);
61
+ const feedbackTimerRef = useRef(null);
62
+ const clearFeedbackTimer = useCallback(() => {
63
+ if (feedbackTimerRef.current) {
64
+ clearTimeout(feedbackTimerRef.current);
65
+ feedbackTimerRef.current = null;
66
+ }
67
+ }, []);
68
+ useEffect(() => {
69
+ return () => {
70
+ clearFeedbackTimer();
71
+ };
72
+ }, [clearFeedbackTimer]);
73
+ const actions = useMemo(() => ({
74
+ setMode: (mode) => dispatch({ type: "setMode", mode }),
75
+ setFilterText: (value) => dispatch({ type: "setFilterText", value }),
76
+ setCommandText: (value) => dispatch({ type: "setCommandText", value }),
77
+ setSearchEntryFilter: (value) => dispatch({ type: "setSearchEntryFilter", value }),
78
+ bumpCommandCursorToEnd: () => dispatch({ type: "bumpCommandCursorToEnd" }),
79
+ setYankMode: (value) => dispatch({ type: "setYankMode", value }),
80
+ setYankHelpOpen: (value) => dispatch({ type: "setYankHelpOpen", value }),
81
+ setUploadPending: (value) => dispatch({ type: "setUploadPending", value }),
82
+ setDescribeState: (value) => dispatch({ type: "setDescribeState", value }),
83
+ setPendingAction: (value) => dispatch({ type: "setPendingAction", value }),
84
+ setPendingInputValue: (value) => dispatch({ type: "setPendingInputValue", value }),
85
+ clearFeedback: () => {
86
+ clearFeedbackTimer();
87
+ dispatch({ type: "setYankFeedback", value: null });
88
+ },
89
+ pushFeedback: (message, durationMs = 1500) => {
90
+ clearFeedbackTimer();
91
+ dispatch({ type: "setYankFeedback", value: message });
92
+ feedbackTimerRef.current = setTimeout(() => {
93
+ dispatch({ type: "setYankFeedback", value: null });
94
+ feedbackTimerRef.current = null;
95
+ }, durationMs);
96
+ },
97
+ }), [clearFeedbackTimer]);
98
+ return {
99
+ state,
100
+ actions,
101
+ };
102
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { appControllerReducer, initialAppControllerState } from "./useAppController.js";
3
+ import { textCell } from "../types.js";
4
+ describe("appControllerReducer", () => {
5
+ it("enters and exits search mode", () => {
6
+ const searching = appControllerReducer(initialAppControllerState, {
7
+ type: "setMode",
8
+ mode: "search",
9
+ });
10
+ expect(searching.mode).toBe("search");
11
+ const navigating = appControllerReducer(searching, {
12
+ type: "setMode",
13
+ mode: "navigate",
14
+ });
15
+ expect(navigating.mode).toBe("navigate");
16
+ });
17
+ it("updates pending prompt input deterministically", () => {
18
+ const withPrompt = appControllerReducer(initialAppControllerState, {
19
+ type: "setPendingAction",
20
+ value: {
21
+ effect: {
22
+ type: "prompt",
23
+ label: "Path",
24
+ nextActionId: "next",
25
+ },
26
+ row: null,
27
+ inputValue: "",
28
+ accumulatedData: {},
29
+ },
30
+ });
31
+ const updated = appControllerReducer(withPrompt, {
32
+ type: "setPendingInputValue",
33
+ value: "s3://bucket/key",
34
+ });
35
+ expect(updated.pendingAction?.inputValue).toBe("s3://bucket/key");
36
+ });
37
+ it("supports details open and close transitions", () => {
38
+ const opened = appControllerReducer(initialAppControllerState, {
39
+ type: "setDescribeState",
40
+ value: {
41
+ row: { id: "1", cells: { name: textCell("obj") } },
42
+ fields: null,
43
+ loading: true,
44
+ requestId: 1,
45
+ },
46
+ });
47
+ expect(opened.describeState?.loading).toBe(true);
48
+ const closed = appControllerReducer(opened, {
49
+ type: "setDescribeState",
50
+ value: null,
51
+ });
52
+ expect(closed.describeState).toBeNull();
53
+ });
54
+ });
@@ -0,0 +1,48 @@
1
+ import { useMemo, useLayoutEffect } from "react";
2
+ import { SERVICE_REGISTRY } from "../services.js";
3
+ import { useServiceView } from "./useServiceView.js";
4
+ import { useNavigation } from "./useNavigation.js";
5
+ import { usePickerManager } from "./usePickerManager.js";
6
+ import { debugLog } from "../utils/debugLogger.js";
7
+ export function useAppData({ currentService, endpointUrl, selectedRegion, tableHeight, filterText, availableRegions, availableProfiles, }) {
8
+ const adapter = useMemo(() => {
9
+ debugLog(currentService, `useAppData: adapter created`);
10
+ return SERVICE_REGISTRY[currentService](endpointUrl, selectedRegion);
11
+ }, [currentService, endpointUrl, selectedRegion]);
12
+ const { rows, columns, isLoading, error, select, edit, goBack, refresh, path } = useServiceView(adapter);
13
+ useLayoutEffect(() => {
14
+ debugLog(adapter.id, `useAppData: received rows from useServiceView`, {
15
+ rowCount: rows.length,
16
+ isLoading,
17
+ "state.adapterId": rows.length > 0 ? "has-data" : "empty",
18
+ });
19
+ }, [rows.length, isLoading, adapter.id]);
20
+ const filteredRows = useMemo(() => {
21
+ if (!filterText)
22
+ return rows;
23
+ const lowerFilter = filterText.toLowerCase();
24
+ return rows.filter((row) => Object.values(row.cells).some((cell) => {
25
+ const value = typeof cell === "string" ? cell : cell.displayName;
26
+ return value.toLowerCase().includes(lowerFilter);
27
+ }));
28
+ }, [filterText, rows]);
29
+ const navigation = useNavigation(filteredRows.length, tableHeight);
30
+ const selectedRow = filteredRows[navigation.selectedIndex] ?? null;
31
+ const pickers = usePickerManager({ tableHeight, availableRegions, availableProfiles });
32
+ return {
33
+ adapter,
34
+ rows,
35
+ columns,
36
+ isLoading,
37
+ error,
38
+ select,
39
+ edit,
40
+ goBack,
41
+ refresh,
42
+ path,
43
+ filteredRows,
44
+ selectedRow,
45
+ navigation,
46
+ pickers,
47
+ };
48
+ }
@@ -0,0 +1,77 @@
1
+ import { useEffect, useState } from "react";
2
+ import { runAwsCli } from "../utils/aws.js";
3
+ const DEFAULT_REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1";
4
+ export function useAwsContext(endpointUrl, selectedRegion, selectedProfile) {
5
+ const explicitProfile = selectedProfile && selectedProfile !== "$default" ? selectedProfile : undefined;
6
+ const envProfile = selectedProfile ?? process.env.AWS_PROFILE ?? "default";
7
+ const resolvedRegion = selectedRegion ?? DEFAULT_REGION;
8
+ const [context, setContext] = useState({
9
+ accountName: "Resolving",
10
+ accountId: "------------",
11
+ awsProfile: envProfile,
12
+ currentIdentity: "resolving",
13
+ region: resolvedRegion,
14
+ });
15
+ useEffect(() => {
16
+ let alive = true;
17
+ void (async () => {
18
+ if (endpointUrl) {
19
+ const profile = explicitProfile ?? process.env.AWS_PROFILE ?? "local";
20
+ if (!alive)
21
+ return;
22
+ setContext({
23
+ accountName: `LocalStack (${profile})`,
24
+ accountId: process.env.AWS_ACCOUNT_ID ?? "000000000000",
25
+ awsProfile: selectedProfile ?? profile,
26
+ currentIdentity: process.env.AWS_ACCESS_KEY_ID ?? profile,
27
+ region: resolvedRegion,
28
+ });
29
+ return;
30
+ }
31
+ const profile = explicitProfile ?? process.env.AWS_PROFILE ?? "default";
32
+ const [stsOut, aliasOut] = await Promise.all([
33
+ runAwsCli(["sts", "get-caller-identity", "--output", "json"], 1500),
34
+ runAwsCli(["iam", "list-account-aliases", "--output", "json"], 1500),
35
+ ]);
36
+ let accountId = "";
37
+ let userId = "";
38
+ let arn = "";
39
+ if (stsOut) {
40
+ try {
41
+ const parsed = JSON.parse(stsOut);
42
+ accountId = parsed.Account ?? "";
43
+ userId = parsed.UserId ?? "";
44
+ arn = parsed.Arn ?? "";
45
+ }
46
+ catch {
47
+ // ignore parse issues, fallback below
48
+ }
49
+ }
50
+ let alias = "";
51
+ if (aliasOut) {
52
+ try {
53
+ const parsed = JSON.parse(aliasOut);
54
+ alias = parsed.AccountAliases?.[0] ?? "";
55
+ }
56
+ catch {
57
+ // ignore parse issues, fallback below
58
+ }
59
+ }
60
+ const identity = alias || profile || (accountId ? "AWS Account" : "Unknown Account");
61
+ const currentIdentity = arn || userId || profile || "unknown";
62
+ if (!alive)
63
+ return;
64
+ setContext({
65
+ accountName: identity,
66
+ accountId: accountId || "------------",
67
+ awsProfile: selectedProfile ?? profile,
68
+ currentIdentity,
69
+ region: resolvedRegion,
70
+ });
71
+ })();
72
+ return () => {
73
+ alive = false;
74
+ };
75
+ }, [endpointUrl, explicitProfile, resolvedRegion, selectedProfile]);
76
+ return context;
77
+ }
@@ -0,0 +1,53 @@
1
+ import { useEffect, useState } from "react";
2
+ import { runAwsCli } from "../utils/aws.js";
3
+ const DEFAULT_PROFILE = {
4
+ name: "$default",
5
+ description: "use process credentials/environment",
6
+ };
7
+ let cachedProfiles = null;
8
+ let pendingProfilesPromise = null;
9
+ async function fetchProfiles() {
10
+ const stdout = await runAwsCli(["configure", "list-profiles"], 3000);
11
+ if (!stdout)
12
+ return [DEFAULT_PROFILE];
13
+ const profileNames = stdout
14
+ .split(/\r?\n/)
15
+ .map((line) => line.trim())
16
+ .filter(Boolean);
17
+ if (profileNames.length === 0)
18
+ return [DEFAULT_PROFILE];
19
+ // Keep startup snappy: avoid N additional AWS CLI calls for region enrichment here.
20
+ const withMeta = profileNames.map((name) => ({
21
+ name,
22
+ description: "configured profile",
23
+ }));
24
+ const hasDefault = withMeta.some((p) => p.name === "$default");
25
+ return hasDefault ? withMeta : [DEFAULT_PROFILE, ...withMeta];
26
+ }
27
+ export function useAwsProfiles() {
28
+ const [profiles, setProfiles] = useState([]);
29
+ useEffect(() => {
30
+ let alive = true;
31
+ if (cachedProfiles) {
32
+ setProfiles(cachedProfiles);
33
+ return () => {
34
+ alive = false;
35
+ };
36
+ }
37
+ setProfiles([DEFAULT_PROFILE]);
38
+ if (!pendingProfilesPromise) {
39
+ pendingProfilesPromise = fetchProfiles().then((result) => {
40
+ cachedProfiles = result;
41
+ return result;
42
+ });
43
+ }
44
+ void pendingProfilesPromise.then((result) => {
45
+ if (alive)
46
+ setProfiles(result);
47
+ });
48
+ return () => {
49
+ alive = false;
50
+ };
51
+ }, []);
52
+ return profiles;
53
+ }