@a9s/cli 0.0.1 → 1.0.6

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 +72 -5
  95. package/index.js +0 -1
@@ -0,0 +1,23 @@
1
+ export function deriveYankHeaderMarkers(yankMode, yankOptions) {
2
+ if (!yankMode)
3
+ return undefined;
4
+ const map = new Map();
5
+ for (const option of yankOptions) {
6
+ if (!option.headerKey)
7
+ continue;
8
+ if (option.trigger.type !== "key")
9
+ continue;
10
+ const token = option.trigger.char;
11
+ const prev = map.get(option.headerKey) ?? [];
12
+ if (!prev.includes(token)) {
13
+ map.set(option.headerKey, [...prev, token]);
14
+ }
15
+ }
16
+ if (map.size === 0)
17
+ return undefined;
18
+ const markers = {};
19
+ for (const [key, tokens] of map.entries()) {
20
+ markers[key] = tokens;
21
+ }
22
+ return markers;
23
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deriveYankHeaderMarkers } from "./yankHeaderMarkers.js";
3
+ describe("deriveYankHeaderMarkers", () => {
4
+ it("returns undefined when yank mode is inactive", () => {
5
+ const result = deriveYankHeaderMarkers(false, [
6
+ {
7
+ trigger: { type: "key", char: "n" },
8
+ label: "copy name",
9
+ feedback: "copied",
10
+ headerKey: "name",
11
+ isRelevant: () => true,
12
+ resolve: async () => "x",
13
+ },
14
+ ]);
15
+ expect(result).toBeUndefined();
16
+ });
17
+ it("groups key triggers by header key", () => {
18
+ const result = deriveYankHeaderMarkers(true, [
19
+ {
20
+ trigger: { type: "key", char: "n" },
21
+ label: "copy name",
22
+ feedback: "copied",
23
+ headerKey: "name",
24
+ isRelevant: () => true,
25
+ resolve: async () => "x",
26
+ },
27
+ {
28
+ trigger: { type: "key", char: "d" },
29
+ label: "copy lm",
30
+ feedback: "copied",
31
+ headerKey: "lastModified",
32
+ isRelevant: () => true,
33
+ resolve: async () => "x",
34
+ },
35
+ {
36
+ trigger: { type: "special", name: "return" },
37
+ label: "ignored",
38
+ feedback: "copied",
39
+ headerKey: "name",
40
+ isRelevant: () => true,
41
+ resolve: async () => "x",
42
+ },
43
+ ]);
44
+ expect(result).toEqual({
45
+ name: ["n"],
46
+ lastModified: ["d"],
47
+ });
48
+ });
49
+ });
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Command, Option } from "@commander-js/extra-typings";
3
+ import { App } from "./App.js";
4
+ import { SERVICE_REGISTRY } from "./services.js";
5
+ import { withFullscreen } from "./utils/withFullscreen.js";
6
+ const SERVICE_IDS = Object.keys(SERVICE_REGISTRY);
7
+ const program = new Command()
8
+ .name("a9s")
9
+ .description("k9s-style AWS navigator")
10
+ .version("0.1.0")
11
+ .addOption(new Option("-s, --service <service>", "AWS service to browse")
12
+ .choices(SERVICE_IDS)
13
+ .default("s3"))
14
+ .addOption(new Option("--endpoint-url <url>", "Custom endpoint URL (e.g. http://localhost:4566 for LocalStack)").env("AWS_ENDPOINT_URL"));
15
+ program.parse();
16
+ // opts() return type is fully inferred from addOption() calls via extra-typings
17
+ const options = program.opts();
18
+ void (async () => {
19
+ const { instance, cleanup } = withFullscreen(_jsx(App, { initialService: options.service, endpointUrl: options.endpointUrl }));
20
+ process.on("SIGINT", () => {
21
+ cleanup();
22
+ process.exit(0);
23
+ });
24
+ process.on("SIGTERM", () => {
25
+ cleanup();
26
+ process.exit(0);
27
+ });
28
+ await instance.waitUntilExit();
29
+ cleanup();
30
+ })();
@@ -0,0 +1,12 @@
1
+ import { createS3ServiceAdapter } from "./views/s3/adapter.js";
2
+ import { createRoute53ServiceAdapter } from "./views/route53/adapter.js";
3
+ import { createDynamoDBServiceAdapter } from "./views/dynamodb/adapter.js";
4
+ import { createIamServiceAdapter } from "./views/iam/adapter.js";
5
+ import { createSecretsManagerServiceAdapter } from "./views/secretsmanager/adapter.js";
6
+ export const SERVICE_REGISTRY = {
7
+ s3: (endpointUrl, region) => createS3ServiceAdapter(endpointUrl, region),
8
+ route53: (_endpointUrl, _region) => createRoute53ServiceAdapter(),
9
+ dynamodb: (_endpointUrl, _region) => createDynamoDBServiceAdapter(),
10
+ iam: (_endpointUrl, _region) => createIamServiceAdapter(),
11
+ secretsmanager: (endpointUrl, region) => createSecretsManagerServiceAdapter(endpointUrl, region),
12
+ };
@@ -0,0 +1,27 @@
1
+ import { atom } from "jotai";
2
+ /** Persists across HMR / re-renders. Currently selected AWS service. */
3
+ export const currentlySelectedServiceAtom = atom("s3");
4
+ /** Current UI mode (navigate / search / command). */
5
+ export const modeAtom = atom("navigate");
6
+ /** Active filter text applied to the current view. */
7
+ export const filterTextAtom = atom("");
8
+ /** Text typed in command mode. */
9
+ export const commandTextAtom = atom("");
10
+ /** Navigation history: parallel stacks of filter texts and selected indices per level. */
11
+ export const hierarchyStateAtom = atom({
12
+ filters: [""],
13
+ indices: [0],
14
+ });
15
+ /** Selected AWS region. Falls back to env vars or us-east-1. */
16
+ export const selectedRegionAtom = atom(process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1");
17
+ /** Selected AWS profile. "$default" means use ambient credentials. */
18
+ export const selectedProfileAtom = atom(process.env.AWS_PROFILE ?? "$default");
19
+ /** Derived atom: adapter session ID. Changes atomically when service/region/profile change. */
20
+ export const adapterSessionAtom = atom((get) => {
21
+ const service = get(currentlySelectedServiceAtom);
22
+ const region = get(selectedRegionAtom);
23
+ const profile = get(selectedProfileAtom);
24
+ return `${service}:${region}:${profile}`;
25
+ });
26
+ /** Toggle state for revealing/hiding secret values. Persists across HMR. */
27
+ export const revealSecretsAtom = atom(false);
@@ -0,0 +1,12 @@
1
+ /** Helper to create a text cell */
2
+ export function textCell(displayName) {
3
+ return { displayName, type: "text" };
4
+ }
5
+ /** Helper to create a secret cell */
6
+ export function secretCell(displayName) {
7
+ return { displayName, type: "secret" };
8
+ }
9
+ /** Helper to get cell displayName from Cell or string */
10
+ export function getCellValue(cell) {
11
+ return typeof cell === "string" ? cell : cell.displayName;
12
+ }
@@ -0,0 +1,39 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ /**
5
+ * Run an AWS CLI command asynchronously. Returns stdout or null on error/timeout.
6
+ */
7
+ export async function runAwsCli(args, timeoutMs = 2000) {
8
+ try {
9
+ const { stdout } = await execFileAsync("aws", args, {
10
+ timeout: timeoutMs,
11
+ env: process.env,
12
+ });
13
+ return stdout;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /**
20
+ * Run an AWS CLI command asynchronously and parse JSON output. Throws on error.
21
+ */
22
+ export async function runAwsJsonAsync(args) {
23
+ try {
24
+ const { stdout } = await execFileAsync("aws", [...args, "--output", "json"], {
25
+ timeout: 10_000,
26
+ env: process.env,
27
+ });
28
+ return JSON.parse(stdout);
29
+ }
30
+ catch (error) {
31
+ const message = error.message;
32
+ const isIamDisabled = message.includes("Service 'iam' is not enabled") ||
33
+ message.includes("when calling the ListRoles operation");
34
+ if (isIamDisabled && Boolean(process.env.AWS_ENDPOINT_URL)) {
35
+ throw new Error("IAM is not enabled in LocalStack. Add iam to SERVICES (for example: SERVICES=s3,iam,sts) and restart LocalStack.");
36
+ }
37
+ throw error;
38
+ }
39
+ }
@@ -0,0 +1,34 @@
1
+ import { appendFileSync, mkdirSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ const LOG_DIR = join(homedir(), ".local/share/a9s");
5
+ const LOG_FILE = join(LOG_DIR, "debug.log");
6
+ // Ensure log directory exists
7
+ try {
8
+ mkdirSync(LOG_DIR, { recursive: true });
9
+ }
10
+ catch {
11
+ // Silently fail if we can't create directory
12
+ }
13
+ function timestamp() {
14
+ return new Date().toISOString();
15
+ }
16
+ export function debugLog(tag, message, data) {
17
+ const logEntry = data
18
+ ? `[${timestamp()}] ${tag} ${message} ${JSON.stringify(data)}`
19
+ : `[${timestamp()}] ${tag} ${message}`;
20
+ try {
21
+ appendFileSync(LOG_FILE, logEntry + "\n");
22
+ }
23
+ catch {
24
+ // Silently fail if we can't write to log file
25
+ }
26
+ }
27
+ export function clearDebugLog() {
28
+ try {
29
+ appendFileSync(LOG_FILE, "\n\n=== LOG CLEARED ===\n\n");
30
+ }
31
+ catch {
32
+ // Silently fail
33
+ }
34
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Format secret values for display:
3
+ * - Escape newlines as literal \n
4
+ * - Optionally mask with asterisks if not revealed
5
+ */
6
+ export function formatSecretForDisplay(value, reveal = false) {
7
+ if (!value)
8
+ return "";
9
+ // Escape newlines as literal \n
10
+ const escaped = value.replace(/\n/g, "\\n").replace(/\r/g, "\\r");
11
+ // Mask with asterisks if not revealed
12
+ if (!reveal) {
13
+ return "****";
14
+ }
15
+ return escaped;
16
+ }
17
+ /**
18
+ * Truncate secret for table display (with escaping)
19
+ * For hidden values: shows "****"
20
+ * For revealed values:
21
+ * - Shows full value if fits in maxLength chars
22
+ * - Shows multi-line truncation if too large (2 header lines + ... + 1 footer line)
23
+ */
24
+ export function truncateSecretForTable(value, reveal = false, maxLength = 50) {
25
+ if (!value)
26
+ return "";
27
+ const formatted = formatSecretForDisplay(value, reveal);
28
+ // If hidden, always return the masked value
29
+ if (!reveal) {
30
+ return formatted; // "****"
31
+ }
32
+ // For revealed values, handle multi-line truncation
33
+ const lines = formatted.split("\\n"); // Split by escaped newline
34
+ if (lines.length > 3) {
35
+ // Show 2 header lines + ... + 1 footer line
36
+ const header = lines.slice(0, 2).join("\\n");
37
+ const footer = lines[lines.length - 1];
38
+ return `${header}\\n...\\n${footer}`;
39
+ }
40
+ // If single/few lines, apply character truncation
41
+ if (formatted.length > maxLength) {
42
+ return formatted.slice(0, maxLength) + "...";
43
+ }
44
+ return formatted;
45
+ }
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useState } from "react";
3
+ import { Box, render } from "ink";
4
+ // Hook replacing useTerminalSize — listens for resize events
5
+ export function useScreenSize() {
6
+ const [size, setSize] = useState({
7
+ columns: process.stdout.columns ?? 80,
8
+ rows: process.stdout.rows ?? 24,
9
+ });
10
+ useEffect(() => {
11
+ const handler = () => setSize({
12
+ columns: process.stdout.columns ?? 80,
13
+ rows: process.stdout.rows ?? 24,
14
+ });
15
+ process.stdout.on("resize", handler);
16
+ return () => {
17
+ process.stdout.off("resize", handler);
18
+ };
19
+ }, []);
20
+ return size;
21
+ }
22
+ // Full-terminal Box — takes exactly columns×rows
23
+ export const FullscreenBox = forwardRef(({ children, ...props }, ref) => {
24
+ const { columns, rows } = useScreenSize();
25
+ return (_jsx(Box, { ref: ref, width: columns, height: rows, ...props, children: children }));
26
+ });
27
+ FullscreenBox.displayName = "FullscreenBox";
28
+ // Wrap render() with alternate screen buffer enter/exit
29
+ export function withFullscreen(element) {
30
+ process.stdout.write("\x1b[?1049h"); // enter alternate screen
31
+ process.stdout.write("\x1b[H"); // move cursor to top-left
32
+ const instance = render(element);
33
+ const cleanup = () => {
34
+ instance.unmount();
35
+ process.stdout.write("\x1b[?1049l"); // exit alternate screen
36
+ };
37
+ return { instance, cleanup };
38
+ }
@@ -0,0 +1,22 @@
1
+ import { textCell } from "../../types.js";
2
+ export function createDynamoDBServiceAdapter() {
3
+ const getColumns = () => [{ key: "name", label: "Name" }];
4
+ const getRows = async () => {
5
+ return [{ id: "stub", cells: { name: textCell("DynamoDB not yet implemented") }, meta: {} }];
6
+ };
7
+ const onSelect = async (_row) => {
8
+ return { action: "none" };
9
+ };
10
+ return {
11
+ id: "dynamodb",
12
+ label: "DynamoDB",
13
+ hudColor: { bg: "green", fg: "white" },
14
+ getColumns,
15
+ getRows,
16
+ onSelect,
17
+ canGoBack: () => false,
18
+ goBack: () => { },
19
+ getPath: () => "/",
20
+ getContextLabel: () => "⚡ Tables",
21
+ };
22
+ }
@@ -0,0 +1,258 @@
1
+ import { runAwsJsonAsync } from "../../utils/aws.js";
2
+ import { textCell } from "../../types.js";
3
+ import { formatDate } from "./utils.js";
4
+ import { createIamEditCapability } from "./capabilities/editCapability.js";
5
+ import { createIamDetailCapability } from "./capabilities/detailCapability.js";
6
+ import { createIamYankCapability } from "./capabilities/yankCapability.js";
7
+ function getIamMeta(row) {
8
+ return row.meta;
9
+ }
10
+ export function createIamServiceAdapter() {
11
+ let level = { kind: "root" };
12
+ let backStack = [];
13
+ const getLevel = () => level;
14
+ const setLevel = (newLevel) => {
15
+ level = newLevel;
16
+ };
17
+ const getBackStack = () => backStack;
18
+ const setBackStack = (newStack) => {
19
+ backStack = newStack;
20
+ };
21
+ const getColumns = () => {
22
+ switch (level.kind) {
23
+ case "root":
24
+ case "role-menu":
25
+ return [
26
+ { key: "name", label: "Name" },
27
+ { key: "type", label: "Type", width: 24 },
28
+ ];
29
+ case "roles":
30
+ return [
31
+ { key: "name", label: "Role Name" },
32
+ { key: "type", label: "Type", width: 14 },
33
+ { key: "created", label: "Created", width: 22 },
34
+ ];
35
+ case "role-inline-policies":
36
+ case "role-attached-policies":
37
+ case "policies":
38
+ return [
39
+ { key: "name", label: "Policy Name" },
40
+ { key: "type", label: "Type", width: 24 },
41
+ { key: "scope", label: "Scope", width: 12 },
42
+ ];
43
+ }
44
+ };
45
+ const getRows = async () => {
46
+ switch (level.kind) {
47
+ case "root":
48
+ return [
49
+ {
50
+ id: "roles",
51
+ cells: { name: textCell("Roles"), type: textCell("IAM Role List") },
52
+ meta: { type: "menu", kind: "roles" },
53
+ },
54
+ {
55
+ id: "policies",
56
+ cells: { name: textCell("Policies"), type: textCell("Managed Policy List") },
57
+ meta: { type: "menu", kind: "policies" },
58
+ },
59
+ ];
60
+ case "roles": {
61
+ const data = await runAwsJsonAsync(["iam", "list-roles"]);
62
+ return (data.Roles ?? []).map((role) => ({
63
+ id: role.RoleName,
64
+ cells: {
65
+ name: textCell(role.RoleName),
66
+ type: textCell("Role"),
67
+ created: textCell(formatDate(role.CreateDate)),
68
+ },
69
+ meta: { type: "role", roleName: role.RoleName, arn: role.Arn },
70
+ }));
71
+ }
72
+ case "role-menu":
73
+ return [
74
+ {
75
+ id: `${level.roleName}::inline`,
76
+ cells: { name: textCell("Inline Policies"), type: textCell("Role Inline Policies") },
77
+ meta: {
78
+ type: "menu",
79
+ kind: "role-inline-policies",
80
+ roleName: level.roleName,
81
+ },
82
+ },
83
+ {
84
+ id: `${level.roleName}::attached`,
85
+ cells: {
86
+ name: textCell("Attached Policies"),
87
+ type: textCell("Role Attached Policies"),
88
+ },
89
+ meta: {
90
+ type: "menu",
91
+ kind: "role-attached-policies",
92
+ roleName: level.roleName,
93
+ },
94
+ },
95
+ ];
96
+ case "role-inline-policies": {
97
+ const { roleName } = level;
98
+ const data = await runAwsJsonAsync([
99
+ "iam",
100
+ "list-role-policies",
101
+ "--role-name",
102
+ roleName,
103
+ ]);
104
+ return (data.PolicyNames ?? []).map((policyName) => ({
105
+ id: `${roleName}::inline::${policyName}`,
106
+ cells: {
107
+ name: textCell(policyName),
108
+ type: textCell("Inline Policy"),
109
+ scope: textCell("Role"),
110
+ },
111
+ meta: {
112
+ type: "inline-policy",
113
+ roleName,
114
+ policyName,
115
+ },
116
+ }));
117
+ }
118
+ case "role-attached-policies": {
119
+ const { roleName } = level;
120
+ const data = await runAwsJsonAsync([
121
+ "iam",
122
+ "list-attached-role-policies",
123
+ "--role-name",
124
+ roleName,
125
+ ]);
126
+ return (data.AttachedPolicies ?? []).map((policy) => ({
127
+ id: policy.PolicyArn,
128
+ cells: {
129
+ name: textCell(policy.PolicyName),
130
+ type: textCell("Attached Policy"),
131
+ scope: textCell("Managed"),
132
+ },
133
+ meta: {
134
+ type: "managed-policy",
135
+ policyArn: policy.PolicyArn,
136
+ policyName: policy.PolicyName,
137
+ },
138
+ }));
139
+ }
140
+ case "policies": {
141
+ const data = await runAwsJsonAsync([
142
+ "iam",
143
+ "list-policies",
144
+ "--scope",
145
+ "Local",
146
+ ]);
147
+ return (data.Policies ?? []).map((policy) => ({
148
+ id: policy.Arn,
149
+ cells: {
150
+ name: textCell(policy.PolicyName),
151
+ type: textCell("Managed Policy"),
152
+ scope: textCell("Account"),
153
+ },
154
+ meta: {
155
+ type: "managed-policy",
156
+ policyArn: policy.Arn,
157
+ policyName: policy.PolicyName,
158
+ },
159
+ }));
160
+ }
161
+ }
162
+ };
163
+ const onSelect = async (row) => {
164
+ const nextBackStack = [...backStack, { level, selectedIndex: 0 }];
165
+ const meta = getIamMeta(row);
166
+ if (level.kind === "root" && meta?.type === "menu" && meta.kind === "roles") {
167
+ setBackStack(nextBackStack);
168
+ setLevel({ kind: "roles" });
169
+ return { action: "navigate" };
170
+ }
171
+ if (level.kind === "root" && meta?.type === "menu" && meta.kind === "policies") {
172
+ setBackStack(nextBackStack);
173
+ setLevel({ kind: "policies" });
174
+ return { action: "navigate" };
175
+ }
176
+ if (level.kind === "roles" && meta?.type === "role") {
177
+ setBackStack(nextBackStack);
178
+ setLevel({ kind: "role-menu", roleName: row.id });
179
+ return { action: "navigate" };
180
+ }
181
+ if (level.kind === "role-menu" &&
182
+ meta?.type === "menu" &&
183
+ meta.kind === "role-inline-policies") {
184
+ setBackStack(nextBackStack);
185
+ setLevel({ kind: "role-inline-policies", roleName: level.roleName });
186
+ return { action: "navigate" };
187
+ }
188
+ if (level.kind === "role-menu" &&
189
+ meta?.type === "menu" &&
190
+ meta.kind === "role-attached-policies") {
191
+ setBackStack(nextBackStack);
192
+ setLevel({ kind: "role-attached-policies", roleName: level.roleName });
193
+ return { action: "navigate" };
194
+ }
195
+ return { action: "none" };
196
+ };
197
+ const canGoBack = () => getBackStack().length > 0;
198
+ const goBack = () => {
199
+ const frame = getBackStack()[getBackStack().length - 1];
200
+ if (!frame)
201
+ return;
202
+ setBackStack(getBackStack().slice(0, -1));
203
+ setLevel(frame.level);
204
+ };
205
+ const getPath = () => {
206
+ switch (level.kind) {
207
+ case "root":
208
+ return "iam://";
209
+ case "roles":
210
+ return "iam://roles";
211
+ case "role-menu":
212
+ return `iam://roles/${level.roleName}`;
213
+ case "role-inline-policies":
214
+ return `iam://roles/${level.roleName}/inline-policies`;
215
+ case "role-attached-policies":
216
+ return `iam://roles/${level.roleName}/attached-policies`;
217
+ case "policies":
218
+ return "iam://policies";
219
+ }
220
+ };
221
+ const getContextLabel = () => {
222
+ switch (level.kind) {
223
+ case "root":
224
+ return "🔐 IAM Resources";
225
+ case "roles":
226
+ return "👤 IAM Roles";
227
+ case "role-menu":
228
+ return `👤 Role: ${level.roleName}`;
229
+ case "role-inline-policies":
230
+ return `📄 Inline Policies (${level.roleName})`;
231
+ case "role-attached-policies":
232
+ return `📎 Attached Policies (${level.roleName})`;
233
+ case "policies":
234
+ return "📚 Managed Policies";
235
+ }
236
+ };
237
+ // Compose capabilities
238
+ const editCapability = createIamEditCapability(getLevel);
239
+ const detailCapability = createIamDetailCapability(getLevel);
240
+ const yankCapability = createIamYankCapability();
241
+ return {
242
+ id: "iam",
243
+ label: "IAM",
244
+ hudColor: { bg: "magenta", fg: "white" },
245
+ getColumns,
246
+ getRows,
247
+ onSelect,
248
+ canGoBack,
249
+ goBack,
250
+ getPath,
251
+ getContextLabel,
252
+ capabilities: {
253
+ edit: editCapability,
254
+ detail: detailCapability,
255
+ yank: yankCapability,
256
+ },
257
+ };
258
+ }