@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,93 @@
1
+ import { getCellValue } from "../../../types.js";
2
+ import { runAwsJsonAsync } from "../../../utils/aws.js";
3
+ import { safeString, formatDate } from "../utils.js";
4
+ function getIamMeta(row) {
5
+ return row.meta;
6
+ }
7
+ export function createIamDetailCapability(_getLevel) {
8
+ const getDetails = async (row) => {
9
+ const meta = getIamMeta(row);
10
+ if (meta?.type === "role") {
11
+ const roleName = meta.roleName;
12
+ const data = await runAwsJsonAsync([
13
+ "iam",
14
+ "get-role",
15
+ "--role-name",
16
+ roleName,
17
+ ]);
18
+ const role = data.Role;
19
+ return [
20
+ { label: "Name", value: role.RoleName },
21
+ { label: "Type", value: "Role" },
22
+ { label: "ARN", value: role.Arn },
23
+ { label: "Path", value: role.Path ?? "/" },
24
+ { label: "Created", value: formatDate(role.CreateDate) },
25
+ {
26
+ label: "Max Session Duration",
27
+ value: role.MaxSessionDuration ? `${role.MaxSessionDuration} sec` : "-",
28
+ },
29
+ { label: "Description", value: role.Description ?? "-" },
30
+ {
31
+ label: "Last Used",
32
+ value: formatDate(role.RoleLastUsed?.LastUsedDate),
33
+ },
34
+ {
35
+ label: "Last Used Region",
36
+ value: role.RoleLastUsed?.Region ?? "-",
37
+ },
38
+ ];
39
+ }
40
+ if (meta?.type === "inline-policy") {
41
+ const { roleName, policyName } = meta;
42
+ const data = await runAwsJsonAsync([
43
+ "iam",
44
+ "get-role-policy",
45
+ "--role-name",
46
+ roleName,
47
+ "--policy-name",
48
+ policyName,
49
+ ]);
50
+ return [
51
+ { label: "Name", value: policyName },
52
+ { label: "Type", value: "Inline Policy" },
53
+ { label: "Role", value: roleName },
54
+ {
55
+ label: "Statements",
56
+ value: safeString(data.PolicyDocument?.["Statement"]),
57
+ },
58
+ ];
59
+ }
60
+ if (meta?.type === "managed-policy") {
61
+ const { policyArn } = meta;
62
+ const data = await runAwsJsonAsync([
63
+ "iam",
64
+ "get-policy",
65
+ "--policy-arn",
66
+ policyArn,
67
+ ]);
68
+ const p = data.Policy;
69
+ return [
70
+ { label: "Name", value: p.PolicyName },
71
+ { label: "Type", value: "Managed Policy" },
72
+ { label: "ARN", value: p.Arn },
73
+ { label: "Path", value: p.Path ?? "/" },
74
+ { label: "Description", value: p.Description ?? "-" },
75
+ { label: "Default Version", value: p.DefaultVersionId ?? "-" },
76
+ {
77
+ label: "Attachment Count",
78
+ value: p.AttachmentCount !== undefined ? String(p.AttachmentCount) : "-",
79
+ },
80
+ { label: "Created", value: formatDate(p.CreateDate) },
81
+ { label: "Updated", value: formatDate(p.UpdateDate) },
82
+ ];
83
+ }
84
+ const label = getCellValue(row.cells.name) ?? row.id;
85
+ return [
86
+ { label: "Name", value: label },
87
+ { label: "Type", value: safeString(meta?.type ?? "Item") },
88
+ ];
89
+ };
90
+ return {
91
+ getDetails,
92
+ };
93
+ }
@@ -0,0 +1,59 @@
1
+ import { runAwsJsonAsync } from "../../../utils/aws.js";
2
+ import { writeTempJsonFile } from "../utils.js";
3
+ function getIamMeta(row) {
4
+ return row.meta;
5
+ }
6
+ export function createIamEditCapability(_getLevel) {
7
+ const onEdit = async (row) => {
8
+ const meta = getIamMeta(row);
9
+ if (meta?.type === "role") {
10
+ const roleName = meta.roleName;
11
+ const roleData = await runAwsJsonAsync([
12
+ "iam",
13
+ "get-role",
14
+ "--role-name",
15
+ roleName,
16
+ ]);
17
+ const trust = roleData.Role.AssumeRolePolicyDocument ?? {};
18
+ const filePath = await writeTempJsonFile(`${roleName}-trust-policy`, trust);
19
+ return { action: "edit", filePath, metadata: {} };
20
+ }
21
+ if (meta?.type === "inline-policy") {
22
+ const { roleName, policyName } = meta;
23
+ const data = await runAwsJsonAsync([
24
+ "iam",
25
+ "get-role-policy",
26
+ "--role-name",
27
+ roleName,
28
+ "--policy-name",
29
+ policyName,
30
+ ]);
31
+ const filePath = await writeTempJsonFile(`${roleName}-${policyName}-inline-policy`, data.PolicyDocument ?? {});
32
+ return { action: "edit", filePath, metadata: {} };
33
+ }
34
+ if (meta?.type === "managed-policy") {
35
+ const { policyArn, policyName } = meta;
36
+ const policyMeta = await runAwsJsonAsync([
37
+ "iam",
38
+ "get-policy",
39
+ "--policy-arn",
40
+ policyArn,
41
+ ]);
42
+ const versionId = policyMeta.Policy.DefaultVersionId;
43
+ if (!versionId)
44
+ return { action: "none" };
45
+ const policyVersion = await runAwsJsonAsync(["iam", "get-policy-version", "--policy-arn", policyArn, "--version-id", versionId]);
46
+ const filePath = await writeTempJsonFile(`${policyName || "policy"}-${versionId}`, policyVersion.PolicyVersion?.Document ?? {});
47
+ return { action: "edit", filePath, metadata: {} };
48
+ }
49
+ return { action: "none" };
50
+ };
51
+ const uploadFile = async (_filePath, _metadata) => {
52
+ // IAM doesn't support file upload for now
53
+ throw new Error("Upload not supported for IAM");
54
+ };
55
+ return {
56
+ onEdit,
57
+ uploadFile,
58
+ };
59
+ }
@@ -0,0 +1,6 @@
1
+ import { createYankCapability } from "../../../adapters/capabilities/YankCapability.js";
2
+ import { IamRowMetaSchema } from "../schema.js";
3
+ import { iamYankOptions } from "./yankOptions.js";
4
+ export function createIamYankCapability() {
5
+ return createYankCapability(iamYankOptions, IamRowMetaSchema, {});
6
+ }
@@ -0,0 +1,15 @@
1
+ export const iamYankOptions = [
2
+ {
3
+ trigger: { type: "key", char: "a" },
4
+ label: "copy arn",
5
+ feedback: "Copied ARN",
6
+ isRelevant: (row) => row.meta.type === "role" || row.meta.type === "managed-policy",
7
+ resolve: async (row) => {
8
+ if (row.meta.type === "role")
9
+ return row.meta.arn;
10
+ if (row.meta.type === "managed-policy")
11
+ return row.meta.policyArn;
12
+ return null;
13
+ },
14
+ },
15
+ ];
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ export const IamRowMetaSchema = z.discriminatedUnion("type", [
3
+ z.object({ type: z.literal("menu"), kind: z.string(), roleName: z.string().optional() }),
4
+ z.object({ type: z.literal("role"), roleName: z.string(), arn: z.string() }),
5
+ z.object({ type: z.literal("inline-policy"), roleName: z.string(), policyName: z.string() }),
6
+ z.object({ type: z.literal("managed-policy"), policyArn: z.string(), policyName: z.string() }),
7
+ ]);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { tmpdir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdtemp, writeFile } from "node:fs/promises";
4
+ export function safeString(value) {
5
+ if (value === null || value === undefined)
6
+ return "-";
7
+ if (typeof value === "string")
8
+ return value;
9
+ return JSON.stringify(value);
10
+ }
11
+ export function formatDate(value) {
12
+ if (!value)
13
+ return "-";
14
+ return value.replace("T", " ").replace("Z", "");
15
+ }
16
+ export async function writeTempJsonFile(prefix, payload) {
17
+ const dir = await mkdtemp(join(tmpdir(), "a9s-iam-"));
18
+ const filePath = join(dir, `${prefix}.json`);
19
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
20
+ return filePath;
21
+ }
@@ -0,0 +1,22 @@
1
+ import { textCell } from "../../types.js";
2
+ export function createRoute53ServiceAdapter() {
3
+ const getColumns = () => [{ key: "name", label: "Name" }];
4
+ const getRows = async () => {
5
+ return [{ id: "stub", cells: { name: textCell("Route53 not yet implemented") }, meta: {} }];
6
+ };
7
+ const onSelect = async (_row) => {
8
+ return { action: "none" };
9
+ };
10
+ return {
11
+ id: "route53",
12
+ label: "Route53",
13
+ hudColor: { bg: "blue", fg: "white" },
14
+ getColumns,
15
+ getRows,
16
+ onSelect,
17
+ canGoBack: () => false,
18
+ goBack: () => { },
19
+ getPath: () => "/",
20
+ getContextLabel: () => "🌐 DNS Records",
21
+ };
22
+ }
@@ -0,0 +1,154 @@
1
+ import { textCell } from "../../types.js";
2
+ import { createS3Client } from "./client.js";
3
+ import { fetchBuckets, fetchObjects, downloadObject } from "./fetcher.js";
4
+ import { atom } from "jotai";
5
+ import { getDefaultStore } from "jotai";
6
+ import { formatSize } from "./utils.js";
7
+ import { createS3EditCapability } from "./capabilities/editCapability.js";
8
+ import { createS3DetailCapability } from "./capabilities/detailCapability.js";
9
+ import { createS3YankCapability } from "./capabilities/yankCapability.js";
10
+ import { createS3ActionCapability } from "./capabilities/actionCapability.js";
11
+ export const s3LevelAtom = atom({ kind: "buckets" });
12
+ export const s3BackStackAtom = atom([]);
13
+ export function createS3ServiceAdapter(endpointUrl, region) {
14
+ const store = getDefaultStore();
15
+ const client = createS3Client(endpointUrl, region);
16
+ // Getters and setters for level/backStack from atoms
17
+ const getLevel = () => store.get(s3LevelAtom);
18
+ const setLevel = (level) => store.set(s3LevelAtom, level);
19
+ const getBackStack = () => store.get(s3BackStackAtom);
20
+ const setBackStack = (stack) => store.set(s3BackStackAtom, stack);
21
+ const getColumns = () => {
22
+ const level = getLevel();
23
+ if (level.kind === "buckets") {
24
+ return [
25
+ { key: "name", label: "Name" },
26
+ { key: "type", label: "Type", width: 10 },
27
+ { key: "creationDate", label: "Creation Date", width: 22 },
28
+ ];
29
+ }
30
+ return [
31
+ { key: "name", label: "Name" },
32
+ { key: "type", label: "Type", width: 10 },
33
+ { key: "size", label: "Size", width: 12 },
34
+ { key: "lastModified", label: "Last Modified", width: 22 },
35
+ ];
36
+ };
37
+ const getRows = async () => {
38
+ const level = getLevel();
39
+ if (level.kind === "buckets") {
40
+ const buckets = await fetchBuckets(client);
41
+ return buckets.map((b) => ({
42
+ id: b.name,
43
+ cells: {
44
+ name: textCell(b.name),
45
+ type: textCell("Bucket"),
46
+ creationDate: textCell(b.creationDate ? b.creationDate.toISOString().replace("T", " ").slice(0, 19) : "-"),
47
+ },
48
+ meta: { type: "bucket" },
49
+ }));
50
+ }
51
+ const { bucket, prefix } = level;
52
+ const objects = await fetchObjects(client, bucket, prefix);
53
+ return objects.map((obj) => {
54
+ const displayKey = obj.key.slice(prefix.length);
55
+ return {
56
+ id: obj.key,
57
+ cells: {
58
+ name: textCell(displayKey),
59
+ type: textCell(obj.isFolder ? "Folder" : "File"),
60
+ size: textCell(obj.isFolder ? "" : formatSize(obj.size)),
61
+ lastModified: textCell(obj.lastModified ? obj.lastModified.toISOString().replace("T", " ").slice(0, 19) : ""),
62
+ },
63
+ meta: { type: obj.isFolder ? "folder" : "object", key: obj.key },
64
+ };
65
+ });
66
+ };
67
+ const onSelect = async (row) => {
68
+ const level = getLevel();
69
+ const backStack = getBackStack();
70
+ if (level.kind === "buckets") {
71
+ const newStack = [...backStack, { level: level, selectedIndex: 0 }];
72
+ setBackStack(newStack);
73
+ setLevel({ kind: "objects", bucket: row.id, prefix: "" });
74
+ return { action: "navigate" };
75
+ }
76
+ const type = row.meta?.type;
77
+ if (type === "folder") {
78
+ const newStack = [...backStack, { level: level, selectedIndex: 0 }];
79
+ setBackStack(newStack);
80
+ setLevel({
81
+ kind: "objects",
82
+ bucket: level.bucket,
83
+ prefix: row.meta?.key,
84
+ });
85
+ return { action: "navigate" };
86
+ }
87
+ if (type === "object") {
88
+ const filePath = await downloadObject(client, level.bucket, row.meta?.key);
89
+ return {
90
+ action: "edit",
91
+ filePath,
92
+ metadata: {
93
+ bucket: level.bucket,
94
+ key: row.meta?.key,
95
+ },
96
+ };
97
+ }
98
+ return { action: "none" };
99
+ };
100
+ const canGoBack = () => getBackStack().length > 0;
101
+ const goBack = () => {
102
+ const backStack = getBackStack();
103
+ if (backStack.length > 0) {
104
+ const newStack = backStack.slice(0, -1);
105
+ const frame = backStack[backStack.length - 1];
106
+ setBackStack(newStack);
107
+ setLevel(frame.level);
108
+ }
109
+ };
110
+ const getPath = () => {
111
+ const level = getLevel();
112
+ if (level.kind === "buckets")
113
+ return "s3://";
114
+ const { bucket, prefix } = level;
115
+ return `s3://${bucket}/${prefix}`;
116
+ };
117
+ const getContextLabel = () => {
118
+ const level = getLevel();
119
+ if (level.kind === "buckets")
120
+ return "🪣 Buckets";
121
+ const prefix = level.prefix ? ` › ${level.prefix}` : "";
122
+ return `📦 ${level.bucket}${prefix}`;
123
+ };
124
+ // Compose capabilities
125
+ const detailCapability = createS3DetailCapability(client, getLevel);
126
+ const editCapability = createS3EditCapability(client, getLevel);
127
+ const yankCapability = createS3YankCapability({
128
+ getLevel,
129
+ getDetails: detailCapability.getDetails,
130
+ });
131
+ const actionCapability = createS3ActionCapability(client, getLevel, getBackStack, setBackStack, setLevel);
132
+ return {
133
+ id: "s3",
134
+ label: "S3",
135
+ hudColor: { bg: "red", fg: "white" },
136
+ getColumns,
137
+ getRows,
138
+ onSelect,
139
+ canGoBack,
140
+ goBack,
141
+ getPath,
142
+ getContextLabel,
143
+ reset() {
144
+ setLevel({ kind: "buckets" });
145
+ setBackStack([]);
146
+ },
147
+ capabilities: {
148
+ edit: editCapability,
149
+ detail: detailCapability,
150
+ yank: yankCapability,
151
+ actions: actionCapability,
152
+ },
153
+ };
154
+ }
@@ -0,0 +1,172 @@
1
+ import { downloadObjectToPath } from "../fetcher.js";
2
+ import { toParentPrefix } from "../utils.js";
3
+ function toErrorMessage(error) {
4
+ if (error instanceof Error)
5
+ return error.message;
6
+ return String(error);
7
+ }
8
+ function hasCode(error, code) {
9
+ return Boolean(typeof error === "object" &&
10
+ error !== null &&
11
+ "code" in error &&
12
+ error.code === code);
13
+ }
14
+ export function createS3ActionCapability(client, getLevel, getBackStack, setBackStack, setLevel) {
15
+ const getKeybindings = () => [
16
+ {
17
+ trigger: { type: "key", char: "f" },
18
+ actionId: "fetch",
19
+ label: "Fetch / download selected file",
20
+ shortLabel: "download",
21
+ scope: "navigate",
22
+ adapterId: "s3",
23
+ },
24
+ {
25
+ trigger: { type: "chord", keys: ["g", "p"] },
26
+ actionId: "jump-to-path",
27
+ label: "Go to path jump prompt",
28
+ shortLabel: "jump path",
29
+ scope: "navigate",
30
+ adapterId: "s3",
31
+ },
32
+ ];
33
+ const executeAction = async (actionId, context) => {
34
+ const level = getLevel();
35
+ if (actionId === "fetch") {
36
+ // Start fetch flow: prompt for path
37
+ if (!context.row)
38
+ return { type: "error", message: "No row selected" };
39
+ if (level.kind !== "objects") {
40
+ return { type: "error", message: "Fetch is only available in object view" };
41
+ }
42
+ const type = context.row.meta?.type;
43
+ if (type !== "object") {
44
+ return { type: "error", message: "Fetch is only available for objects" };
45
+ }
46
+ return {
47
+ type: "prompt",
48
+ label: "Fetch to:",
49
+ defaultValue: "",
50
+ nextActionId: "fetch:submit",
51
+ };
52
+ }
53
+ if (actionId === "fetch:submit") {
54
+ // User entered a path; try to download
55
+ if (!context.row || !context.data?.path) {
56
+ return { type: "error", message: "Invalid path" };
57
+ }
58
+ if (level.kind !== "objects") {
59
+ return { type: "error", message: "Fetch is only available in object view" };
60
+ }
61
+ const destinationPath = context.data.path;
62
+ try {
63
+ const finalPath = await downloadObjectToPath(client, level.bucket, context.row.meta?.key, destinationPath, false);
64
+ return {
65
+ type: "feedback",
66
+ message: `Downloaded to ${finalPath}`,
67
+ };
68
+ }
69
+ catch (err) {
70
+ // Check for file exists error
71
+ if (hasCode(err, "EEXIST")) {
72
+ return {
73
+ type: "confirm",
74
+ message: `File exists. Overwrite ${destinationPath}?`,
75
+ nextActionId: "fetch:overwrite",
76
+ data: { path: destinationPath },
77
+ };
78
+ }
79
+ return {
80
+ type: "error",
81
+ message: `Fetch failed: ${toErrorMessage(err)}`,
82
+ };
83
+ }
84
+ }
85
+ if (actionId === "fetch:overwrite") {
86
+ // User confirmed overwrite
87
+ if (!context.row || !context.data?.path) {
88
+ return { type: "error", message: "Invalid path" };
89
+ }
90
+ if (level.kind !== "objects") {
91
+ return { type: "error", message: "Fetch is only available in object view" };
92
+ }
93
+ const destinationPath = context.data.path;
94
+ try {
95
+ const finalPath = await downloadObjectToPath(client, level.bucket, context.row.meta?.key, destinationPath, true);
96
+ return {
97
+ type: "feedback",
98
+ message: `Downloaded to ${finalPath}`,
99
+ };
100
+ }
101
+ catch (err) {
102
+ return {
103
+ type: "error",
104
+ message: `Fetch failed: ${toErrorMessage(err)}`,
105
+ };
106
+ }
107
+ }
108
+ if (actionId === "jump-to-path") {
109
+ // Start jump flow: prompt for target
110
+ return {
111
+ type: "prompt",
112
+ label: "Jump to (s3://bucket/key or /key):",
113
+ defaultValue: "",
114
+ nextActionId: "jump-to-path:submit",
115
+ };
116
+ }
117
+ if (actionId === "jump-to-path:submit") {
118
+ // User entered a jump target
119
+ if (!context.data?.path) {
120
+ return { type: "error", message: "Invalid jump target" };
121
+ }
122
+ const raw = context.data.path.trim();
123
+ if (!raw)
124
+ return { type: "error", message: "Jump target is empty" };
125
+ const current = level;
126
+ let nextBucket = "";
127
+ let nextPrefix = "";
128
+ if (raw.startsWith("s3://")) {
129
+ const withoutScheme = raw.slice("s3://".length);
130
+ const slashIdx = withoutScheme.indexOf("/");
131
+ if (slashIdx === -1) {
132
+ nextBucket = withoutScheme;
133
+ nextPrefix = "";
134
+ }
135
+ else {
136
+ nextBucket = withoutScheme.slice(0, slashIdx);
137
+ const keySpec = withoutScheme.slice(slashIdx + 1);
138
+ nextPrefix = toParentPrefix(keySpec);
139
+ }
140
+ if (!nextBucket)
141
+ return { type: "error", message: "Invalid S3 URI: missing bucket" };
142
+ }
143
+ else {
144
+ if (level.kind !== "objects") {
145
+ return {
146
+ type: "error",
147
+ message: 'From bucket list, jump must use "s3://bucket/..."',
148
+ };
149
+ }
150
+ nextBucket = level.bucket;
151
+ const localSpec = raw.startsWith("/") ? raw.slice(1) : raw;
152
+ nextPrefix = toParentPrefix(localSpec);
153
+ }
154
+ const backStack = getBackStack();
155
+ // Actually perform the navigation by updating the level
156
+ setBackStack([...backStack, { level: current, selectedIndex: 0 }]);
157
+ setLevel({ kind: "objects", bucket: nextBucket, prefix: nextPrefix });
158
+ return {
159
+ type: "multi",
160
+ effects: [
161
+ { type: "refresh" },
162
+ { type: "feedback", message: `Jumped to s3://${nextBucket}/${nextPrefix}` },
163
+ ],
164
+ };
165
+ }
166
+ return { type: "error", message: `Unknown action: ${actionId}` };
167
+ };
168
+ return {
169
+ getKeybindings,
170
+ executeAction,
171
+ };
172
+ }
@@ -0,0 +1,115 @@
1
+ import { GetBucketAclCommand, GetBucketEncryptionCommand, GetBucketLocationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, ListObjectsV2Command, } from "@aws-sdk/client-s3";
2
+ import { formatSize } from "../utils.js";
3
+ import { headObject } from "../fetcher.js";
4
+ export function createS3DetailCapability(client, getLevel) {
5
+ const getDetails = async (row) => {
6
+ const cells = row.cells;
7
+ const type = row.meta?.type;
8
+ if (type === "bucket") {
9
+ const bucket = cells.name.displayName ?? row.id;
10
+ const fields = [
11
+ { label: "Name", value: cells.name.displayName ?? "-" },
12
+ { label: "Type", value: "Bucket" },
13
+ { label: "Created", value: cells.creationDate.displayName ?? "-" },
14
+ ];
15
+ const [locationResult, versioningResult, encryptionResult, taggingResult, aclResult, sampleObjectsResult,] = await Promise.allSettled([
16
+ client.send(new GetBucketLocationCommand({ Bucket: bucket })),
17
+ client.send(new GetBucketVersioningCommand({ Bucket: bucket })),
18
+ client.send(new GetBucketEncryptionCommand({ Bucket: bucket })),
19
+ client.send(new GetBucketTaggingCommand({ Bucket: bucket })),
20
+ client.send(new GetBucketAclCommand({ Bucket: bucket })),
21
+ client.send(new ListObjectsV2Command({
22
+ Bucket: bucket,
23
+ MaxKeys: 1000,
24
+ })),
25
+ ]);
26
+ if (locationResult.status === "fulfilled") {
27
+ const raw = locationResult.value.LocationConstraint;
28
+ const region = raw ?? "us-east-1";
29
+ fields.push({ label: "Region", value: region });
30
+ }
31
+ if (versioningResult.status === "fulfilled") {
32
+ fields.push({
33
+ label: "Versioning",
34
+ value: versioningResult.value.Status ?? "Disabled",
35
+ });
36
+ }
37
+ if (encryptionResult.status === "fulfilled") {
38
+ const rule = encryptionResult.value.ServerSideEncryptionConfiguration?.Rules?.[0];
39
+ const algorithm = rule?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm;
40
+ fields.push({
41
+ label: "Default Encryption",
42
+ value: algorithm ?? "Enabled (custom)",
43
+ });
44
+ }
45
+ else {
46
+ fields.push({ label: "Default Encryption", value: "Not configured" });
47
+ }
48
+ if (taggingResult.status === "fulfilled") {
49
+ const tags = taggingResult.value.TagSet ?? [];
50
+ fields.push({ label: "Tags", value: String(tags.length) });
51
+ for (const tag of tags.slice(0, 5)) {
52
+ fields.push({
53
+ label: `tag:${tag.Key ?? "-"}`,
54
+ value: tag.Value ?? "-",
55
+ });
56
+ }
57
+ if (tags.length > 5) {
58
+ fields.push({
59
+ label: "tag:...",
60
+ value: `+${tags.length - 5} more`,
61
+ });
62
+ }
63
+ }
64
+ if (aclResult.status === "fulfilled") {
65
+ const grants = aclResult.value.Grants ?? [];
66
+ fields.push({ label: "ACL Grants", value: String(grants.length) });
67
+ }
68
+ if (sampleObjectsResult.status === "fulfilled") {
69
+ const objects = sampleObjectsResult.value.Contents ?? [];
70
+ const objectCount = objects.length;
71
+ const totalBytes = objects.reduce((sum, obj) => sum + (obj.Size ?? 0), 0);
72
+ fields.push({ label: "Objects (sample)", value: String(objectCount) });
73
+ fields.push({ label: "Size (sample)", value: formatSize(totalBytes) });
74
+ if (sampleObjectsResult.value.IsTruncated) {
75
+ fields.push({ label: "Sample Note", value: "First 1000 objects only" });
76
+ }
77
+ }
78
+ return fields;
79
+ }
80
+ if (type === "folder") {
81
+ return [
82
+ { label: "Name", value: row.cells.name.displayName ?? "-" },
83
+ { label: "Type", value: "Folder" },
84
+ { label: "Key", value: row.meta?.key ?? "-" },
85
+ ];
86
+ }
87
+ const level = getLevel();
88
+ if (level.kind !== "objects")
89
+ return [];
90
+ const key = row.meta?.key;
91
+ const meta = await headObject(client, level.bucket, key);
92
+ const fields = [
93
+ { label: "Name", value: row.cells.name.displayName ?? "-" },
94
+ { label: "Key", value: key },
95
+ { label: "Size", value: row.cells.size.displayName ?? "-" },
96
+ { label: "Content-Type", value: meta.contentType ?? "-" },
97
+ { label: "ETag", value: meta.etag ?? "-" },
98
+ { label: "Last Modified", value: meta.lastModified ?? "-" },
99
+ { label: "Storage Class", value: meta.storageClass ?? "-" },
100
+ ];
101
+ if (meta.versionId)
102
+ fields.push({ label: "Version ID", value: meta.versionId });
103
+ if (meta.serverSideEncryption)
104
+ fields.push({ label: "SSE", value: meta.serverSideEncryption });
105
+ for (const [k, v] of Object.entries(meta)) {
106
+ if (k.startsWith("meta:") && v) {
107
+ fields.push({ label: k.slice(5), value: v });
108
+ }
109
+ }
110
+ return fields;
111
+ };
112
+ return {
113
+ getDetails,
114
+ };
115
+ }