@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.
- package/README.md +167 -2
- package/dist/scripts/seed.js +310 -0
- package/dist/src/App.js +476 -0
- package/dist/src/adapters/ServiceAdapter.js +1 -0
- package/dist/src/adapters/capabilities/ActionCapability.js +1 -0
- package/dist/src/adapters/capabilities/DetailCapability.js +1 -0
- package/dist/src/adapters/capabilities/EditCapability.js +1 -0
- package/dist/src/adapters/capabilities/YankCapability.js +42 -0
- package/dist/src/adapters/capabilities/YankCapability.test.js +29 -0
- package/dist/src/components/AdvancedTextInput.js +200 -0
- package/dist/src/components/AdvancedTextInput.test.js +190 -0
- package/dist/src/components/AutocompleteInput.js +29 -0
- package/dist/src/components/DetailPanel.js +12 -0
- package/dist/src/components/DiffViewer.js +17 -0
- package/dist/src/components/ErrorStatePanel.js +5 -0
- package/dist/src/components/HUD.js +31 -0
- package/dist/src/components/HelpPanel.js +33 -0
- package/dist/src/components/ModeBar.js +43 -0
- package/dist/src/components/Table/index.js +109 -0
- package/dist/src/components/Table/widths.js +19 -0
- package/dist/src/components/TableSkeleton.js +25 -0
- package/dist/src/components/YankHelpPanel.js +43 -0
- package/dist/src/constants/commands.js +15 -0
- package/dist/src/constants/keybindings.js +530 -0
- package/dist/src/constants/keys.js +37 -0
- package/dist/src/features/AppMainView.integration.test.js +133 -0
- package/dist/src/features/AppMainView.js +95 -0
- package/dist/src/hooks/inputEvents.js +1 -0
- package/dist/src/hooks/mainInputScopes.js +68 -0
- package/dist/src/hooks/mainInputScopes.test.js +24 -0
- package/dist/src/hooks/useActionController.js +78 -0
- package/dist/src/hooks/useAppController.js +102 -0
- package/dist/src/hooks/useAppController.test.js +54 -0
- package/dist/src/hooks/useAppData.js +48 -0
- package/dist/src/hooks/useAwsContext.js +77 -0
- package/dist/src/hooks/useAwsProfiles.js +53 -0
- package/dist/src/hooks/useAwsRegions.js +105 -0
- package/dist/src/hooks/useCommandRouter.js +56 -0
- package/dist/src/hooks/useCommandRouter.test.js +27 -0
- package/dist/src/hooks/useDetailController.js +57 -0
- package/dist/src/hooks/useDetailController.test.js +32 -0
- package/dist/src/hooks/useHelpPanel.js +65 -0
- package/dist/src/hooks/useHierarchyState.js +39 -0
- package/dist/src/hooks/useInputEventProcessor.js +450 -0
- package/dist/src/hooks/useInputEventProcessor.test.js +174 -0
- package/dist/src/hooks/useKeyChord.js +83 -0
- package/dist/src/hooks/useMainInput.js +18 -0
- package/dist/src/hooks/useNavigation.js +47 -0
- package/dist/src/hooks/usePendingAction.js +8 -0
- package/dist/src/hooks/usePickerManager.js +130 -0
- package/dist/src/hooks/usePickerState.js +47 -0
- package/dist/src/hooks/usePickerTable.js +20 -0
- package/dist/src/hooks/useServiceView.js +226 -0
- package/dist/src/hooks/useUiHints.js +60 -0
- package/dist/src/hooks/useYankMode.js +24 -0
- package/dist/src/hooks/yankHeaderMarkers.js +23 -0
- package/dist/src/hooks/yankHeaderMarkers.test.js +49 -0
- package/dist/src/index.js +30 -0
- package/dist/src/services.js +12 -0
- package/dist/src/state/atoms.js +27 -0
- package/dist/src/types.js +12 -0
- package/dist/src/utils/aws.js +39 -0
- package/dist/src/utils/debugLogger.js +34 -0
- package/dist/src/utils/secretDisplay.js +45 -0
- package/dist/src/utils/withFullscreen.js +38 -0
- package/dist/src/views/dynamodb/adapter.js +22 -0
- package/dist/src/views/iam/adapter.js +258 -0
- package/dist/src/views/iam/capabilities/detailCapability.js +93 -0
- package/dist/src/views/iam/capabilities/editCapability.js +59 -0
- package/dist/src/views/iam/capabilities/yankCapability.js +6 -0
- package/dist/src/views/iam/capabilities/yankOptions.js +15 -0
- package/dist/src/views/iam/schema.js +7 -0
- package/dist/src/views/iam/types.js +1 -0
- package/dist/src/views/iam/utils.js +21 -0
- package/dist/src/views/route53/adapter.js +22 -0
- package/dist/src/views/s3/adapter.js +154 -0
- package/dist/src/views/s3/capabilities/actionCapability.js +172 -0
- package/dist/src/views/s3/capabilities/detailCapability.js +115 -0
- package/dist/src/views/s3/capabilities/editCapability.js +35 -0
- package/dist/src/views/s3/capabilities/yankCapability.js +6 -0
- package/dist/src/views/s3/capabilities/yankOptions.js +55 -0
- package/dist/src/views/s3/client.js +12 -0
- package/dist/src/views/s3/fetcher.js +86 -0
- package/dist/src/views/s3/schema.js +6 -0
- package/dist/src/views/s3/utils.js +19 -0
- package/dist/src/views/secretsmanager/adapter.js +188 -0
- package/dist/src/views/secretsmanager/capabilities/actionCapability.js +193 -0
- package/dist/src/views/secretsmanager/capabilities/detailCapability.js +46 -0
- package/dist/src/views/secretsmanager/capabilities/editCapability.js +116 -0
- package/dist/src/views/secretsmanager/capabilities/yankCapability.js +7 -0
- package/dist/src/views/secretsmanager/capabilities/yankOptions.js +68 -0
- package/dist/src/views/secretsmanager/schema.js +28 -0
- package/dist/src/views/secretsmanager/types.js +1 -0
- package/package.json +68 -5
- package/index.js +0 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { downloadObject } from "../fetcher.js";
|
|
4
|
+
export function createS3EditCapability(client, getLevel) {
|
|
5
|
+
const onEdit = async (row) => {
|
|
6
|
+
const level = getLevel();
|
|
7
|
+
const type = row.meta?.type;
|
|
8
|
+
if (level.kind !== "objects" || type !== "object") {
|
|
9
|
+
return { action: "none" };
|
|
10
|
+
}
|
|
11
|
+
const filePath = await downloadObject(client, level.bucket, row.meta?.key);
|
|
12
|
+
return {
|
|
13
|
+
action: "edit",
|
|
14
|
+
filePath,
|
|
15
|
+
metadata: {
|
|
16
|
+
bucket: level.bucket,
|
|
17
|
+
key: row.meta?.key,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
const uploadFile = async (filePath, metadata) => {
|
|
22
|
+
const bucket = metadata.bucket;
|
|
23
|
+
const key = metadata.key;
|
|
24
|
+
const fileContent = await readFile(filePath);
|
|
25
|
+
await client.send(new PutObjectCommand({
|
|
26
|
+
Bucket: bucket,
|
|
27
|
+
Key: key,
|
|
28
|
+
Body: fileContent,
|
|
29
|
+
}));
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
onEdit,
|
|
33
|
+
uploadFile,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createYankCapability } from "../../../adapters/capabilities/YankCapability.js";
|
|
2
|
+
import { S3RowMetaSchema } from "../schema.js";
|
|
3
|
+
import { s3YankOptions } from "./yankOptions.js";
|
|
4
|
+
export function createS3YankCapability(ctx) {
|
|
5
|
+
return createYankCapability(s3YankOptions, S3RowMetaSchema, ctx);
|
|
6
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const isS3Navigable = (row) => row.meta.type === "bucket" || row.meta.type === "folder" || row.meta.type === "object";
|
|
2
|
+
export const s3YankOptions = [
|
|
3
|
+
{
|
|
4
|
+
trigger: { type: "key", char: "k" },
|
|
5
|
+
label: "copy key/path",
|
|
6
|
+
feedback: "Copied Key",
|
|
7
|
+
headerKey: "name",
|
|
8
|
+
isRelevant: isS3Navigable,
|
|
9
|
+
resolve: async (row, ctx) => {
|
|
10
|
+
const level = ctx.getLevel();
|
|
11
|
+
const bucket = level.kind === "objects" ? level.bucket : row.id;
|
|
12
|
+
if (row.meta.type === "bucket")
|
|
13
|
+
return `s3://${row.id}`;
|
|
14
|
+
return `s3://${bucket}/${row.meta.key}`;
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
trigger: { type: "key", char: "a" },
|
|
19
|
+
label: "copy arn",
|
|
20
|
+
feedback: "Copied ARN",
|
|
21
|
+
headerKey: "name",
|
|
22
|
+
isRelevant: isS3Navigable,
|
|
23
|
+
resolve: async (row, ctx) => {
|
|
24
|
+
const level = ctx.getLevel();
|
|
25
|
+
const bucket = level.kind === "objects" ? level.bucket : row.id;
|
|
26
|
+
if (row.meta.type === "bucket")
|
|
27
|
+
return `arn:aws:s3:::${row.id}`;
|
|
28
|
+
return `arn:aws:s3:::${bucket}/${row.meta.key}`;
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
trigger: { type: "key", char: "e" },
|
|
33
|
+
label: "copy etag",
|
|
34
|
+
feedback: "Copied ETag",
|
|
35
|
+
headerKey: "name",
|
|
36
|
+
isRelevant: (row) => row.meta.type === "object",
|
|
37
|
+
resolve: async (row, ctx) => {
|
|
38
|
+
const fields = await ctx.getDetails(row);
|
|
39
|
+
const etag = fields.find((f) => f.label === "ETag")?.value ?? "";
|
|
40
|
+
return etag && etag !== "-" ? etag : null;
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
trigger: { type: "key", char: "d" },
|
|
45
|
+
label: "copy last-modified",
|
|
46
|
+
feedback: "Copied Last Modified",
|
|
47
|
+
headerKey: "lastModified",
|
|
48
|
+
isRelevant: (row) => row.meta.type === "object",
|
|
49
|
+
resolve: async (row, ctx) => {
|
|
50
|
+
const fields = await ctx.getDetails(row);
|
|
51
|
+
const lastModified = fields.find((f) => f.label === "Last Modified")?.value ?? "";
|
|
52
|
+
return lastModified && lastModified !== "-" ? lastModified : null;
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
export function createS3Client(endpointUrl, region) {
|
|
3
|
+
return new S3Client({
|
|
4
|
+
...(region ? { region } : {}),
|
|
5
|
+
...(endpointUrl
|
|
6
|
+
? {
|
|
7
|
+
endpoint: endpointUrl,
|
|
8
|
+
forcePathStyle: true,
|
|
9
|
+
}
|
|
10
|
+
: {}),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ListBucketsCommand, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand, } from "@aws-sdk/client-s3";
|
|
2
|
+
import { mkdir, stat, writeFile } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { basename, join, resolve } from "path";
|
|
5
|
+
export async function fetchBuckets(client) {
|
|
6
|
+
const response = await client.send(new ListBucketsCommand({}));
|
|
7
|
+
return (response.Buckets ?? []).map((b) => ({
|
|
8
|
+
name: b.Name ?? "",
|
|
9
|
+
creationDate: b.CreationDate,
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
export async function fetchObjects(client, bucket, prefix) {
|
|
13
|
+
const response = await client.send(new ListObjectsV2Command({
|
|
14
|
+
Bucket: bucket,
|
|
15
|
+
Prefix: prefix || undefined,
|
|
16
|
+
Delimiter: "/",
|
|
17
|
+
}));
|
|
18
|
+
const folders = (response.CommonPrefixes ?? []).map((cp) => ({
|
|
19
|
+
key: cp.Prefix ?? "",
|
|
20
|
+
size: undefined,
|
|
21
|
+
lastModified: undefined,
|
|
22
|
+
isFolder: true,
|
|
23
|
+
}));
|
|
24
|
+
const files = (response.Contents ?? [])
|
|
25
|
+
.filter((obj) => obj.Key !== prefix) // skip the "directory" marker itself
|
|
26
|
+
.map((obj) => ({
|
|
27
|
+
key: obj.Key ?? "",
|
|
28
|
+
size: obj.Size,
|
|
29
|
+
lastModified: obj.LastModified ?? undefined,
|
|
30
|
+
isFolder: false,
|
|
31
|
+
}));
|
|
32
|
+
return [...folders, ...files];
|
|
33
|
+
}
|
|
34
|
+
export async function downloadObject(client, bucket, key) {
|
|
35
|
+
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
36
|
+
if (!response.Body)
|
|
37
|
+
throw new Error("Empty response body");
|
|
38
|
+
const bytes = await response.Body.transformToByteArray();
|
|
39
|
+
const safeName = key.replace(/\//g, "_");
|
|
40
|
+
const filePath = join(tmpdir(), `a9s_${safeName}`);
|
|
41
|
+
await writeFile(filePath, bytes);
|
|
42
|
+
return filePath;
|
|
43
|
+
}
|
|
44
|
+
export async function downloadObjectToPath(client, bucket, key, destinationPath, overwrite = false) {
|
|
45
|
+
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
46
|
+
if (!response.Body)
|
|
47
|
+
throw new Error("Empty response body");
|
|
48
|
+
const bytes = await response.Body.transformToByteArray();
|
|
49
|
+
const rawTarget = destinationPath.trim();
|
|
50
|
+
const absTarget = resolve(rawTarget);
|
|
51
|
+
const fileName = basename(key);
|
|
52
|
+
const endsWithSlash = rawTarget.endsWith("/") || rawTarget.endsWith("\\");
|
|
53
|
+
let finalPath = absTarget;
|
|
54
|
+
if (endsWithSlash) {
|
|
55
|
+
await mkdir(absTarget, { recursive: true });
|
|
56
|
+
finalPath = join(absTarget, fileName);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const targetStat = await stat(absTarget).catch(() => null);
|
|
60
|
+
if (targetStat?.isDirectory()) {
|
|
61
|
+
finalPath = join(absTarget, fileName);
|
|
62
|
+
}
|
|
63
|
+
else if (targetStat?.isFile()) {
|
|
64
|
+
finalPath = absTarget;
|
|
65
|
+
if (!overwrite) {
|
|
66
|
+
throw new Error(`EEXIST_FILE:${finalPath}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await mkdir(resolve(finalPath, ".."), { recursive: true });
|
|
71
|
+
await writeFile(finalPath, bytes);
|
|
72
|
+
return finalPath;
|
|
73
|
+
}
|
|
74
|
+
export async function headObject(client, bucket, key) {
|
|
75
|
+
const res = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
76
|
+
return {
|
|
77
|
+
contentType: res.ContentType,
|
|
78
|
+
contentLength: res.ContentLength?.toString(),
|
|
79
|
+
etag: res.ETag?.replace(/"/g, ""),
|
|
80
|
+
lastModified: res.LastModified?.toISOString().replace("T", " ").slice(0, 19),
|
|
81
|
+
storageClass: res.StorageClass ?? "STANDARD",
|
|
82
|
+
versionId: res.VersionId,
|
|
83
|
+
serverSideEncryption: res.ServerSideEncryption,
|
|
84
|
+
...Object.fromEntries(Object.entries(res.Metadata ?? {}).map(([k, v]) => [`meta:${k}`, v])),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function formatSize(bytes) {
|
|
2
|
+
if (bytes === undefined)
|
|
3
|
+
return "-";
|
|
4
|
+
if (bytes < 1024)
|
|
5
|
+
return `${bytes} B`;
|
|
6
|
+
if (bytes < 1024 * 1024)
|
|
7
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
8
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
9
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
10
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
11
|
+
}
|
|
12
|
+
export function toParentPrefix(spec) {
|
|
13
|
+
if (!spec)
|
|
14
|
+
return "";
|
|
15
|
+
if (spec.endsWith("/"))
|
|
16
|
+
return spec;
|
|
17
|
+
const idx = spec.lastIndexOf("/");
|
|
18
|
+
return idx >= 0 ? spec.slice(0, idx + 1) : "";
|
|
19
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { textCell, secretCell } from "../../types.js";
|
|
2
|
+
import { runAwsJsonAsync } from "../../utils/aws.js";
|
|
3
|
+
import { atom } from "jotai";
|
|
4
|
+
import { getDefaultStore } from "jotai";
|
|
5
|
+
import { revealSecretsAtom } from "../../state/atoms.js";
|
|
6
|
+
import { createSecretsManagerDetailCapability } from "./capabilities/detailCapability.js";
|
|
7
|
+
import { createSecretsManagerYankCapability } from "./capabilities/yankCapability.js";
|
|
8
|
+
import { createSecretsManagerActionCapability } from "./capabilities/actionCapability.js";
|
|
9
|
+
import { createSecretsManagerEditCapability } from "./capabilities/editCapability.js";
|
|
10
|
+
export const secretLevelAtom = atom({ kind: "secrets" });
|
|
11
|
+
export const secretBackStackAtom = atom([]);
|
|
12
|
+
function tryParseFields(secretString) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(secretString);
|
|
15
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
16
|
+
return Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Not JSON
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
export function createSecretsManagerServiceAdapter(endpointUrl, region) {
|
|
25
|
+
const store = getDefaultStore();
|
|
26
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
27
|
+
// Getters and setters for level/backStack from atoms
|
|
28
|
+
const getLevel = () => store.get(secretLevelAtom);
|
|
29
|
+
const setLevel = (level) => store.set(secretLevelAtom, level);
|
|
30
|
+
const getBackStack = () => store.get(secretBackStackAtom);
|
|
31
|
+
const setBackStack = (stack) => store.set(secretBackStackAtom, stack);
|
|
32
|
+
const setReveal = (reveal) => store.set(revealSecretsAtom, reveal);
|
|
33
|
+
const getColumns = () => {
|
|
34
|
+
const level = getLevel();
|
|
35
|
+
if (level.kind === "secrets") {
|
|
36
|
+
return [
|
|
37
|
+
{ key: "name", label: "Name" },
|
|
38
|
+
{ key: "description", label: "Description", width: 30 },
|
|
39
|
+
{ key: "lastChanged", label: "Last Changed", width: 22 },
|
|
40
|
+
{ key: "rotation", label: "Rotation", width: 10 },
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
// secret-fields level
|
|
44
|
+
return [
|
|
45
|
+
{ key: "key", label: "Key" },
|
|
46
|
+
{ key: "value", label: "Value", width: 50 },
|
|
47
|
+
];
|
|
48
|
+
};
|
|
49
|
+
const getRows = async () => {
|
|
50
|
+
const level = getLevel();
|
|
51
|
+
if (level.kind === "secrets") {
|
|
52
|
+
const data = await runAwsJsonAsync([
|
|
53
|
+
"secretsmanager",
|
|
54
|
+
"list-secrets",
|
|
55
|
+
...regionArgs,
|
|
56
|
+
]);
|
|
57
|
+
return (data.SecretList ?? []).map((secret) => ({
|
|
58
|
+
id: secret.ARN,
|
|
59
|
+
cells: {
|
|
60
|
+
name: textCell(secret.Name),
|
|
61
|
+
description: textCell(secret.Description ?? ""),
|
|
62
|
+
lastChanged: textCell(secret.LastChangedDate
|
|
63
|
+
? new Date(secret.LastChangedDate).toISOString().replace("T", " ").slice(0, 19)
|
|
64
|
+
: "-"),
|
|
65
|
+
rotation: textCell(secret.RotationEnabled ? "✓" : "-"),
|
|
66
|
+
},
|
|
67
|
+
meta: {
|
|
68
|
+
type: "secret",
|
|
69
|
+
name: secret.Name,
|
|
70
|
+
arn: secret.ARN,
|
|
71
|
+
description: secret.Description ?? "",
|
|
72
|
+
},
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
// secret-fields level
|
|
76
|
+
const { secretArn, secretName } = level;
|
|
77
|
+
try {
|
|
78
|
+
const secretData = await runAwsJsonAsync([
|
|
79
|
+
"secretsmanager",
|
|
80
|
+
"get-secret-value",
|
|
81
|
+
"--secret-id",
|
|
82
|
+
secretArn,
|
|
83
|
+
...regionArgs,
|
|
84
|
+
]);
|
|
85
|
+
const secretString = secretData.SecretString || "";
|
|
86
|
+
const fields = [];
|
|
87
|
+
// Always add $RAW field (the whole secret value)
|
|
88
|
+
fields.push({ key: "$RAW", value: secretString });
|
|
89
|
+
// If JSON, also add parsed fields
|
|
90
|
+
const parsed = tryParseFields(secretString);
|
|
91
|
+
if (parsed) {
|
|
92
|
+
Object.entries(parsed).forEach(([key, value]) => {
|
|
93
|
+
fields.push({ key, value });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Store raw values; formatting happens at display time based on reveal state
|
|
97
|
+
return fields.map(({ key, value }) => ({
|
|
98
|
+
id: key,
|
|
99
|
+
cells: {
|
|
100
|
+
key: textCell(key),
|
|
101
|
+
value: secretCell(value), // Raw value - will be formatted at display time
|
|
102
|
+
},
|
|
103
|
+
meta: {
|
|
104
|
+
type: "secret-field",
|
|
105
|
+
key,
|
|
106
|
+
value, // Raw value stored in metadata too
|
|
107
|
+
secretArn,
|
|
108
|
+
secretName,
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const onSelect = async (row) => {
|
|
117
|
+
const level = getLevel();
|
|
118
|
+
const backStack = getBackStack();
|
|
119
|
+
const meta = row.meta;
|
|
120
|
+
if (level.kind === "secrets") {
|
|
121
|
+
if (!meta || meta.type !== "secret") {
|
|
122
|
+
return { action: "none" };
|
|
123
|
+
}
|
|
124
|
+
// Always navigate to level 2 (which includes $RAW field)
|
|
125
|
+
const newStack = [...backStack, { level: level, selectedIndex: 0 }];
|
|
126
|
+
setBackStack(newStack);
|
|
127
|
+
setLevel({
|
|
128
|
+
kind: "secret-fields",
|
|
129
|
+
secretArn: meta.arn,
|
|
130
|
+
secretName: meta.name,
|
|
131
|
+
});
|
|
132
|
+
// Reset reveal state when entering a secret (security: always start hidden)
|
|
133
|
+
setReveal(false);
|
|
134
|
+
return { action: "navigate" };
|
|
135
|
+
}
|
|
136
|
+
// secret-fields level: do nothing (use "e" key to edit)
|
|
137
|
+
return { action: "none" };
|
|
138
|
+
};
|
|
139
|
+
const canGoBack = () => getBackStack().length > 0;
|
|
140
|
+
const goBack = () => {
|
|
141
|
+
const backStack = getBackStack();
|
|
142
|
+
if (backStack.length > 0) {
|
|
143
|
+
const newStack = backStack.slice(0, -1);
|
|
144
|
+
const frame = backStack[backStack.length - 1];
|
|
145
|
+
setBackStack(newStack);
|
|
146
|
+
setLevel(frame.level);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const getPath = () => {
|
|
150
|
+
const level = getLevel();
|
|
151
|
+
if (level.kind === "secrets")
|
|
152
|
+
return "secrets://";
|
|
153
|
+
return `secrets://${level.secretName}`;
|
|
154
|
+
};
|
|
155
|
+
const getContextLabel = () => {
|
|
156
|
+
const level = getLevel();
|
|
157
|
+
if (level.kind === "secrets")
|
|
158
|
+
return "🔑 Secrets Manager";
|
|
159
|
+
return `🔑 Secret: ${level.secretName}`;
|
|
160
|
+
};
|
|
161
|
+
// Compose capabilities
|
|
162
|
+
const detailCapability = createSecretsManagerDetailCapability(region, getLevel);
|
|
163
|
+
const editCapability = createSecretsManagerEditCapability(region, getLevel);
|
|
164
|
+
const yankCapability = createSecretsManagerYankCapability(region, getLevel);
|
|
165
|
+
const actionCapability = createSecretsManagerActionCapability(region, getLevel);
|
|
166
|
+
return {
|
|
167
|
+
id: "secretsmanager",
|
|
168
|
+
label: "Secrets Manager",
|
|
169
|
+
hudColor: { bg: "blue", fg: "white" },
|
|
170
|
+
getColumns,
|
|
171
|
+
getRows,
|
|
172
|
+
onSelect,
|
|
173
|
+
canGoBack,
|
|
174
|
+
goBack,
|
|
175
|
+
getPath,
|
|
176
|
+
getContextLabel,
|
|
177
|
+
reset() {
|
|
178
|
+
setLevel({ kind: "secrets" });
|
|
179
|
+
setBackStack([]);
|
|
180
|
+
},
|
|
181
|
+
capabilities: {
|
|
182
|
+
edit: editCapability,
|
|
183
|
+
detail: detailCapability,
|
|
184
|
+
yank: yankCapability,
|
|
185
|
+
actions: actionCapability,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { runAwsJsonAsync } from "../../../utils/aws.js";
|
|
2
|
+
import { writeFile, stat, mkdir } from "fs/promises";
|
|
3
|
+
import { resolve, join } from "path";
|
|
4
|
+
function toErrorMessage(error) {
|
|
5
|
+
if (error instanceof Error)
|
|
6
|
+
return error.message;
|
|
7
|
+
return String(error);
|
|
8
|
+
}
|
|
9
|
+
function hasCode(error, code) {
|
|
10
|
+
return Boolean(typeof error === "object" &&
|
|
11
|
+
error !== null &&
|
|
12
|
+
"code" in error &&
|
|
13
|
+
error.code === code);
|
|
14
|
+
}
|
|
15
|
+
export function createSecretsManagerActionCapability(region, getLevel) {
|
|
16
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
17
|
+
const getKeybindings = () => [
|
|
18
|
+
{
|
|
19
|
+
trigger: { type: "key", char: "f" },
|
|
20
|
+
actionId: "fetch",
|
|
21
|
+
label: "Fetch / save to file",
|
|
22
|
+
shortLabel: "save file",
|
|
23
|
+
scope: "navigate",
|
|
24
|
+
adapterId: "secrets-manager",
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
const executeAction = async (actionId, context) => {
|
|
28
|
+
const level = getLevel?.();
|
|
29
|
+
if (actionId === "fetch") {
|
|
30
|
+
// Start fetch flow: prompt for path
|
|
31
|
+
if (!context.row)
|
|
32
|
+
return { type: "error", message: "No row selected" };
|
|
33
|
+
const meta = context.row.meta;
|
|
34
|
+
if (!meta) {
|
|
35
|
+
return { type: "error", message: "No item selected" };
|
|
36
|
+
}
|
|
37
|
+
const isValidLevel = (level?.kind === "secrets" && meta.type === "secret") ||
|
|
38
|
+
(level?.kind === "secret-fields" && meta.type === "secret-field");
|
|
39
|
+
if (!isValidLevel) {
|
|
40
|
+
return { type: "error", message: "Fetch is not available for this item" };
|
|
41
|
+
}
|
|
42
|
+
const promptLabel = level?.kind === "secrets" ? "Fetch secret to:" : "Fetch field to:";
|
|
43
|
+
return {
|
|
44
|
+
type: "prompt",
|
|
45
|
+
label: promptLabel,
|
|
46
|
+
defaultValue: "",
|
|
47
|
+
nextActionId: "fetch:submit",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (actionId === "fetch:submit") {
|
|
51
|
+
// User entered a path; try to write the secret/field
|
|
52
|
+
if (!context.row || !context.data?.path) {
|
|
53
|
+
return { type: "error", message: "Invalid path" };
|
|
54
|
+
}
|
|
55
|
+
const meta = context.row.meta;
|
|
56
|
+
if (!meta) {
|
|
57
|
+
return { type: "error", message: "No item selected" };
|
|
58
|
+
}
|
|
59
|
+
let content = "";
|
|
60
|
+
let defaultFileName = "data.txt";
|
|
61
|
+
try {
|
|
62
|
+
if (level?.kind === "secrets" && meta.type === "secret") {
|
|
63
|
+
// Fetch secret value
|
|
64
|
+
const secretData = await runAwsJsonAsync([
|
|
65
|
+
"secretsmanager",
|
|
66
|
+
"get-secret-value",
|
|
67
|
+
"--secret-id",
|
|
68
|
+
meta.arn,
|
|
69
|
+
...regionArgs,
|
|
70
|
+
]);
|
|
71
|
+
content =
|
|
72
|
+
secretData.SecretString ||
|
|
73
|
+
(secretData.SecretBinary
|
|
74
|
+
? Buffer.from(secretData.SecretBinary, "base64").toString()
|
|
75
|
+
: "");
|
|
76
|
+
defaultFileName = `${meta.name}.txt`;
|
|
77
|
+
}
|
|
78
|
+
else if (level?.kind === "secret-fields" && meta.type === "secret-field") {
|
|
79
|
+
// Use field value directly
|
|
80
|
+
content = meta.value || "";
|
|
81
|
+
defaultFileName = `${meta.key}.txt`;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
return { type: "error", message: "Invalid item type" };
|
|
85
|
+
}
|
|
86
|
+
const destinationPath = context.data.path;
|
|
87
|
+
// Handle destination path
|
|
88
|
+
const rawTarget = destinationPath.trim();
|
|
89
|
+
const absTarget = resolve(rawTarget);
|
|
90
|
+
const endsWithSlash = rawTarget.endsWith("/") || rawTarget.endsWith("\\");
|
|
91
|
+
let finalPath = absTarget;
|
|
92
|
+
if (endsWithSlash) {
|
|
93
|
+
await mkdir(absTarget, { recursive: true });
|
|
94
|
+
finalPath = join(absTarget, defaultFileName);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const targetStat = await stat(absTarget).catch(() => null);
|
|
98
|
+
if (targetStat?.isDirectory()) {
|
|
99
|
+
finalPath = join(absTarget, defaultFileName);
|
|
100
|
+
}
|
|
101
|
+
else if (targetStat?.isFile()) {
|
|
102
|
+
finalPath = absTarget;
|
|
103
|
+
throw new Error("EEXIST_FILE:" + finalPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Ensure parent directory exists
|
|
107
|
+
await mkdir(resolve(finalPath, ".."), { recursive: true });
|
|
108
|
+
await writeFile(finalPath, content, { mode: 0o600 });
|
|
109
|
+
return {
|
|
110
|
+
type: "feedback",
|
|
111
|
+
message: `Saved to ${finalPath}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
// Check for file exists error
|
|
116
|
+
if (hasCode(err, "EEXIST")) {
|
|
117
|
+
const destinationPath = context.data.path;
|
|
118
|
+
return {
|
|
119
|
+
type: "confirm",
|
|
120
|
+
message: `File exists. Overwrite ${destinationPath}?`,
|
|
121
|
+
nextActionId: "fetch:overwrite",
|
|
122
|
+
data: { path: destinationPath },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (err instanceof Error && err.message.includes("EEXIST_FILE")) {
|
|
126
|
+
const filePath = err.message.replace("EEXIST_FILE:", "");
|
|
127
|
+
return {
|
|
128
|
+
type: "confirm",
|
|
129
|
+
message: `File exists. Overwrite ${filePath}?`,
|
|
130
|
+
nextActionId: "fetch:overwrite",
|
|
131
|
+
data: { path: filePath },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
type: "error",
|
|
136
|
+
message: `Fetch failed: ${toErrorMessage(err)}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (actionId === "fetch:overwrite") {
|
|
141
|
+
// User confirmed overwrite
|
|
142
|
+
if (!context.row || !context.data?.path) {
|
|
143
|
+
return { type: "error", message: "Invalid path" };
|
|
144
|
+
}
|
|
145
|
+
const meta = context.row.meta;
|
|
146
|
+
if (!meta) {
|
|
147
|
+
return { type: "error", message: "No item selected" };
|
|
148
|
+
}
|
|
149
|
+
const destinationPath = context.data.path;
|
|
150
|
+
try {
|
|
151
|
+
let content = "";
|
|
152
|
+
if (level?.kind === "secrets" && meta.type === "secret") {
|
|
153
|
+
// Fetch secret value
|
|
154
|
+
const secretData = await runAwsJsonAsync([
|
|
155
|
+
"secretsmanager",
|
|
156
|
+
"get-secret-value",
|
|
157
|
+
"--secret-id",
|
|
158
|
+
meta.arn,
|
|
159
|
+
...regionArgs,
|
|
160
|
+
]);
|
|
161
|
+
content =
|
|
162
|
+
secretData.SecretString ||
|
|
163
|
+
(secretData.SecretBinary
|
|
164
|
+
? Buffer.from(secretData.SecretBinary, "base64").toString()
|
|
165
|
+
: "");
|
|
166
|
+
}
|
|
167
|
+
else if (level?.kind === "secret-fields" && meta.type === "secret-field") {
|
|
168
|
+
// Use field value directly
|
|
169
|
+
content = meta.value || "";
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
return { type: "error", message: "Invalid item type" };
|
|
173
|
+
}
|
|
174
|
+
await writeFile(destinationPath, content, { mode: 0o600 });
|
|
175
|
+
return {
|
|
176
|
+
type: "feedback",
|
|
177
|
+
message: `Saved to ${destinationPath}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
return {
|
|
182
|
+
type: "error",
|
|
183
|
+
message: `Fetch failed: ${toErrorMessage(err)}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { type: "none" };
|
|
188
|
+
};
|
|
189
|
+
return {
|
|
190
|
+
getKeybindings,
|
|
191
|
+
executeAction,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { runAwsJsonAsync } from "../../../utils/aws.js";
|
|
2
|
+
export function createSecretsManagerDetailCapability(region, getLevel) {
|
|
3
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
4
|
+
const getDetails = async (row) => {
|
|
5
|
+
const meta = row.meta;
|
|
6
|
+
if (!meta) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
const level = getLevel?.();
|
|
10
|
+
// Level 1: Secret details
|
|
11
|
+
if (level?.kind === "secrets" && meta.type === "secret") {
|
|
12
|
+
const data = await runAwsJsonAsync([
|
|
13
|
+
"secretsmanager",
|
|
14
|
+
"describe-secret",
|
|
15
|
+
"--secret-id",
|
|
16
|
+
meta.arn,
|
|
17
|
+
...regionArgs,
|
|
18
|
+
]);
|
|
19
|
+
const fields = [
|
|
20
|
+
{ label: "Name", value: String(data.Name ?? "-") },
|
|
21
|
+
{ label: "ARN", value: String(data.ARN ?? "-") },
|
|
22
|
+
{ label: "Description", value: String(data.Description ?? "-") },
|
|
23
|
+
{ label: "Last Changed", value: String(data.LastChangedDate ?? "-") },
|
|
24
|
+
{ label: "Last Rotated", value: String(data.LastRotatedDate ?? "-") },
|
|
25
|
+
{ label: "Rotation Enabled", value: data.RotationEnabled ? "Yes" : "No" },
|
|
26
|
+
{ label: "KMS Key ID", value: String(data.KmsKeyId ?? "-") },
|
|
27
|
+
{
|
|
28
|
+
label: "Tags",
|
|
29
|
+
value: data.Tags && data.Tags.length > 0 ? `${data.Tags.length} tag(s)` : "-",
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
return fields;
|
|
33
|
+
}
|
|
34
|
+
// Level 2: Field details
|
|
35
|
+
if (level?.kind === "secret-fields" && meta.type === "secret-field") {
|
|
36
|
+
const fields = [
|
|
37
|
+
{ label: "Field Key", value: String(meta.key ?? "-") },
|
|
38
|
+
{ label: "Secret Name", value: String(meta.secretName ?? "-") },
|
|
39
|
+
{ label: "Value", value: String(meta.value ?? "-") },
|
|
40
|
+
];
|
|
41
|
+
return fields;
|
|
42
|
+
}
|
|
43
|
+
return [];
|
|
44
|
+
};
|
|
45
|
+
return { getDetails };
|
|
46
|
+
}
|