@feasibleone/blong-gogo 1.30.0 → 1.31.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.31.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.30.0...blong-gogo-v1.31.0) (2026-09-07)
4
+
5
+
6
+ ### Features
7
+
8
+ * blong-commander ([#163](https://github.com/feasibleone/blong/issues/163)) ([b7fe11b](https://github.com/feasibleone/blong/commit/b7fe11b44b1d1ee0e14e4be96523d403b59311c2))
9
+
3
10
  ## [1.30.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.29.0...blong-gogo-v1.30.0) (2026-08-21)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -43,6 +43,51 @@ const errorMap: IErrorMap = {
43
43
 
44
44
  let _errors: Errors<typeof errorMap>;
45
45
 
46
+ /**
47
+ * Commander explorer categories for namespaced resources. Each category groups
48
+ * the resource types the adapter can list (`{ns}.<resource>.find`). The
49
+ * category / resource levels are synthetic navigation (no cluster calls).
50
+ */
51
+ const CATEGORIES: Array<{name: string; label: string; resources: Array<{type: string; label: string}>}> = [
52
+ {
53
+ name: 'workloads',
54
+ label: 'Workloads',
55
+ resources: [
56
+ {type: 'deployment', label: 'Deployments'},
57
+ {type: 'replicaset', label: 'ReplicaSets'},
58
+ {type: 'daemonset', label: 'DaemonSets'},
59
+ {type: 'statefulset', label: 'StatefulSets'},
60
+ {type: 'pod', label: 'Pods'},
61
+ ],
62
+ },
63
+ {
64
+ name: 'networking',
65
+ label: 'Networking',
66
+ resources: [
67
+ {type: 'service', label: 'Services'},
68
+ {type: 'ingress', label: 'Ingresses'},
69
+ {type: 'networkpolicy', label: 'NetworkPolicies'},
70
+ ],
71
+ },
72
+ {
73
+ name: 'storage',
74
+ label: 'Storage',
75
+ resources: [
76
+ {type: 'persistentvolume', label: 'PersistentVolumes'},
77
+ {type: 'persistentvolumeclaim', label: 'PersistentVolumeClaims'},
78
+ {type: 'storageclass', label: 'StorageClasses'},
79
+ ],
80
+ },
81
+ {
82
+ name: 'configuration',
83
+ label: 'Configuration',
84
+ resources: [
85
+ {type: 'configmap', label: 'ConfigMaps'},
86
+ {type: 'secret', label: 'Secrets'},
87
+ ],
88
+ },
89
+ ];
90
+
46
91
  export default adapter<IConfig>(({utError}) => {
47
92
  _errors ||= utError.register(errorMap);
48
93
 
@@ -318,6 +363,29 @@ export default adapter<IConfig>(({utError}) => {
318
363
  };
319
364
 
320
365
  try {
366
+ // Commander explorer navigation levels (synthetic, no cluster calls):
367
+ // `{ns}.category.list` → the resource categories
368
+ // `{ns}.resource.list` → the resource types within a category
369
+ if (_resourceType === 'category' && operation === 'list') {
370
+ const ns =
371
+ (!Array.isArray(params) && params.namespace) ||
372
+ this.config.k8s.namespace ||
373
+ 'default';
374
+ return {items: CATEGORIES.map(c => ({category: c.name, label: c.label, namespace: ns}))};
375
+ }
376
+ if (_resourceType === 'resource' && operation === 'list') {
377
+ const category =
378
+ !Array.isArray(params) ? (params.category as string | undefined) : undefined;
379
+ const ns =
380
+ (!Array.isArray(params) && params.namespace) ||
381
+ this.config.k8s.namespace ||
382
+ 'default';
383
+ const cat = CATEGORIES.find(c => c.name === category);
384
+ const resources = cat?.resources ?? [];
385
+ return {
386
+ items: resources.map(r => ({resourceType: r.type, label: r.label, namespace: ns})),
387
+ };
388
+ }
321
389
  switch (operation) {
322
390
  case 'get': {
323
391
  // Get single resource
@@ -351,6 +419,28 @@ export default adapter<IConfig>(({utError}) => {
351
419
  ...(continueToken && {continue: continueToken}),
352
420
  });
353
421
  }
422
+ case 'log': {
423
+ // Read pod container logs (`{ns}.pod.log`)
424
+ if (Array.isArray(params)) {
425
+ throw this.error(_errors['k8s.invalid'](), $meta);
426
+ }
427
+ if (resourceType !== 'pod') {
428
+ throw this.error(_errors['k8s.invalid'](), $meta);
429
+ }
430
+ const {name, container, tailLines, sinceSeconds, follow = false} = params;
431
+ if (!name) {
432
+ throw this.error(_errors['k8s.missingKey']({key: 'name'}), $meta);
433
+ }
434
+ const result = await this.config.context.coreV1Api!.readNamespacedPodLog({
435
+ name: name as string,
436
+ namespace,
437
+ container: container as string | undefined,
438
+ follow: follow as boolean,
439
+ tailLines: tailLines as number | undefined,
440
+ sinceSeconds: sinceSeconds as number | undefined,
441
+ });
442
+ return {logs: result};
443
+ }
354
444
  case 'create':
355
445
  case 'add': {
356
446
  // Create resource
@@ -1,5 +1,5 @@
1
- import {adapter} from '@feasibleone/blong/types';
2
- import Kafka from 'node-rdkafka';
1
+ import {adapter, type IMeta} from '@feasibleone/blong/types';
2
+ import Kafka, {type Message} from 'node-rdkafka';
3
3
  import {Duplex} from 'stream';
4
4
 
5
5
  type KafkaConfig = ConstructorParameters<typeof Kafka.KafkaConsumer>[0];
@@ -18,6 +18,18 @@ export interface IConfig {
18
18
  codec?: {
19
19
  new (config: object): CodecInstance;
20
20
  };
21
+ /**
22
+ * Operation mode.
23
+ * - `'stream'` (default): a produce/consume message adapter — every triple
24
+ * is routed through the Kafka stream (the request is encoded and produced
25
+ * to `consume.topics`, the response is consumed + decoded). This is the
26
+ * original design for message round-trips.
27
+ * - `'admin'`: an introspection adapter — triples are routed to `exec`
28
+ * (like the API adapters: `super.connect()` → `handle()` → `exec`), so
29
+ * `{ns}.topic.list` (broker metadata) and `{ns}.topic.find` (message
30
+ * reads via one-off consumers) are reachable. No produce/consume stream.
31
+ */
32
+ mode?: 'stream' | 'admin';
21
33
  }
22
34
 
23
35
  export default adapter<IConfig>(() => {
@@ -30,6 +42,13 @@ export default adapter<IConfig>(() => {
30
42
  isConnected(): boolean;
31
43
  once(event: 'ready', cb: () => void): void;
32
44
  assignments(): {partition: number; topic: string; offset: number}[];
45
+ getMetadata(
46
+ opts: {timeout: number},
47
+ cb: (
48
+ err: Error | null,
49
+ data?: {topics?: Array<{name: string; partitions: unknown[]}>},
50
+ ) => void,
51
+ ): void;
33
52
  };
34
53
  })
35
54
  | null = null;
@@ -49,7 +68,12 @@ export default adapter<IConfig>(() => {
49
68
  async start() {
50
69
  const result = await super.start();
51
70
 
52
- if (this.config.codec) {
71
+ const isAdmin = this.config.mode === 'admin';
72
+ // The codec encodes/decodes the Kafka stream message format. Admin
73
+ // (introspection) mode routes triples to `exec` (plain object
74
+ // responses), so the codec must NOT be applied there — a stream
75
+ // codec would try `msg.value.toString()` on the exec result.
76
+ if (this.config.codec && !isAdmin) {
53
77
  codec = new this.config.codec({});
54
78
  this.encode = (...params) => codec!.encode(...params);
55
79
  this.decode = (...params) => codec!.decode(...params);
@@ -57,11 +81,24 @@ export default adapter<IConfig>(() => {
57
81
  codec = null;
58
82
  }
59
83
 
60
- consumerStream = Kafka.KafkaConsumer.createReadStream(
84
+ const groupId = this.config.consume.groupId;
85
+ const startedAt = Date.now();
86
+ const consumerConfig = {
87
+ ...this.config.connection,
88
+ 'group.id': groupId,
89
+ };
90
+ this.log?.info?.(
61
91
  {
62
- ...this.config.connection,
63
- 'group.id': this.config.consume.groupId,
92
+ groupId,
93
+ topics: this.config.consume.topics,
94
+ sessionTimeoutMs: consumerConfig['session.timeout.ms'],
95
+ broker: consumerConfig['metadata.broker.list'],
64
96
  },
97
+ 'kafka consumer creating',
98
+ );
99
+
100
+ consumerStream = Kafka.KafkaConsumer.createReadStream(
101
+ consumerConfig,
65
102
  {
66
103
  'auto.offset.reset': 'earliest',
67
104
  },
@@ -83,49 +120,93 @@ export default adapter<IConfig>(() => {
83
120
  poll = setInterval(() => {
84
121
  if (consumerStream!.consumer.assignments().length > 0) {
85
122
  cleanup();
123
+ this.log?.info?.(
124
+ {groupId, elapsedMs: Date.now() - startedAt},
125
+ 'kafka consumer assigned',
126
+ );
86
127
  resolve();
87
128
  }
88
129
  }, 200);
89
130
  safety = setTimeout(() => {
90
131
  cleanup();
132
+ this.log?.warn?.(
133
+ {
134
+ groupId,
135
+ elapsedMs: Date.now() - startedAt,
136
+ hint: 'stale group members from an earlier abrupt shutdown keep the rebalance waiting up to session.timeout.ms',
137
+ },
138
+ 'kafka consumer assignment timed out',
139
+ );
140
+ // Best-effort graceful close BEFORE failing start: a
141
+ // consumer that joined the group but never got an
142
+ // assignment would otherwise linger as a stale group
143
+ // member (no LeaveGroup), blocking the NEXT rebalance
144
+ // for up to session.timeout.ms too. destroy() →
145
+ // close() → disconnect() sends LeaveGroup.
146
+ try {
147
+ consumerStream?.destroy();
148
+ } catch {
149
+ // ignore — the process is already failing start
150
+ }
91
151
  reject(new Error('Kafka assignment timeout'));
92
152
  }, 30000);
93
153
  };
94
154
  if (consumerStream!.consumer.isConnected()) {
155
+ this.log?.info?.({groupId}, 'kafka consumer already connected');
95
156
  startPolling();
96
157
  } else {
97
- consumerStream!.consumer.once('ready', startPolling);
158
+ this.log?.info?.({groupId}, 'kafka consumer connecting, waiting for ready');
159
+ consumerStream!.consumer.once('ready', () => {
160
+ this.log?.info?.(
161
+ {groupId, elapsedMs: Date.now() - startedAt},
162
+ 'kafka consumer ready',
163
+ );
164
+ startPolling();
165
+ });
98
166
  }
99
167
  });
100
168
 
101
- producerStream = Kafka.Producer.createWriteStream(
102
- {
103
- ...this.config.connection,
104
- },
105
- {},
106
- {
107
- objectMode: true,
108
- },
109
- );
169
+ if (this.config.mode === 'admin') {
170
+ // Admin/introspection mode — route triples to `exec`
171
+ // (`super.connect()` → `handle()` → `findHandler(method) ||
172
+ // imported['exec']`). The consumer stays connected for broker
173
+ // metadata (`{ns}.topic.list` uses `getMetadata`); the
174
+ // produce/consume Duplex is not built and no messages are
175
+ // consumed by this adapter instance.
176
+ super.connect();
177
+ } else {
178
+ producerStream = Kafka.Producer.createWriteStream(
179
+ {
180
+ ...this.config.connection,
181
+ },
182
+ {},
183
+ {
184
+ objectMode: true,
185
+ },
186
+ );
110
187
 
111
- // Build a custom Duplex that writes to the Kafka producer and
112
- // receives messages from the Kafka consumer via 'data' events.
113
- // Duplex.from({readable, writable}) is not used because it does
114
- // not reliably forward object-mode events from the inner readable.
115
- stream = new Duplex({objectMode: true, read() {}});
116
- stream.write = (chunk, ...args) =>
117
- (producerStream!.write as (...a: unknown[]) => boolean)(chunk, ...args);
188
+ // Build a custom Duplex that writes to the Kafka producer and
189
+ // receives messages from the Kafka consumer via 'data' events.
190
+ // Duplex.from({readable, writable}) is not used because it does
191
+ // not reliably forward object-mode events from the inner readable.
192
+ stream = new Duplex({objectMode: true, read() {}});
193
+ stream.write = (chunk, ...args) =>
194
+ (producerStream!.write as (...a: unknown[]) => boolean)(chunk, ...args);
118
195
 
119
- consumerStream!.on('data', msg => stream?.push(msg));
120
- consumerStream!.on('end', () => stream?.push(null));
121
- consumerStream!.on('error', err => stream?.destroy(err as Error));
196
+ consumerStream!.on('data', msg => stream?.push(msg));
197
+ consumerStream!.on('end', () => stream?.push(null));
198
+ consumerStream!.on('error', err => stream?.destroy(err as Error));
122
199
 
123
- super.connect(stream);
200
+ super.connect(stream);
201
+ }
124
202
 
125
203
  return result;
126
204
  },
127
205
 
128
206
  async stop(...params: unknown[]) {
207
+ const groupId = this.config.consume.groupId;
208
+ const stopStartedAt = Date.now();
209
+ this.log?.info?.({groupId}, 'kafka adapter stop');
129
210
  const awaitClose = (
130
211
  s: {once(e: 'close', cb: () => void): void} | null,
131
212
  disconnect: () => void,
@@ -134,9 +215,27 @@ export default adapter<IConfig>(() => {
134
215
  !s
135
216
  ? Promise.resolve()
136
217
  : new Promise<void>(resolve => {
137
- const t = setTimeout(resolve, ms);
218
+ const t = setTimeout(() => {
219
+ this.log?.warn?.(
220
+ {
221
+ groupId,
222
+ waitMs: ms,
223
+ elapsedMs: Date.now() - stopStartedAt,
224
+ hint: 'consumer/producer did not emit close — the group member was not gracefully removed',
225
+ },
226
+ 'kafka close timed out',
227
+ );
228
+ resolve();
229
+ }, ms);
138
230
  s.once('close', () => {
139
231
  clearTimeout(t);
232
+ this.log?.info?.(
233
+ {
234
+ groupId,
235
+ elapsedMs: Date.now() - stopStartedAt,
236
+ },
237
+ 'kafka consumer/producer closed (LeaveGroup sent)',
238
+ );
140
239
  resolve();
141
240
  });
142
241
  disconnect();
@@ -156,6 +255,10 @@ export default adapter<IConfig>(() => {
156
255
  20000,
157
256
  ),
158
257
  ]);
258
+ this.log?.info?.(
259
+ {groupId, elapsedMs: Date.now() - stopStartedAt},
260
+ 'kafka adapter stop complete',
261
+ );
159
262
  } finally {
160
263
  stream = null;
161
264
  codec = null;
@@ -165,5 +268,103 @@ export default adapter<IConfig>(() => {
165
268
  }
166
269
  return result;
167
270
  },
271
+
272
+ async exec(
273
+ params: Record<string, unknown>,
274
+ $meta: IMeta,
275
+ ): Promise<unknown> {
276
+ const {method} = $meta;
277
+ const [, object, operation] = method!.split('.');
278
+ if (object === 'topic') {
279
+ switch (operation) {
280
+ case 'list': {
281
+ // `{ns}.topic.list` — enumerate topics from broker metadata
282
+ if (!consumerStream) {
283
+ throw new Error('Kafka consumer not connected');
284
+ }
285
+ const metadata = await new Promise<{
286
+ topics: Array<{name: string; partitions: unknown[]}>;
287
+ }>((resolve, reject) => {
288
+ consumerStream!.consumer.getMetadata(
289
+ {timeout: 5000, allTopics: true},
290
+ (err, data) => (err ? reject(err) : resolve(data ?? {topics: []})),
291
+ );
292
+ });
293
+ return {
294
+ items: (metadata.topics ?? [])
295
+ .filter(t => !t.name.startsWith('__'))
296
+ .map(t => ({
297
+ topic: t.name,
298
+ partitionCount: t.partitions?.length ?? 0,
299
+ })),
300
+ };
301
+ }
302
+ case 'find': {
303
+ // `{ns}.topic.find` — read a batch of messages from a topic.
304
+ // A fresh consumer group with `auto.offset.reset: earliest`
305
+ // reads existing messages from the beginning (exploration);
306
+ // partitions are surfaced as message metadata, not tree nodes.
307
+ const topic = params.topic as string;
308
+ const limit = (params.limit as number) ?? 50;
309
+ if (!topic) {
310
+ throw new Error('Missing topic param');
311
+ }
312
+ const consumer = new Kafka.KafkaConsumer(
313
+ {
314
+ ...this.config.connection,
315
+ 'group.id': `blong-commander-${Date.now()}`,
316
+ },
317
+ {'auto.offset.reset': 'earliest'},
318
+ );
319
+ // A fresh group must complete the rebalance (group join +
320
+ // partition assignment) before the first consume returns.
321
+ consumer.setDefaultConsumeTimeout(3000);
322
+ // Always RESOLVE to a (possibly empty) batch — a rebalance
323
+ // stall or broker hiccup must surface as an empty topic, not
324
+ // as a malformed/empty RPC response ("JSON RPC response
325
+ // without response and error").
326
+ const messages = await new Promise<Message[] | undefined>(resolve => {
327
+ const timer = setTimeout(() => {
328
+ try {
329
+ consumer.disconnect();
330
+ } catch {
331
+ // ignore
332
+ }
333
+ resolve(undefined);
334
+ }, 15000);
335
+ const done = (msgs: Message[] | undefined) => {
336
+ clearTimeout(timer);
337
+ try {
338
+ consumer.disconnect();
339
+ } catch {
340
+ // ignore
341
+ }
342
+ resolve(msgs);
343
+ };
344
+ consumer.on('ready', () => {
345
+ consumer.subscribe([topic]);
346
+ consumer.consume(limit, (err, msgs) => {
347
+ if (err) return done(undefined);
348
+ done(msgs);
349
+ });
350
+ });
351
+ consumer.on('event.error', () => done(undefined));
352
+ consumer.connect();
353
+ });
354
+ return {
355
+ items: (messages ?? []).map(m => ({
356
+ topic: m.topic,
357
+ partition: m.partition,
358
+ offset: m.offset,
359
+ key: m.key?.toString(),
360
+ value: m.value?.toString(),
361
+ timestamp: m.timestamp,
362
+ })),
363
+ };
364
+ }
365
+ }
366
+ }
367
+ throw new Error(`Unknown kafka operation: ${object}.${operation}`);
368
+ },
168
369
  };
169
370
  });
@@ -407,6 +407,51 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
407
407
  this as unknown as {_dropdownList(s: string): Promise<unknown>}
408
408
  )._dropdownList(subject);
409
409
  }
410
+ // Structure discovery for the commander explorer:
411
+ // `{ns}.schema.list` — databases/schemas on the server
412
+ // `{ns}.table.list` — tables in a schema (params.schema)
413
+ if (object === 'schema' && operation === 'list') {
414
+ const qb = this.config.context.queryBuilder!;
415
+ const rows = await qb
416
+ .select('SCHEMA_NAME as schemaName')
417
+ .from('information_schema.schemata')
418
+ .orderBy('SCHEMA_NAME');
419
+ return {items: rows as Array<{schemaName: string}>};
420
+ }
421
+ if (object === 'table' && operation === 'list') {
422
+ const qb = this.config.context.queryBuilder!;
423
+ // When no schema is given, scope to the adapter's own database so
424
+ // the explorer lists the app's tables only — not every schema the
425
+ // connection can see (information_schema, mysql, other realms' DBs).
426
+ const schema = (params as Record<string, unknown>).schema as string | undefined;
427
+ const database =
428
+ schema ?? (this.config.knex?.connection as {database?: string} | undefined)?.database;
429
+ let query = qb
430
+ .select('TABLE_NAME as tableName', 'TABLE_TYPE as tableType')
431
+ .from('information_schema.tables')
432
+ .orderBy('TABLE_NAME');
433
+ if (database) query = query.where('TABLE_SCHEMA', database);
434
+ const rows = await query;
435
+ // Drop leftover template/placeholder tables (e.g. `$subject_$object`
436
+ // from an unresolved schema seed) — they are not real, queryable
437
+ // tables and clicking them would surface a "table doesn't exist".
438
+ const junk = /[\${}]/;
439
+ // Only the tables this subject owns (`{subject}_*`) are reachable
440
+ // via the generic `{subject}.{object}` CRUD triples; strip the
441
+ // `{subject}_` prefix so `access.{tableName}.find` resolves to the
442
+ // actual table (`access.user.find` → `access_user`), instead of
443
+ // double-prefixing (`access.access_user.find` → `access_access_user`).
444
+ const prefix = `${subject}_`;
445
+ return {
446
+ items: (rows as Array<{tableName: string; tableType: string}>)
447
+ .filter(row => !junk.test(row.tableName))
448
+ .filter(row => row.tableName.startsWith(prefix))
449
+ .map(row => ({
450
+ tableName: row.tableName.slice(prefix.length),
451
+ tableType: row.tableType,
452
+ })),
453
+ };
454
+ }
410
455
  const table = `${subject}_${object}`;
411
456
  switch (operation) {
412
457
  case 'get': {
@@ -70,12 +70,18 @@ export default adapter<IConfig>(({utError}) => {
70
70
  const {method} = $meta;
71
71
  const [, _table, operation] = method!.split('.');
72
72
  let table = _table;
73
+ let dbName: string | undefined;
74
+ // `{ns}.collection.*` triples carry `{database, collection}`; the
75
+ // collection is the table, the database selects the DB (and must NOT
76
+ // leak into the WHERE filter — previously docs were filtered by a
77
+ // literal `database` field, yielding empty lists).
73
78
  if (!Array.isArray(params) && _table === 'collection') {
74
- const {collection, ...rest} = params;
79
+ const {collection, database, ...rest} = params;
75
80
  if (collection) {
76
81
  table = collection;
77
82
  params = rest;
78
83
  }
84
+ dbName = database as string | undefined;
79
85
  }
80
86
  const key = table.split(/\W/, 1)[0] + 'Id';
81
87
  switch (operation) {
@@ -86,8 +92,8 @@ export default adapter<IConfig>(({utError}) => {
86
92
  }
87
93
  const nonArrayParams = params as Record<string, unknown>;
88
94
  const {select = '*', sort, [key]: _id, ...where} = nonArrayParams;
89
- return this.config.context
90
- .mongodb!.db()
95
+ const doc = await this.config.context
96
+ .mongodb!.db(dbName)
91
97
  .collection(table)
92
98
  .findOne(
93
99
  {
@@ -108,6 +114,11 @@ export default adapter<IConfig>(({utError}) => {
108
114
  sort: sort as import('mongodb').Sort | undefined,
109
115
  },
110
116
  );
117
+ // Surface a string `id` (mongo's `_id` is an ObjectId object,
118
+ // dropped by the commander's scalar-only flattening).
119
+ return doc
120
+ ? {...doc, id: String((doc as {_id?: unknown})._id ?? '')}
121
+ : doc;
111
122
  }
112
123
  case 'find': {
113
124
  // find multiple documents
@@ -115,8 +126,8 @@ export default adapter<IConfig>(({utError}) => {
115
126
  throw this.error(_errors['mongodb.invalid'](), $meta);
116
127
  }
117
128
  const {select = '*', order, limit, offset, [key]: _id, ...where} = params;
118
- return this.config.context
119
- .mongodb!.db()
129
+ const docs = await this.config.context
130
+ .mongodb!.db(dbName)
120
131
  .collection(table)
121
132
  .find(
122
133
  {
@@ -152,6 +163,8 @@ export default adapter<IConfig>(({utError}) => {
152
163
  },
153
164
  )
154
165
  .toArray();
166
+ // Surface a string `id` for the commander explorer rows.
167
+ return docs.map(doc => ({...doc, id: String((doc as {_id?: unknown})._id ?? '')}));
155
168
  }
156
169
  case 'add': {
157
170
  // add single document
@@ -234,6 +247,47 @@ export default adapter<IConfig>(({utError}) => {
234
247
  .mongodb!.db()
235
248
  .collection(table)
236
249
  .deleteMany(params as Filter<BSON.Document>);
250
+ case 'list': {
251
+ // Enumeration for the commander explorer:
252
+ // `{ns}.database.list` → databases on the server
253
+ // `{ns}.collection.list` → collections in a database (params.database)
254
+ if (Array.isArray(params)) {
255
+ throw this.error(_errors['mongodb.invalid'](), $meta);
256
+ }
257
+ if (_table === 'database') {
258
+ const result = await this.config.context
259
+ .mongodb!.db()
260
+ .admin()
261
+ .listDatabases();
262
+ return {
263
+ items:
264
+ result.databases?.map(db => ({
265
+ database: db.name,
266
+ sizeOnDisk: db.sizeOnDisk,
267
+ empty: db.empty,
268
+ })) ?? [],
269
+ };
270
+ }
271
+ if (_table === 'collection') {
272
+ const database = (params as Record<string, unknown>).database as
273
+ | string
274
+ | undefined;
275
+ const collections = await this.config.context
276
+ .mongodb!.db(database)
277
+ .listCollections()
278
+ .toArray();
279
+ return {
280
+ items: collections.map(c => ({
281
+ collection: c.name,
282
+ type: c.type,
283
+ // thread the DB so deeper levels can resolve
284
+ // `{parent.database}`
285
+ database,
286
+ })),
287
+ };
288
+ }
289
+ throw this.error(_errors['mongodb.invalid'](), $meta);
290
+ }
237
291
  }
238
292
  throw this.error(_errors['mongodb.generic'](), $meta);
239
293
  },
@@ -1,7 +1,6 @@
1
- import type {IMeta, Adapter} from '@feasibleone/blong/types';
1
+ import type {Adapter, IMeta} from '@feasibleone/blong/types';
2
2
  import {adapter, type Errors, type IErrorMap} from '@feasibleone/blong/types';
3
- import Redis from 'ioredis';
4
- import {Cluster} from 'ioredis';
3
+ import Redis, {Cluster} from 'ioredis';
5
4
 
6
5
  export interface IConfig {
7
6
  /**
@@ -49,6 +48,7 @@ export interface IRedisClient {
49
48
  hincrby(key: string, field: string, increment: number): Promise<number>;
50
49
  hdel(key: string, ...fields: string[]): Promise<number>;
51
50
  eval(script: string, numKeys: number, ...keysAndArgs: unknown[]): Promise<unknown>;
51
+ scan(cursor: string, ...args: unknown[]): Promise<[string, string[]]>;
52
52
  quit(): Promise<unknown>;
53
53
  }
54
54
 
@@ -121,6 +121,20 @@ export default adapter<IConfig>(({utError}) => {
121
121
  expired: (await redis.expire(params.keyName as string, params.seconds as number)) === 1,
122
122
  }),
123
123
  ttl: async params => ({ttl: await redis.ttl(params.keyName as string)}),
124
+ list: async params => {
125
+ const pattern = (params.pattern as string) ?? '*';
126
+ const count = (params.count as number) ?? 100;
127
+ const limit = (params.limit as number) ?? 1000;
128
+ const cursor = (params.cursor as string) ?? '0';
129
+ const keyNames: string[] = [];
130
+ let next = cursor;
131
+ do {
132
+ const [newCursor, batch] = await redis.scan(next, 'MATCH', pattern, 'COUNT', count);
133
+ keyNames.push(...batch);
134
+ next = newCursor;
135
+ } while (next !== '0' && keyNames.length < limit);
136
+ return {items: keyNames.slice(0, limit).map(keyName => ({keyName})), cursor: next};
137
+ },
124
138
  };
125
139
 
126
140
  // Generic hash operations: redis.hash.getAll|get|set|incrBy|del
@@ -185,6 +199,15 @@ export default adapter<IConfig>(({utError}) => {
185
199
  } catch {
186
200
  // Best-effort: a lazy client that never connected may reject quit().
187
201
  }
202
+ try {
203
+ // `quit()` waits for the QUIT round-trip and can leave the socket
204
+ // open when the client is mid-connect/reconnect (the commander tap
205
+ // test observed a lingering 6379 socket after stop). `disconnect()`
206
+ // force-closes without waiting, guaranteeing the handle is released.
207
+ (redis as {disconnect?: () => void})?.disconnect?.();
208
+ } catch {
209
+ // ignore
210
+ }
188
211
  return super.stop();
189
212
  },
190
213
  /**
@@ -194,8 +217,7 @@ export default adapter<IConfig>(({utError}) => {
194
217
  async configChanged(diff: Map<string, {prev: unknown; next: unknown}>, next: unknown) {
195
218
  const redisChanged = Array.from(diff.keys()).some(
196
219
  (key: string) =>
197
- key === this.config.id + '.redis' ||
198
- key.startsWith(this.config.id + '.redis.'),
220
+ key === this.config.id + '.redis' || key.startsWith(this.config.id + '.redis.'),
199
221
  );
200
222
  if (!redisChanged) return;
201
223
  const newAdapterConfig = (next as Record<string, unknown>)?.[this.config.id] as
@@ -217,6 +239,12 @@ export default adapter<IConfig>(({utError}) => {
217
239
  const operation = parts[2];
218
240
  try {
219
241
  await ensureConnected();
242
+ // `{ns}.database.list` — enumerate the logical databases this
243
+ // source exposes (the configured db index).
244
+ if (object === 'database' && operation === 'list') {
245
+ const db = (this.config as {redis?: IConfig}).redis?.db ?? 0;
246
+ return {items: [{db}]};
247
+ }
220
248
  const ops =
221
249
  object === 'key'
222
250
  ? keyOps
@@ -3,6 +3,7 @@ import {
3
3
  DeleteObjectCommand,
4
4
  GetObjectCommand,
5
5
  HeadObjectCommand,
6
+ ListBucketsCommand,
6
7
  ListObjectsV2Command,
7
8
  PutObjectCommand,
8
9
  S3Client,
@@ -89,7 +90,7 @@ export default adapter<IConfig>(({utError}) => {
89
90
  $meta: IMeta,
90
91
  ) {
91
92
  const {method} = $meta;
92
- const [, , operation] = method!.split('.');
93
+ const [, object, operation] = method!.split('.');
93
94
  let bucket: string | undefined;
94
95
  let actualParams = params;
95
96
 
@@ -99,7 +100,10 @@ export default adapter<IConfig>(({utError}) => {
99
100
  actualParams = rest;
100
101
  }
101
102
 
102
- if (!bucket && !this.config.bucket?.Bucket) {
103
+ // Bucket enumeration (`s3.bucket.list`) does not need a target bucket.
104
+ const needsBucket =
105
+ !(object === 'bucket' && (operation === 'list' || operation === 'find'));
106
+ if (!bucket && !this.config.bucket?.Bucket && needsBucket) {
103
107
  throw this.error(_errors['s3.missingBucket'](), $meta);
104
108
  }
105
109
 
@@ -203,6 +207,18 @@ export default adapter<IConfig>(({utError}) => {
203
207
  if (Array.isArray(actualParams)) {
204
208
  throw this.error(_errors['s3.invalid'](), $meta);
205
209
  }
210
+ // `s3.bucket.list` — enumerate buckets on the endpoint
211
+ if (object === 'bucket') {
212
+ const command = new ListBucketsCommand({});
213
+ const response = await this.config.context.s3!.send(command);
214
+ return {
215
+ items:
216
+ response.Buckets?.map(b => ({
217
+ bucket: b.Name,
218
+ creationDate: b.CreationDate,
219
+ })) ?? [],
220
+ };
221
+ }
206
222
  const {prefix, maxKeys = 1000} = actualParams;
207
223
 
208
224
  const command = new ListObjectsV2Command({
@@ -32,6 +32,25 @@ const errorMap: IErrorMap = {
32
32
 
33
33
  let _errors: Errors<typeof errorMap>;
34
34
 
35
+ // KV v2 secret-engine mount paths (trailing slash, e.g. `secret/`), discovered
36
+ // from `vault.mount.list`. KV v2 stores the actual secrets under `metadata/`
37
+ // (listing) / `data/` (reading) instead of the mount root.
38
+ const kv2Mounts = new Set<string>();
39
+
40
+ /** Collapse doubled separators from `{parent.path}/{key}` joins. */
41
+ function normalizePath(path: string): string {
42
+ return path.replace(/\/{2,}/g, '/');
43
+ }
44
+
45
+ /** The KV v2 mount a path belongs to (e.g. `secret/`), or null. */
46
+ function kv2MountFor(path: string): string | null {
47
+ const normalized = normalizePath(path);
48
+ for (const mount of kv2Mounts) {
49
+ if (normalized === mount || normalized.startsWith(mount)) return mount;
50
+ }
51
+ return null;
52
+ }
53
+
35
54
  async function authenticateVault(this: {config: IConfig}): Promise<void> {
36
55
  const {authMethod, roleId, secretId, username, password} = this.config.vault;
37
56
 
@@ -155,9 +174,27 @@ export default adapter<IConfig>(({utError}) => {
155
174
  throw this.error(_errors['vault.missingPath'](), $meta);
156
175
  }
157
176
 
177
+ // KV v2: secrets are read from `<mount>data/<name>`, and the
178
+ // secret fields are wrapped under `data`.
179
+ let readPath = normalizePath(secretPath);
180
+ const mount = kv2MountFor(readPath);
181
+ if (mount && !readPath.startsWith(`${mount}data/`)) {
182
+ const name = readPath.slice(mount.length);
183
+ readPath = `${mount}data/${name}`;
184
+ }
185
+
158
186
  try {
159
- const result = await this.config.context.vault!.read(secretPath);
160
- return result.data;
187
+ const result = await this.config.context.vault!.read(readPath);
188
+ const payload = result.data;
189
+ if (
190
+ payload &&
191
+ typeof payload === 'object' &&
192
+ !Array.isArray(payload) &&
193
+ 'data' in payload
194
+ ) {
195
+ return (payload as {data: unknown}).data;
196
+ }
197
+ return payload;
161
198
  } catch (error: unknown) {
162
199
  throw this.error(
163
200
  (error as {response?: {statusCode?: number}})?.response?.statusCode === 404
@@ -220,19 +257,74 @@ export default adapter<IConfig>(({utError}) => {
220
257
  if (Array.isArray(actualParams)) {
221
258
  throw this.error(_errors['vault.invalid'](), $meta);
222
259
  }
260
+ // `vault.mount.list` — enumerate mounted secret engines
261
+ if (resource === 'mount') {
262
+ try {
263
+ const result = await this.config.context.vault!.mounts();
264
+ kv2Mounts.clear();
265
+ const items = Object.entries(result?.data ?? {}).map(
266
+ ([path, cfg]: [string, unknown]) => {
267
+ const c = cfg as {
268
+ type?: string;
269
+ options?: {version?: number};
270
+ };
271
+ if (c?.type === 'kv' && (c.options?.version ?? 0) === 2) {
272
+ kv2Mounts.add(path);
273
+ }
274
+ return {
275
+ path,
276
+ ...(typeof cfg === 'object' && cfg !== null
277
+ ? (cfg as Record<string, unknown>)
278
+ : {}),
279
+ };
280
+ },
281
+ );
282
+ return {items};
283
+ } catch (error: unknown) {
284
+ throw this.error(_errors['vault.generic'](error), $meta);
285
+ }
286
+ }
223
287
  if (!secretPath) {
224
288
  throw this.error(_errors['vault.missingPath'](), $meta);
225
289
  }
226
290
 
291
+ // KV v2: a mount root (or its `data/` marker) lists the actual
292
+ // secrets through `metadata/` — never the raw mount root.
293
+ const originalPath = normalizePath(secretPath);
294
+ let listPath = originalPath;
295
+ const mount = kv2MountFor(listPath);
296
+ if (mount) {
297
+ const trimmedMount = mount.replace(/\/+$/, '');
298
+ const trimmedList = listPath.replace(/\/+$/, '');
299
+ if (trimmedList === trimmedMount || trimmedList === `${trimmedMount}/data`) {
300
+ listPath = `${trimmedMount}/metadata/`;
301
+ }
302
+ }
303
+
227
304
  try {
228
- const result = await this.config.context.vault!.list(secretPath);
229
- return result.data;
305
+ const result = await this.config.context.vault!.list(listPath);
306
+ const data = (result.data ?? {}) as {keys?: string[]};
307
+ // Keep the native Vault `keys` shape (relied on by the
308
+ // integration tests) and additionally expose commander-style
309
+ // `items` rows (consistent with `vault.mount.list`) so the
310
+ // generic explorer can render the secrets as a table. Keys
311
+ // ending in `/` are sub-path (directory) markers, not leaf
312
+ // secrets — clicking one would 404 ("Vault Secret Not
313
+ // Found"), so they are filtered out. Rows carry the MOUNT
314
+ // path so the deeper level's `{parent.path}/{key}` open
315
+ // resolves to `<mount>/<name>`.
316
+ const keys = (data.keys ?? []).filter(key => !key.endsWith('/'));
317
+ return {
318
+ ...data,
319
+ keys,
320
+ items: keys.map(key => ({key, path: originalPath})),
321
+ };
230
322
  } catch (error: unknown) {
231
323
  if (
232
324
  (error as {response?: {statusCode?: number}})?.response?.statusCode ===
233
325
  404
234
326
  ) {
235
- return {keys: []};
327
+ return {keys: [], items: []};
236
328
  }
237
329
  throw this.error(_errors['vault.generic'](error), $meta);
238
330
  }
@@ -1,7 +1,3 @@
1
1
  import {defineBlongConfig} from '@feasibleone/blong-browser/playwright/config';
2
2
 
3
- export default defineBlongConfig({
4
- // Adjust these ports if they clash with another locally-running realm.
5
- backendPort: 9003,
6
- frontendPort: 9103,
7
- });
3
+ export default defineBlongConfig();