@a9s/cli 1.0.7 → 1.0.8

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.
@@ -93,7 +93,7 @@ async function checkLocalStack() {
93
93
  process.exit(1);
94
94
  }
95
95
  }
96
- async function runAws(args) {
96
+ async function runAws(args, timeoutMs = 10000) {
97
97
  const env = {
98
98
  ...process.env,
99
99
  AWS_ACCESS_KEY_ID: "test",
@@ -102,7 +102,7 @@ async function runAws(args) {
102
102
  };
103
103
  const { stdout } = await execFileAsync("aws", ["--endpoint-url", "http://localhost:4566", ...args], {
104
104
  env,
105
- timeout: 5000,
105
+ timeout: timeoutMs,
106
106
  });
107
107
  return stdout;
108
108
  }
@@ -195,6 +195,182 @@ async function ensureAttachedRolePolicy(roleName, policyArn) {
195
195
  await runAws(["iam", "attach-role-policy", "--role-name", roleName, "--policy-arn", policyArn]);
196
196
  console.log(` Attached managed policy to ${roleName}`);
197
197
  }
198
+ async function seedDynamoDB() {
199
+ console.log("\nSeeding DynamoDB:");
200
+ const tables = [
201
+ {
202
+ name: "Users",
203
+ pkName: "userId",
204
+ skName: "timestamp",
205
+ items: [
206
+ { userId: "user-001", timestamp: "2024-01-15T10:00:00Z", email: "alice@example.com", role: "admin" },
207
+ { userId: "user-002", timestamp: "2024-01-16T14:30:00Z", email: "bob@example.com", role: "user" },
208
+ { userId: "user-003", timestamp: "2024-01-17T09:45:00Z", email: "charlie@example.com", role: "user" },
209
+ ],
210
+ },
211
+ {
212
+ name: "Orders",
213
+ pkName: "orderId",
214
+ items: [
215
+ { orderId: "order-001", status: "completed", total: "99.99", items: "3" },
216
+ { orderId: "order-002", status: "pending", total: "149.50", items: "5" },
217
+ { orderId: "order-003", status: "shipped", total: "299.00", items: "1" },
218
+ ],
219
+ },
220
+ ];
221
+ for (const table of tables) {
222
+ // Try to create table
223
+ try {
224
+ const keySchema = [{ AttributeName: table.pkName, KeyType: "HASH" }];
225
+ const attrDefs = [{ AttributeName: table.pkName, AttributeType: "S" }];
226
+ if (table.skName) {
227
+ keySchema.push({ AttributeName: table.skName, KeyType: "RANGE" });
228
+ attrDefs.push({ AttributeName: table.skName, AttributeType: "S" });
229
+ }
230
+ await runAws([
231
+ "dynamodb",
232
+ "create-table",
233
+ "--table-name",
234
+ table.name,
235
+ "--key-schema",
236
+ JSON.stringify(keySchema),
237
+ "--attribute-definitions",
238
+ JSON.stringify(attrDefs),
239
+ "--billing-mode",
240
+ "PAY_PER_REQUEST",
241
+ "--output",
242
+ "json",
243
+ ], 15000);
244
+ console.log(` Created table: ${table.name}`);
245
+ }
246
+ catch (e) {
247
+ const err = e;
248
+ if (err.message?.includes("already exists")) {
249
+ console.log(` Table already exists: ${table.name}`);
250
+ }
251
+ else {
252
+ throw e;
253
+ }
254
+ }
255
+ // Add items to the table (whether newly created or existing)
256
+ let addedCount = 0;
257
+ for (const item of table.items) {
258
+ const itemObj = {};
259
+ for (const [k, v] of Object.entries(item)) {
260
+ itemObj[k] = { S: String(v) };
261
+ }
262
+ try {
263
+ await runAws([
264
+ "dynamodb",
265
+ "put-item",
266
+ "--table-name",
267
+ table.name,
268
+ "--item",
269
+ JSON.stringify(itemObj),
270
+ ], 10000);
271
+ addedCount++;
272
+ }
273
+ catch (e) {
274
+ console.log(` Warning: could not add item: ${e.message}`);
275
+ }
276
+ }
277
+ console.log(` Added ${addedCount}/${table.items.length} items`);
278
+ }
279
+ }
280
+ async function seedRoute53() {
281
+ console.log("\nSeeding Route53:");
282
+ const zones = [
283
+ { name: "example.com.", isPrivate: false },
284
+ { name: "internal.local.", isPrivate: true },
285
+ { name: "staging.test.", isPrivate: false },
286
+ ];
287
+ for (const zone of zones) {
288
+ try {
289
+ // First check if zone already exists
290
+ const listOut = await runAws([
291
+ "route53",
292
+ "list-hosted-zones",
293
+ "--output",
294
+ "json",
295
+ ], 10000).catch(() => "");
296
+ let zoneId;
297
+ if (listOut) {
298
+ try {
299
+ const listParsed = JSON.parse(listOut);
300
+ const existingZone = listParsed.HostedZones?.find((z) => z.Name === zone.name);
301
+ if (existingZone) {
302
+ zoneId = existingZone.Id;
303
+ console.log(` Hosted zone already exists: ${zone.name}`);
304
+ }
305
+ }
306
+ catch (e) {
307
+ // Ignore parse errors
308
+ console.log(` List zones error: ${e.message}`);
309
+ }
310
+ }
311
+ // If not found, create it
312
+ if (!zoneId) {
313
+ const createOut = await runAws([
314
+ "route53",
315
+ "create-hosted-zone",
316
+ "--name",
317
+ zone.name,
318
+ "--caller-reference",
319
+ `zone-${Date.now()}-${Math.random()}`,
320
+ "--hosted-zone-config",
321
+ JSON.stringify({ PrivateZone: zone.isPrivate, Comment: `Test zone for ${zone.name}` }),
322
+ "--output",
323
+ "json",
324
+ ], 10000);
325
+ const parsed = JSON.parse(createOut);
326
+ zoneId = parsed.HostedZone?.Id;
327
+ if (!zoneId)
328
+ throw new Error(`Failed creating hosted zone ${zone.name}`);
329
+ console.log(` Created hosted zone: ${zone.name} (${zoneId})`);
330
+ }
331
+ // Add some DNS records to the zone
332
+ const records = [
333
+ { name: `www.${zone.name}`, type: "A", value: "192.0.2.1" },
334
+ { name: `api.${zone.name}`, type: "A", value: "192.0.2.2" },
335
+ { name: `mail.${zone.name}`, type: "A", value: "192.0.2.3" },
336
+ { name: zone.name, type: "MX", value: "10 mail.example.com." },
337
+ ];
338
+ for (const record of records) {
339
+ try {
340
+ await runAws([
341
+ "route53",
342
+ "change-resource-record-sets",
343
+ "--hosted-zone-id",
344
+ zoneId,
345
+ "--change-batch",
346
+ JSON.stringify({
347
+ Changes: [
348
+ {
349
+ Action: "UPSERT",
350
+ ResourceRecordSet: {
351
+ Name: record.name,
352
+ Type: record.type,
353
+ TTL: 300,
354
+ ResourceRecords: [{ Value: record.value }],
355
+ },
356
+ },
357
+ ],
358
+ }),
359
+ ], 10000);
360
+ console.log(` Ensured record: ${record.name} (${record.type})`);
361
+ }
362
+ catch (e) {
363
+ const err = e;
364
+ console.log(` Warning adding record ${record.name}: ${err.message ?? String(e)}`);
365
+ }
366
+ }
367
+ }
368
+ catch (e) {
369
+ const err = e;
370
+ console.error(` Error seeding zone ${zone.name}: ${err.message ?? String(e)}`);
371
+ }
372
+ }
373
+ }
198
374
  async function seedSecretsManager() {
199
375
  console.log("\nSeeding Secrets Manager:");
200
376
  const secrets = [
@@ -299,8 +475,30 @@ async function main() {
299
475
  process.stdout.write(" Objects: ");
300
476
  await seedBucket(bucket);
301
477
  }
302
- await seedIam();
303
- await seedSecretsManager();
478
+ try {
479
+ await seedDynamoDB();
480
+ }
481
+ catch (e) {
482
+ console.error(`\nDynamoDB seeding failed: ${e.message}`);
483
+ }
484
+ try {
485
+ await seedRoute53();
486
+ }
487
+ catch (e) {
488
+ console.error(`\nRoute53 seeding failed: ${e.message}`);
489
+ }
490
+ try {
491
+ await seedIam();
492
+ }
493
+ catch (e) {
494
+ console.error(`\nIAM seeding failed: ${e.message}`);
495
+ }
496
+ try {
497
+ await seedSecretsManager();
498
+ }
499
+ catch (e) {
500
+ console.error(`\nSecrets Manager seeding failed: ${e.message}`);
501
+ }
304
502
  console.log("\nDone! LocalStack seeded with test data.");
305
503
  console.log("Run: pnpm dev:local");
306
504
  }
@@ -5,8 +5,8 @@ import { createIamServiceAdapter } from "./views/iam/adapter.js";
5
5
  import { createSecretsManagerServiceAdapter } from "./views/secretsmanager/adapter.js";
6
6
  export const SERVICE_REGISTRY = {
7
7
  s3: (endpointUrl, region) => createS3ServiceAdapter(endpointUrl, region),
8
- route53: (_endpointUrl, _region) => createRoute53ServiceAdapter(),
9
- dynamodb: (_endpointUrl, _region) => createDynamoDBServiceAdapter(),
8
+ route53: (_endpointUrl, region) => createRoute53ServiceAdapter(undefined, region),
9
+ dynamodb: (_endpointUrl, region) => createDynamoDBServiceAdapter(undefined, region),
10
10
  iam: (_endpointUrl, _region) => createIamServiceAdapter(),
11
11
  secretsmanager: (endpointUrl, region) => createSecretsManagerServiceAdapter(endpointUrl, region),
12
12
  };
@@ -1,12 +1,305 @@
1
1
  import { textCell } from "../../types.js";
2
- export function createDynamoDBServiceAdapter() {
3
- const getColumns = () => [{ key: "name", label: "Name" }];
2
+ import { runAwsJsonAsync } from "../../utils/aws.js";
3
+ import { atom } from "jotai";
4
+ import { getDefaultStore } from "jotai";
5
+ import { unwrapDynamoValue, formatBillingMode, formatDynamoValue, getDynamoType, extractPkValue, extractSkValue, } from "./utils.js";
6
+ import { createDynamoDBDetailCapability } from "./capabilities/detailCapability.js";
7
+ import { createDynamoDBYankCapability } from "./capabilities/yankCapability.js";
8
+ export const dynamoDBLevelAtom = atom({ kind: "tables" });
9
+ export const dynamoDBBackStackAtom = atom([]);
10
+ // Cache for table descriptions to avoid repeated AWS calls
11
+ const tableDescriptionCache = new Map();
12
+ // Cache for scanned items
13
+ const itemsCache = new Map();
14
+ export function createDynamoDBServiceAdapter(endpointUrl, region) {
15
+ const store = getDefaultStore();
16
+ const regionArgs = region ? ["--region", region] : [];
17
+ const getLevel = () => store.get(dynamoDBLevelAtom);
18
+ const setLevel = (level) => store.set(dynamoDBLevelAtom, level);
19
+ const getBackStack = () => store.get(dynamoDBBackStackAtom);
20
+ const setBackStack = (stack) => store.set(dynamoDBBackStackAtom, stack);
21
+ const getTableDescription = async (tableName) => {
22
+ const cached = tableDescriptionCache.get(tableName);
23
+ if (cached)
24
+ return cached;
25
+ try {
26
+ const data = await runAwsJsonAsync([
27
+ "dynamodb",
28
+ "describe-table",
29
+ "--table-name",
30
+ tableName,
31
+ ...regionArgs,
32
+ ]);
33
+ const table = data.Table;
34
+ tableDescriptionCache.set(tableName, table);
35
+ return table;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ };
41
+ const getColumns = () => {
42
+ const level = getLevel();
43
+ if (level.kind === "tables") {
44
+ return [
45
+ { key: "name", label: "Name" },
46
+ { key: "status", label: "Status", width: 12 },
47
+ { key: "items", label: "Items", width: 10 },
48
+ { key: "billing", label: "Billing", width: 25 },
49
+ { key: "gsis", label: "GSIs", width: 6 },
50
+ ];
51
+ }
52
+ if (level.kind === "items") {
53
+ const table = tableDescriptionCache.get(level.tableName);
54
+ if (!table) {
55
+ // Fallback: return standard columns that will always be populated
56
+ return [
57
+ { key: "#", label: "#", width: 4 },
58
+ { key: "pk", label: "PK", width: 20 },
59
+ { key: "sk", label: "SK", width: 20 },
60
+ { key: "size", label: "Size", width: 10 },
61
+ ];
62
+ }
63
+ const keys = table.KeySchema ?? [];
64
+ const hashKey = keys.find((k) => k.KeyType === "HASH");
65
+ const rangeKey = keys.find((k) => k.KeyType === "RANGE");
66
+ const cols = [{ key: "#", label: "#", width: 4 }];
67
+ if (hashKey) {
68
+ cols.push({ key: "pk", label: hashKey.AttributeName, width: 20 });
69
+ }
70
+ if (rangeKey) {
71
+ cols.push({ key: "sk", label: rangeKey.AttributeName, width: 20 });
72
+ }
73
+ cols.push({ key: "size", label: "Size", width: 10 });
74
+ return cols;
75
+ }
76
+ // item-fields level
77
+ return [
78
+ { key: "attribute", label: "Attribute" },
79
+ { key: "value", label: "Value", width: 50 },
80
+ { key: "type", label: "Type", width: 8 },
81
+ ];
82
+ };
4
83
  const getRows = async () => {
5
- return [{ id: "stub", cells: { name: textCell("DynamoDB not yet implemented") }, meta: {} }];
84
+ const level = getLevel();
85
+ if (level.kind === "tables") {
86
+ try {
87
+ const listData = await runAwsJsonAsync([
88
+ "dynamodb",
89
+ "list-tables",
90
+ ...regionArgs,
91
+ ]);
92
+ const tableNames = listData.TableNames ?? [];
93
+ // Describe all tables in parallel
94
+ const tables = await Promise.all(tableNames.map((name) => getTableDescription(name)));
95
+ return tables
96
+ .filter((t) => t !== null)
97
+ .map((table) => {
98
+ const statusColor = table.TableStatus === "ACTIVE"
99
+ ? "green"
100
+ : table.TableStatus === "CREATING" || table.TableStatus === "UPDATING"
101
+ ? "yellow"
102
+ : "red";
103
+ return {
104
+ id: table.TableArn,
105
+ cells: {
106
+ name: textCell(table.TableName),
107
+ status: textCell(table.TableStatus),
108
+ items: textCell(String(table.ItemCount ?? 0)),
109
+ billing: textCell(formatBillingMode(table)),
110
+ gsis: textCell(String(table.GlobalSecondaryIndexes?.length ?? 0)),
111
+ },
112
+ meta: {
113
+ type: "table",
114
+ tableName: table.TableName,
115
+ tableStatus: table.TableStatus,
116
+ tableArn: table.TableArn,
117
+ billing: formatBillingMode(table),
118
+ gsiCount: table.GlobalSecondaryIndexes?.length ?? 0,
119
+ },
120
+ };
121
+ });
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ }
127
+ if (level.kind === "items") {
128
+ const { tableName } = level;
129
+ try {
130
+ // Check cache
131
+ const cached = itemsCache.get(tableName);
132
+ if (cached) {
133
+ const table = cached.table;
134
+ const items = cached.items;
135
+ return items.map((item, index) => {
136
+ const pkValue = extractPkValue(item, table);
137
+ const skValue = extractSkValue(item, table);
138
+ const itemSize = JSON.stringify(item).length;
139
+ const cells = {
140
+ name: textCell(`Item ${index + 1}`),
141
+ "#": textCell(String(index + 1)),
142
+ pk: textCell(pkValue ?? "-"),
143
+ sk: textCell(skValue ?? "-"),
144
+ size: textCell(`${itemSize}B`),
145
+ };
146
+ return {
147
+ id: `${tableName}-${index}`,
148
+ cells,
149
+ meta: {
150
+ type: "item",
151
+ tableName,
152
+ itemIndex: index,
153
+ itemPkValue: pkValue ?? undefined,
154
+ itemSkValue: skValue ?? undefined,
155
+ itemSize,
156
+ itemJson: JSON.stringify(item),
157
+ },
158
+ };
159
+ });
160
+ }
161
+ // Fetch table description to get key schema
162
+ const table = await getTableDescription(tableName);
163
+ if (!table)
164
+ return [];
165
+ // Scan items
166
+ const scanData = await runAwsJsonAsync([
167
+ "dynamodb",
168
+ "scan",
169
+ "--table-name",
170
+ tableName,
171
+ "--limit",
172
+ "50",
173
+ ...regionArgs,
174
+ ]);
175
+ const items = scanData.Items ?? [];
176
+ itemsCache.set(tableName, { items, table });
177
+ return items.map((item, index) => {
178
+ const pkValue = extractPkValue(item, table);
179
+ const skValue = extractSkValue(item, table);
180
+ const itemSize = JSON.stringify(item).length;
181
+ const cells = {
182
+ name: textCell(`Item ${index + 1}`),
183
+ "#": textCell(String(index + 1)),
184
+ pk: textCell(pkValue ?? "-"),
185
+ sk: textCell(skValue ?? "-"),
186
+ size: textCell(`${itemSize}B`),
187
+ };
188
+ return {
189
+ id: `${tableName}-${index}`,
190
+ cells,
191
+ meta: {
192
+ type: "item",
193
+ tableName,
194
+ itemIndex: index,
195
+ itemPkValue: pkValue ?? undefined,
196
+ itemSkValue: skValue ?? undefined,
197
+ itemSize,
198
+ itemJson: JSON.stringify(item),
199
+ },
200
+ };
201
+ });
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ }
207
+ // item-fields level
208
+ if (level.kind === "item-fields") {
209
+ const { tableName, itemIndex } = level;
210
+ const cached = itemsCache.get(tableName);
211
+ if (!cached)
212
+ return [];
213
+ const item = cached.items[itemIndex];
214
+ if (!item)
215
+ return [];
216
+ return Object.entries(item).map(([attrName, attrValue]) => {
217
+ const displayValue = formatDynamoValue(attrValue);
218
+ const type = getDynamoType(attrValue);
219
+ return {
220
+ id: attrName,
221
+ cells: {
222
+ attribute: textCell(attrName),
223
+ value: textCell(displayValue),
224
+ type: textCell(type),
225
+ },
226
+ meta: {
227
+ type: "item-field",
228
+ tableName,
229
+ itemIndex,
230
+ fieldName: attrName,
231
+ fieldValue: displayValue,
232
+ fieldType: type,
233
+ fieldRawValue: unwrapDynamoValue(attrValue),
234
+ },
235
+ };
236
+ });
237
+ }
238
+ return [];
6
239
  };
7
- const onSelect = async (_row) => {
240
+ const onSelect = async (row) => {
241
+ const level = getLevel();
242
+ const backStack = getBackStack();
243
+ const meta = row.meta;
244
+ if (level.kind === "tables") {
245
+ if (!meta || meta.type !== "table") {
246
+ return { action: "none" };
247
+ }
248
+ // Clear items cache when switching tables
249
+ itemsCache.clear();
250
+ const newStack = [...backStack, { level: level, selectedIndex: 0 }];
251
+ setBackStack(newStack);
252
+ setLevel({
253
+ kind: "items",
254
+ tableName: meta.tableName,
255
+ });
256
+ return { action: "navigate" };
257
+ }
258
+ if (level.kind === "items") {
259
+ if (!meta || meta.type !== "item") {
260
+ return { action: "none" };
261
+ }
262
+ const newStack = [...backStack, { level: level, selectedIndex: 0 }];
263
+ setBackStack(newStack);
264
+ setLevel({
265
+ kind: "item-fields",
266
+ tableName: meta.tableName,
267
+ itemIndex: meta.itemIndex,
268
+ });
269
+ return { action: "navigate" };
270
+ }
271
+ // item-fields level: leaf, no drill-down
8
272
  return { action: "none" };
9
273
  };
274
+ const canGoBack = () => getBackStack().length > 0;
275
+ const goBack = () => {
276
+ const backStack = getBackStack();
277
+ if (backStack.length > 0) {
278
+ const newStack = backStack.slice(0, -1);
279
+ const frame = backStack[backStack.length - 1];
280
+ setBackStack(newStack);
281
+ setLevel(frame.level);
282
+ }
283
+ };
284
+ const getPath = () => {
285
+ const level = getLevel();
286
+ if (level.kind === "tables")
287
+ return "dynamodb://";
288
+ if (level.kind === "items")
289
+ return `dynamodb://${level.tableName}`;
290
+ return `dynamodb://${level.tableName}/items`;
291
+ };
292
+ const getContextLabel = () => {
293
+ const level = getLevel();
294
+ if (level.kind === "tables")
295
+ return "⚡ Tables";
296
+ if (level.kind === "items")
297
+ return `⚡ ${level.tableName}`;
298
+ return `⚡ ${level.tableName}/items`;
299
+ };
300
+ // Compose capabilities
301
+ const detailCapability = createDynamoDBDetailCapability(region, getLevel);
302
+ const yankCapability = createDynamoDBYankCapability(region, getLevel);
10
303
  return {
11
304
  id: "dynamodb",
12
305
  label: "DynamoDB",
@@ -14,9 +307,19 @@ export function createDynamoDBServiceAdapter() {
14
307
  getColumns,
15
308
  getRows,
16
309
  onSelect,
17
- canGoBack: () => false,
18
- goBack: () => { },
19
- getPath: () => "/",
20
- getContextLabel: () => "⚡ Tables",
310
+ canGoBack,
311
+ goBack,
312
+ getPath,
313
+ getContextLabel,
314
+ reset() {
315
+ setLevel({ kind: "tables" });
316
+ setBackStack([]);
317
+ tableDescriptionCache.clear();
318
+ itemsCache.clear();
319
+ },
320
+ capabilities: {
321
+ detail: detailCapability,
322
+ yank: yankCapability,
323
+ },
21
324
  };
22
325
  }
@@ -0,0 +1,94 @@
1
+ import { runAwsJsonAsync } from "../../../utils/aws.js";
2
+ import { formatBillingMode, formatKeySchema } from "../utils.js";
3
+ export function createDynamoDBDetailCapability(region, getLevel) {
4
+ const regionArgs = region ? ["--region", region] : [];
5
+ const getDetails = async (row) => {
6
+ const meta = row.meta;
7
+ if (!meta) {
8
+ return [];
9
+ }
10
+ const level = getLevel?.();
11
+ // Table level
12
+ if (level?.kind === "tables" && meta.type === "table") {
13
+ try {
14
+ const data = await runAwsJsonAsync([
15
+ "dynamodb",
16
+ "describe-table",
17
+ "--table-name",
18
+ meta.tableName,
19
+ ...regionArgs,
20
+ ]);
21
+ const table = data.Table;
22
+ const fields = [
23
+ { label: "Table Name", value: table.TableName },
24
+ { label: "Table ARN", value: table.TableArn },
25
+ { label: "Status", value: table.TableStatus },
26
+ { label: "Billing Mode", value: formatBillingMode(table) },
27
+ { label: "Item Count", value: String(table.ItemCount ?? 0) },
28
+ { label: "Table Size", value: `${table.TableSizeBytes ?? 0} bytes` },
29
+ { label: "Key Schema", value: formatKeySchema(table) },
30
+ ];
31
+ // Add attributes
32
+ if (table.AttributeDefinitions && table.AttributeDefinitions.length > 0) {
33
+ const attrs = table.AttributeDefinitions.map((a) => `${a.AttributeName} (${a.AttributeType})`).join(", ");
34
+ fields.push({ label: "Attributes", value: attrs });
35
+ }
36
+ fields.push({
37
+ label: "GSI Count",
38
+ value: String(table.GlobalSecondaryIndexes?.length ?? 0),
39
+ });
40
+ fields.push({
41
+ label: "LSI Count",
42
+ value: String(table.LocalSecondaryIndexes?.length ?? 0),
43
+ });
44
+ if (table.CreationDateTime) {
45
+ fields.push({
46
+ label: "Created",
47
+ value: new Date(table.CreationDateTime).toISOString(),
48
+ });
49
+ }
50
+ return fields;
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ }
56
+ // Item level
57
+ if (level?.kind === "items" && meta.type === "item") {
58
+ try {
59
+ const itemJson = meta.itemJson ? JSON.parse(meta.itemJson) : {};
60
+ const prettyJson = JSON.stringify(itemJson, null, 2);
61
+ const fields = [
62
+ {
63
+ label: "Item (JSON)",
64
+ value: prettyJson,
65
+ },
66
+ ];
67
+ if (meta.itemSize) {
68
+ fields.push({
69
+ label: "Size",
70
+ value: `${meta.itemSize} bytes`,
71
+ });
72
+ }
73
+ return fields;
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
79
+ // Item-field level
80
+ if (level?.kind === "item-fields" && meta.type === "item-field") {
81
+ const fields = [
82
+ { label: "Attribute", value: meta.fieldName ?? "-" },
83
+ { label: "Type", value: meta.fieldType ?? "-" },
84
+ {
85
+ label: "Value",
86
+ value: String(meta.fieldRawValue ?? "-"),
87
+ },
88
+ ];
89
+ return fields;
90
+ }
91
+ return [];
92
+ };
93
+ return { getDetails };
94
+ }
@@ -0,0 +1,6 @@
1
+ import { createYankCapability } from "../../../adapters/capabilities/YankCapability.js";
2
+ import { DynamoDBRowMetaSchema } from "../schema.js";
3
+ import { DynamoDBYankOptions } from "./yankOptions.js";
4
+ export function createDynamoDBYankCapability(_region, _getLevel) {
5
+ return createYankCapability(DynamoDBYankOptions, DynamoDBRowMetaSchema, {});
6
+ }
@@ -0,0 +1,69 @@
1
+ export const DynamoDBYankOptions = [
2
+ // Table options
3
+ {
4
+ trigger: { type: "key", char: "n" },
5
+ label: "Copy table name",
6
+ feedback: "Copied table name",
7
+ isRelevant: (row) => row.meta.type === "table",
8
+ resolve: async (row) => {
9
+ return row.meta.tableName ?? null;
10
+ },
11
+ },
12
+ {
13
+ trigger: { type: "key", char: "a" },
14
+ label: "Copy table ARN",
15
+ feedback: "Copied table ARN",
16
+ isRelevant: (row) => row.meta.type === "table",
17
+ resolve: async (row) => {
18
+ return row.meta.tableArn ?? null;
19
+ },
20
+ },
21
+ // Item options
22
+ {
23
+ trigger: { type: "key", char: "k" },
24
+ label: "Copy primary key (JSON)",
25
+ feedback: "Copied primary key",
26
+ isRelevant: (row) => row.meta.type === "item",
27
+ resolve: async (row) => {
28
+ if (!row.meta.itemPkValue && !row.meta.itemSkValue) {
29
+ return null;
30
+ }
31
+ const obj = {};
32
+ if (row.meta.itemPkValue) {
33
+ obj.pk = row.meta.itemPkValue;
34
+ }
35
+ if (row.meta.itemSkValue) {
36
+ obj.sk = row.meta.itemSkValue;
37
+ }
38
+ return JSON.stringify(obj);
39
+ },
40
+ },
41
+ {
42
+ trigger: { type: "key", char: "j" },
43
+ label: "Copy entire item (JSON)",
44
+ feedback: "Copied item",
45
+ isRelevant: (row) => row.meta.type === "item" && !!row.meta.itemJson,
46
+ resolve: async (row) => {
47
+ return row.meta.itemJson ?? null;
48
+ },
49
+ },
50
+ // Field options
51
+ {
52
+ trigger: { type: "key", char: "v" },
53
+ label: "Copy attribute value",
54
+ feedback: "Copied attribute value",
55
+ isRelevant: (row) => row.meta.type === "item-field",
56
+ resolve: async (row) => {
57
+ return row.meta.fieldValue ?? null;
58
+ },
59
+ },
60
+ {
61
+ trigger: { type: "key", char: "k" },
62
+ label: "Copy attribute name",
63
+ feedback: "Copied attribute name",
64
+ isRelevant: (row) => row.meta.type === "item-field",
65
+ resolve: async (row) => {
66
+ return row.meta.fieldName ?? null;
67
+ },
68
+ },
69
+ ];
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ export const DynamoDBRowMetaSchema = z.object({
3
+ type: z.enum(["table", "item", "item-field"]),
4
+ tableName: z.string().optional(),
5
+ tableStatus: z.string().optional(),
6
+ tableArn: z.string().optional(),
7
+ billing: z.string().optional(),
8
+ gsiCount: z.number().optional(),
9
+ itemIndex: z.number().optional(),
10
+ itemPkValue: z.string().optional(),
11
+ itemSkValue: z.string().optional(),
12
+ itemSize: z.number().optional(),
13
+ itemJson: z.string().optional(),
14
+ fieldName: z.string().optional(),
15
+ fieldValue: z.string().optional(),
16
+ fieldType: z.string().optional(),
17
+ fieldRawValue: z.unknown().optional(),
18
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Unwrap a DynamoDB AttributeValue to its native JS value.
3
+ * E.g., { S: "hello" } → "hello", { N: "42" } → "42", { BOOL: true } → true
4
+ */
5
+ export function unwrapDynamoValue(attr) {
6
+ const key = Object.keys(attr)[0];
7
+ const value = attr[key];
8
+ switch (key) {
9
+ case "S":
10
+ return value;
11
+ case "N":
12
+ return value;
13
+ case "B":
14
+ return value;
15
+ case "SS":
16
+ return value;
17
+ case "NS":
18
+ return value;
19
+ case "BS":
20
+ return value;
21
+ case "M": {
22
+ const mapVal = value;
23
+ const obj = {};
24
+ for (const [k, v] of Object.entries(mapVal)) {
25
+ obj[k] = unwrapDynamoValue(v);
26
+ }
27
+ return obj;
28
+ }
29
+ case "L": {
30
+ const listVal = value;
31
+ return listVal.map((v) => unwrapDynamoValue(v));
32
+ }
33
+ case "NULL":
34
+ return null;
35
+ case "BOOL":
36
+ return value;
37
+ default:
38
+ return undefined;
39
+ }
40
+ }
41
+ /**
42
+ * Get the DynamoDB type descriptor for an attribute value.
43
+ * Returns the single-letter type: S, N, B, SS, NS, BS, M, L, NULL, BOOL
44
+ */
45
+ export function getDynamoType(attr) {
46
+ const key = Object.keys(attr)[0];
47
+ return key || "?";
48
+ }
49
+ /**
50
+ * Format a DynamoDB value for display.
51
+ * - For simple types (S, N, B, BOOL, NULL), return the value
52
+ * - For collections (SS, NS, BS), return comma-separated or wrapped in []
53
+ * - For maps/lists, return pretty JSON
54
+ * - For large values (>3 lines), truncate to 2 header + ... + 1 footer
55
+ * - For single/few line values, truncate at 50 chars with "..." suffix
56
+ */
57
+ export function formatDynamoValue(attr) {
58
+ const type = getDynamoType(attr);
59
+ const raw = unwrapDynamoValue(attr);
60
+ const key = Object.keys(attr)[0];
61
+ const value = attr[key];
62
+ if (type === "NULL")
63
+ return "null";
64
+ if (type === "BOOL")
65
+ return String(value);
66
+ if (type === "N")
67
+ return String(value);
68
+ if (type === "S") {
69
+ const str = String(value);
70
+ // Escape newlines for display
71
+ const escaped = str.replace(/\n/g, "\\n");
72
+ if (escaped.length > 50)
73
+ return escaped.slice(0, 47) + "...";
74
+ return escaped;
75
+ }
76
+ if (type === "B")
77
+ return `<binary: ${value?.length ?? 0} bytes>`;
78
+ if (type === "SS") {
79
+ return (value ?? []).join(", ");
80
+ }
81
+ if (type === "NS") {
82
+ return (value ?? []).join(", ");
83
+ }
84
+ if (type === "BS") {
85
+ return `<${(value ?? []).length} binary values>`;
86
+ }
87
+ if (type === "M") {
88
+ const json = JSON.stringify(raw, null, 2);
89
+ return truncateLargeValue(json);
90
+ }
91
+ if (type === "L") {
92
+ const json = JSON.stringify(raw, null, 2);
93
+ return truncateLargeValue(json);
94
+ }
95
+ return String(raw);
96
+ }
97
+ function truncateLargeValue(str) {
98
+ const lines = str.split("\n");
99
+ if (lines.length > 3) {
100
+ return `${lines[0]}\n${lines[1]}\n...\n${lines[lines.length - 1]}`;
101
+ }
102
+ // Single or few lines
103
+ const compact = str.replace(/\n/g, " ");
104
+ if (compact.length > 50)
105
+ return compact.slice(0, 47) + "...";
106
+ return compact;
107
+ }
108
+ /**
109
+ * Format billing mode: "On-Demand" or "Provisioned (R:X W:Y)"
110
+ */
111
+ export function formatBillingMode(table) {
112
+ const billing = table.BillingModeSummary?.BillingMode;
113
+ if (billing === "PAY_PER_REQUEST") {
114
+ return "On-Demand";
115
+ }
116
+ const r = table.ProvisionedThroughput?.ReadCapacityUnits ?? 0;
117
+ const w = table.ProvisionedThroughput?.WriteCapacityUnits ?? 0;
118
+ return `Provisioned (R:${r} W:${w})`;
119
+ }
120
+ /**
121
+ * Format key schema: "PK: userId, SK: timestamp" or just "PK: id"
122
+ */
123
+ export function formatKeySchema(table) {
124
+ const keys = table.KeySchema ?? [];
125
+ const hash = keys.find((k) => k.KeyType === "HASH");
126
+ const range = keys.find((k) => k.KeyType === "RANGE");
127
+ let result = "";
128
+ if (hash)
129
+ result += `PK: ${hash.AttributeName}`;
130
+ if (range)
131
+ result += (result ? ", " : "") + `SK: ${range.AttributeName}`;
132
+ return result || "-";
133
+ }
134
+ /**
135
+ * Extract primary key value from an item.
136
+ * Returns a JSON string like '{"userId": "abc123"}' or '{"id": "x", "sk": "y"}'
137
+ */
138
+ export function extractPrimaryKeyJson(item, table) {
139
+ const keys = table.KeySchema ?? [];
140
+ const pkObj = {};
141
+ for (const key of keys) {
142
+ const attrName = key.AttributeName;
143
+ const attr = item[attrName];
144
+ if (attr) {
145
+ pkObj[attrName] = unwrapDynamoValue(attr);
146
+ }
147
+ }
148
+ return JSON.stringify(pkObj);
149
+ }
150
+ /**
151
+ * Get the sort key value from an item for display.
152
+ * Returns unwrapped value or undefined.
153
+ */
154
+ export function extractSkValue(item, table) {
155
+ const rangeKey = table.KeySchema?.find((k) => k.KeyType === "RANGE");
156
+ if (!rangeKey)
157
+ return undefined;
158
+ const attr = item[rangeKey.AttributeName];
159
+ if (!attr)
160
+ return undefined;
161
+ return String(unwrapDynamoValue(attr));
162
+ }
163
+ /**
164
+ * Get the partition key value from an item for display.
165
+ * Returns unwrapped value or undefined.
166
+ */
167
+ export function extractPkValue(item, table) {
168
+ const hashKey = table.KeySchema?.find((k) => k.KeyType === "HASH");
169
+ if (!hashKey)
170
+ return undefined;
171
+ const attr = item[hashKey.AttributeName];
172
+ if (!attr)
173
+ return undefined;
174
+ return String(unwrapDynamoValue(attr));
175
+ }
@@ -1,22 +1,178 @@
1
1
  import { textCell } from "../../types.js";
2
- export function createRoute53ServiceAdapter() {
3
- const getColumns = () => [{ key: "name", label: "Name" }];
2
+ import { runAwsJsonAsync } from "../../utils/aws.js";
3
+ import { atom } from "jotai";
4
+ import { getDefaultStore } from "jotai";
5
+ import { createRoute53DetailCapability } from "./capabilities/detailCapability.js";
6
+ import { createRoute53YankCapability } from "./capabilities/yankCapability.js";
7
+ export const route53LevelAtom = atom({ kind: "zones" });
8
+ export const route53BackStackAtom = atom([]);
9
+ export function createRoute53ServiceAdapter(endpointUrl, region) {
10
+ const store = getDefaultStore();
11
+ const regionArgs = region ? ["--region", region] : [];
12
+ const getLevel = () => store.get(route53LevelAtom);
13
+ const setLevel = (level) => store.set(route53LevelAtom, level);
14
+ const getBackStack = () => store.get(route53BackStackAtom);
15
+ const setBackStack = (stack) => store.set(route53BackStackAtom, stack);
16
+ const getColumns = () => {
17
+ const level = getLevel();
18
+ if (level.kind === "zones") {
19
+ return [
20
+ { key: "name", label: "Name" },
21
+ { key: "zoneId", label: "Zone ID" },
22
+ { key: "recordCount", label: "Records" },
23
+ { key: "type", label: "Type", width: 10 },
24
+ ];
25
+ }
26
+ // records level
27
+ return [
28
+ { key: "name", label: "Name" },
29
+ { key: "type", label: "Type", width: 10 },
30
+ { key: "ttl", label: "TTL", width: 10 },
31
+ { key: "values", label: "Value(s)", width: 50 },
32
+ ];
33
+ };
4
34
  const getRows = async () => {
5
- return [{ id: "stub", cells: { name: textCell("Route53 not yet implemented") }, meta: {} }];
35
+ const level = getLevel();
36
+ if (level.kind === "zones") {
37
+ const data = await runAwsJsonAsync([
38
+ "route53",
39
+ "list-hosted-zones",
40
+ ...regionArgs,
41
+ ]);
42
+ return (data.HostedZones ?? []).map((zone) => {
43
+ const isPrivate = zone.Config?.PrivateZone ?? zone.HostedZoneConfig?.PrivateZone ?? false;
44
+ const shortZoneId = zone.Id.replace(/^\/hostedzone\//, "");
45
+ return {
46
+ id: zone.Id,
47
+ cells: {
48
+ name: textCell(zone.Name),
49
+ zoneId: textCell(shortZoneId),
50
+ recordCount: textCell(String(zone.ResourceRecordSetCount ?? 0)),
51
+ type: textCell(isPrivate ? "Private" : "Public"),
52
+ },
53
+ meta: {
54
+ type: "zone",
55
+ zoneId: shortZoneId,
56
+ zoneName: zone.Name,
57
+ isPrivate,
58
+ },
59
+ };
60
+ });
61
+ }
62
+ // records level
63
+ const { zoneId, zoneName } = level;
64
+ try {
65
+ const fullZoneId = zoneId.startsWith("/hostedzone/") ? zoneId : `/hostedzone/${zoneId}`;
66
+ const data = await runAwsJsonAsync([
67
+ "route53",
68
+ "list-resource-record-sets",
69
+ "--hosted-zone-id",
70
+ fullZoneId,
71
+ ...regionArgs,
72
+ ]);
73
+ return (data.ResourceRecordSets ?? []).map((record) => {
74
+ const values = [];
75
+ let valuesDisplay = "";
76
+ if (record.AliasTarget) {
77
+ valuesDisplay = `ALIAS → ${record.AliasTarget.DNSName}`;
78
+ }
79
+ else if (record.ResourceRecords && record.ResourceRecords.length > 0) {
80
+ record.ResourceRecords.forEach((r) => values.push(r.Value));
81
+ if (values.length <= 3) {
82
+ valuesDisplay = values.join(", ");
83
+ }
84
+ else {
85
+ valuesDisplay = `<${values.length} values>`;
86
+ }
87
+ }
88
+ return {
89
+ id: `${record.Name}-${record.Type}`,
90
+ cells: {
91
+ name: textCell(record.Name),
92
+ type: textCell(record.Type),
93
+ ttl: textCell(record.AliasTarget ? "-" : String(record.TTL ?? "-")),
94
+ values: textCell(valuesDisplay),
95
+ },
96
+ meta: {
97
+ type: "record",
98
+ zoneId,
99
+ zoneName,
100
+ recordName: record.Name,
101
+ recordType: record.Type,
102
+ recordTtl: record.TTL,
103
+ recordValues: values,
104
+ recordAliasTarget: record.AliasTarget,
105
+ },
106
+ };
107
+ });
108
+ }
109
+ catch {
110
+ return [];
111
+ }
6
112
  };
7
- const onSelect = async (_row) => {
113
+ const onSelect = async (row) => {
114
+ const level = getLevel();
115
+ const backStack = getBackStack();
116
+ const meta = row.meta;
117
+ if (level.kind === "zones") {
118
+ if (!meta || meta.type !== "zone") {
119
+ return { action: "none" };
120
+ }
121
+ const newStack = [...backStack, { level: level, selectedIndex: 0 }];
122
+ setBackStack(newStack);
123
+ setLevel({
124
+ kind: "records",
125
+ zoneId: meta.zoneId,
126
+ zoneName: meta.zoneName,
127
+ });
128
+ return { action: "navigate" };
129
+ }
130
+ // records level: leaf, no drill-down
8
131
  return { action: "none" };
9
132
  };
133
+ const canGoBack = () => getBackStack().length > 0;
134
+ const goBack = () => {
135
+ const backStack = getBackStack();
136
+ if (backStack.length > 0) {
137
+ const newStack = backStack.slice(0, -1);
138
+ const frame = backStack[backStack.length - 1];
139
+ setBackStack(newStack);
140
+ setLevel(frame.level);
141
+ }
142
+ };
143
+ const getPath = () => {
144
+ const level = getLevel();
145
+ if (level.kind === "zones")
146
+ return "route53://";
147
+ return `route53://${level.zoneName}`;
148
+ };
149
+ const getContextLabel = () => {
150
+ const level = getLevel();
151
+ if (level.kind === "zones")
152
+ return "🌐 Hosted Zones";
153
+ return `🌐 ${level.zoneName}`;
154
+ };
155
+ // Compose capabilities
156
+ const detailCapability = createRoute53DetailCapability(region, getLevel);
157
+ const yankCapability = createRoute53YankCapability(region, getLevel);
10
158
  return {
11
159
  id: "route53",
12
160
  label: "Route53",
13
- hudColor: { bg: "blue", fg: "white" },
161
+ hudColor: { bg: "cyan", fg: "black" },
14
162
  getColumns,
15
163
  getRows,
16
164
  onSelect,
17
- canGoBack: () => false,
18
- goBack: () => { },
19
- getPath: () => "/",
20
- getContextLabel: () => "🌐 DNS Records",
165
+ canGoBack,
166
+ goBack,
167
+ getPath,
168
+ getContextLabel,
169
+ reset() {
170
+ setLevel({ kind: "zones" });
171
+ setBackStack([]);
172
+ },
173
+ capabilities: {
174
+ detail: detailCapability,
175
+ yank: yankCapability,
176
+ },
21
177
  };
22
178
  }
@@ -0,0 +1,63 @@
1
+ import { runAwsJsonAsync } from "../../../utils/aws.js";
2
+ export function createRoute53DetailCapability(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
+ // Zone level
11
+ if (level?.kind === "zones" && meta.type === "zone") {
12
+ const fullZoneId = meta.zoneId.startsWith("/hostedzone/")
13
+ ? meta.zoneId
14
+ : `/hostedzone/${meta.zoneId}`;
15
+ const data = await runAwsJsonAsync([
16
+ "route53",
17
+ "get-hosted-zone",
18
+ "--id",
19
+ fullZoneId,
20
+ ...regionArgs,
21
+ ]);
22
+ const fields = [
23
+ { label: "Zone Name", value: data.Name ?? "-" },
24
+ { label: "Zone ID (Full)", value: data.Id ?? "-" },
25
+ { label: "Zone ID (Short)", value: meta.zoneId },
26
+ { label: "Record Count", value: String(data.ResourceRecordSetCount ?? 0) },
27
+ {
28
+ label: "Type",
29
+ value: meta.isPrivate ? "Private" : "Public",
30
+ },
31
+ { label: "Caller Reference", value: data.CallerReference ?? "-" },
32
+ ];
33
+ return fields;
34
+ }
35
+ // Record level
36
+ if (level?.kind === "records" && meta.type === "record") {
37
+ const fields = [
38
+ { label: "Record Name", value: meta.recordName ?? "-" },
39
+ { label: "Record Type", value: meta.recordType ?? "-" },
40
+ { label: "TTL", value: meta.recordAliasTarget ? "(Alias - no TTL)" : String(meta.recordTtl ?? "-") },
41
+ ];
42
+ if (meta.recordValues && meta.recordValues.length > 0) {
43
+ fields.push({
44
+ label: "Values",
45
+ value: meta.recordValues.join("\n"),
46
+ });
47
+ }
48
+ if (meta.recordAliasTarget) {
49
+ fields.push({
50
+ label: "Alias Target",
51
+ value: meta.recordAliasTarget.DNSName,
52
+ });
53
+ fields.push({
54
+ label: "Hosted Zone ID",
55
+ value: meta.recordAliasTarget.HostedZoneId,
56
+ });
57
+ }
58
+ return fields;
59
+ }
60
+ return [];
61
+ };
62
+ return { getDetails };
63
+ }
@@ -0,0 +1,6 @@
1
+ import { createYankCapability } from "../../../adapters/capabilities/YankCapability.js";
2
+ import { Route53RowMetaSchema } from "../schema.js";
3
+ import { Route53YankOptions } from "./yankOptions.js";
4
+ export function createRoute53YankCapability(_region, _getLevel) {
5
+ return createYankCapability(Route53YankOptions, Route53RowMetaSchema, {});
6
+ }
@@ -0,0 +1,58 @@
1
+ export const Route53YankOptions = [
2
+ // Zone options
3
+ {
4
+ trigger: { type: "key", char: "n" },
5
+ label: "Copy zone name",
6
+ feedback: "Copied zone name",
7
+ isRelevant: (row) => row.meta.type === "zone",
8
+ resolve: async (row) => {
9
+ return row.meta.zoneName ?? null;
10
+ },
11
+ },
12
+ {
13
+ trigger: { type: "key", char: "i" },
14
+ label: "Copy zone ID",
15
+ feedback: "Copied zone ID",
16
+ isRelevant: (row) => row.meta.type === "zone",
17
+ resolve: async (row) => {
18
+ return row.meta.zoneId ?? null;
19
+ },
20
+ },
21
+ // Record options
22
+ {
23
+ trigger: { type: "key", char: "n" },
24
+ label: "Copy record name",
25
+ feedback: "Copied record name",
26
+ isRelevant: (row) => row.meta.type === "record",
27
+ resolve: async (row) => {
28
+ return row.meta.recordName ?? null;
29
+ },
30
+ },
31
+ {
32
+ trigger: { type: "key", char: "v" },
33
+ label: "Copy first value",
34
+ feedback: "Copied first value",
35
+ isRelevant: (row) => {
36
+ return row.meta.type === "record" && (row.meta.recordValues?.length ?? 0) > 0;
37
+ },
38
+ resolve: async (row) => {
39
+ return row.meta.recordValues?.[0] ?? null;
40
+ },
41
+ },
42
+ {
43
+ trigger: { type: "key", char: "b" },
44
+ label: "Copy in BIND format",
45
+ feedback: "Copied BIND format",
46
+ isRelevant: (row) => row.meta.type === "record",
47
+ resolve: async (row) => {
48
+ const name = row.meta.recordName ?? "";
49
+ const type = row.meta.recordType ?? "";
50
+ const ttl = row.meta.recordTtl ?? 3600;
51
+ const value = row.meta.recordValues?.[0] ?? "";
52
+ if (row.meta.recordAliasTarget) {
53
+ return `${name} ALIAS ${row.meta.recordAliasTarget.DNSName}`;
54
+ }
55
+ return `${name} ${ttl} IN ${type} ${value}`;
56
+ },
57
+ },
58
+ ];
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ export const Route53RowMetaSchema = z.object({
3
+ type: z.enum(["zone", "record"]),
4
+ zoneId: z.string().optional(),
5
+ zoneName: z.string().optional(),
6
+ isPrivate: z.boolean().optional(),
7
+ recordName: z.string().optional(),
8
+ recordType: z.string().optional(),
9
+ recordTtl: z.number().optional(),
10
+ recordValues: z.array(z.string()).optional(),
11
+ recordAliasTarget: z
12
+ .object({
13
+ HostedZoneId: z.string(),
14
+ DNSName: z.string(),
15
+ EvaluateTargetHealth: z.boolean(),
16
+ })
17
+ .optional(),
18
+ });
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a9s/cli",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "k9s-style TUI navigator for AWS services",
5
5
  "keywords": [
6
6
  "aws",