@mcowger/plexus-cli 0.0.1

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/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@mcowger/plexus-cli",
3
+ "version": "0.0.1",
4
+ "description": "Dynamic command line client for Plexus management APIs",
5
+ "type": "module",
6
+ "bin": {
7
+ "plexuscli": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "start": "bun run src/index.ts",
11
+ "typecheck": "bun x tsc --noEmit",
12
+ "test": "bunx --bun vitest run"
13
+ },
14
+ "engines": {
15
+ "bun": ">=1.0.0"
16
+ }
17
+ }
@@ -0,0 +1,177 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildRequest,
4
+ discoverOperations,
5
+ formatOutput,
6
+ isRisky,
7
+ parseArgs,
8
+ run,
9
+ type Json,
10
+ type Operation,
11
+ } from '../cli';
12
+
13
+ const document = {
14
+ paths: {
15
+ '/v0/management/keys/{id}': {
16
+ delete: {
17
+ operationId: 'deleteKey',
18
+ parameters: [{ name: 'id', in: 'path', required: true }],
19
+ },
20
+ get: { operationId: 'getKey', tags: ['Keys'] },
21
+ },
22
+ '/v0/system/logs/stream': {
23
+ get: {
24
+ operationId: 'streamLogs',
25
+ responses: { 200: { content: { 'text/event-stream': {} } } },
26
+ },
27
+ },
28
+ '/v1/models': { get: { operationId: 'listModels' } },
29
+ },
30
+ };
31
+
32
+ describe('dynamic OpenAPI routing', () => {
33
+ it('limits discovery to supported non-stream operations', () => {
34
+ expect(discoverOperations(document).map((operation) => operation.id)).toEqual([
35
+ 'deleteKey',
36
+ 'getKey',
37
+ ]);
38
+ });
39
+
40
+ it('builds encoded path and query parameters', () => {
41
+ const operation: Operation = {
42
+ id: 'get',
43
+ method: 'get',
44
+ path: '/v0/management/items/{id}',
45
+ operation: {
46
+ parameters: [
47
+ { name: 'id', in: 'path', required: true },
48
+ { name: 'limit', in: 'query' },
49
+ ],
50
+ },
51
+ };
52
+ const request = buildRequest(
53
+ operation,
54
+ new Map([
55
+ ['id', 'a/b' as Json],
56
+ ['limit', 10 as Json],
57
+ ])
58
+ );
59
+ expect(request.url).toBe('/v0/management/items/a%2Fb?limit=10');
60
+ });
61
+
62
+ it('recognizes destructive operations', () => {
63
+ expect(isRisky(discoverOperations(document)[0]!)).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe('arguments and output', () => {
68
+ it('uses environment defaults and coerces JSON literal parameters', () => {
69
+ const args = parseArgs(['api', 'call', 'getKey', '--param', 'limit=10'], {
70
+ PLEXUS_URL: 'http://plexus',
71
+ });
72
+ expect(args.url).toBe('http://plexus');
73
+ expect(args.params.get('limit')).toBe(10);
74
+ });
75
+
76
+ it('parses the all-pages flag', () => {
77
+ expect(parseArgs(['api', 'call', 'getKey', '--all'], {}).all).toBe(true);
78
+ });
79
+
80
+ it('formats tables with deterministic columns', () => {
81
+ expect(formatOutput([{ b: 2, a: 'one' }], 'table')).toBe('a b\n--- -\none 2\n');
82
+ });
83
+ });
84
+
85
+ describe('execution', () => {
86
+ it('fetches the schema without caching and uses JSON for non-TTY output', async () => {
87
+ const requests: Array<{ url: string; init?: RequestInit }> = [];
88
+ let stdout = '';
89
+ const exitCode = await run(
90
+ ['api', 'call', 'getKey'],
91
+ {},
92
+ {
93
+ fetch: async (url, init) => {
94
+ requests.push({ url: String(url), init });
95
+ return requests.length === 1
96
+ ? new Response(JSON.stringify(document))
97
+ : new Response(JSON.stringify({ value: true }));
98
+ },
99
+ stdin: async () => '',
100
+ stdout: (text) => {
101
+ stdout += text;
102
+ },
103
+ stderr: () => {},
104
+ isTTY: false,
105
+ confirm: async () => false,
106
+ }
107
+ );
108
+ expect(exitCode).toBe(0);
109
+ expect(requests[0]).toMatchObject({
110
+ url: 'http://localhost:4000/.well-known/plexus/openapi.json',
111
+ init: { cache: 'no-store' },
112
+ });
113
+ expect(stdout).toBe('{\n "value": true\n}\n');
114
+ });
115
+
116
+ it('sends the management credential with x-admin-key', async () => {
117
+ const requests: Array<{ url: string; init?: RequestInit }> = [];
118
+ const exitCode = await run(
119
+ ['api', 'call', 'getKey', '--admin-key', 'secret'],
120
+ {},
121
+ {
122
+ fetch: async (url, init) => {
123
+ requests.push({ url: String(url), init });
124
+ return requests.length === 1
125
+ ? new Response(JSON.stringify(document))
126
+ : new Response(JSON.stringify({ value: true }));
127
+ },
128
+ stdin: async () => '',
129
+ stdout: () => {},
130
+ stderr: () => {},
131
+ isTTY: false,
132
+ confirm: async () => false,
133
+ }
134
+ );
135
+ expect(exitCode).toBe(0);
136
+ expect(new Headers(requests[1]?.init?.headers).get('x-admin-key')).toBe('secret');
137
+ });
138
+
139
+ it('retrieves all standardized pages', async () => {
140
+ const paginatedDocument = {
141
+ paths: {
142
+ '/v0/management/items': {
143
+ get: {
144
+ operationId: 'listItems',
145
+ parameters: [
146
+ { name: 'limit', in: 'query' },
147
+ { name: 'offset', in: 'query' },
148
+ ],
149
+ },
150
+ },
151
+ },
152
+ };
153
+ let stdout = '';
154
+ const exitCode = await run(
155
+ ['api', 'call', 'listItems', '--all', '--param', 'limit=2'],
156
+ {},
157
+ {
158
+ fetch: async (url) => {
159
+ if (String(url).includes('openapi.json'))
160
+ return new Response(JSON.stringify(paginatedDocument));
161
+ return String(url).includes('offset=2')
162
+ ? new Response(JSON.stringify({ data: [3], total: 3, limit: 2, offset: 2 }))
163
+ : new Response(JSON.stringify({ data: [1, 2], total: 3, limit: 2, offset: 0 }));
164
+ },
165
+ stdin: async () => '',
166
+ stdout: (text) => {
167
+ stdout += text;
168
+ },
169
+ stderr: () => {},
170
+ isTTY: false,
171
+ confirm: async () => true,
172
+ }
173
+ );
174
+ expect(exitCode).toBe(0);
175
+ expect(JSON.parse(stdout)).toMatchObject({ data: [1, 2, 3], total: 3 });
176
+ });
177
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,428 @@
1
+ export const DEFAULT_URL = 'http://localhost:4000';
2
+ const OPENAPI_PATH = '/.well-known/plexus/openapi.json';
3
+ const ALLOWED_PATH = /^\/v0\/(management\/|system\/logs\/)/;
4
+ const HTTP_METHODS = new Set(['delete', 'get', 'head', 'patch', 'post', 'put']);
5
+ const RISKY_OPERATION = /delete|restore|restart|reset|clear|rotate|disable/i;
6
+
7
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
8
+
9
+ interface OpenApiOperation {
10
+ operationId?: string;
11
+ summary?: string;
12
+ description?: string;
13
+ tags?: string[];
14
+ parameters?: OpenApiParameter[];
15
+ requestBody?: { required?: boolean; content?: Record<string, unknown> };
16
+ responses?: Record<string, { content?: Record<string, unknown> }>;
17
+ }
18
+
19
+ interface OpenApiParameter {
20
+ name: string;
21
+ in: 'path' | 'query' | 'header' | 'cookie';
22
+ required?: boolean;
23
+ description?: string;
24
+ }
25
+
26
+ export interface Operation {
27
+ id: string;
28
+ method: string;
29
+ path: string;
30
+ operation: OpenApiOperation;
31
+ }
32
+
33
+ export interface ParsedArgs {
34
+ positionals: string[];
35
+ url: string;
36
+ adminKey?: string;
37
+ output?: 'json' | 'yaml' | 'table';
38
+ yes: boolean;
39
+ all: boolean;
40
+ params: Map<string, Json>;
41
+ body?: string;
42
+ bodyFile?: string;
43
+ help: boolean;
44
+ }
45
+
46
+ export class CliError extends Error {
47
+ constructor(
48
+ message: string,
49
+ readonly exitCode = 2
50
+ ) {
51
+ super(message);
52
+ }
53
+ }
54
+
55
+ export function parseJsonLiteral(value: string): Json {
56
+ try {
57
+ return JSON.parse(value) as Json;
58
+ } catch {
59
+ return value;
60
+ }
61
+ }
62
+
63
+ export function parseArgs(argv: string[], env: Record<string, string | undefined>): ParsedArgs {
64
+ const result: ParsedArgs = {
65
+ positionals: [],
66
+ url: env.PLEXUS_URL || DEFAULT_URL,
67
+ adminKey: env.PLEXUS_ADMIN_KEY,
68
+ yes: false,
69
+ all: false,
70
+ params: new Map(),
71
+ help: false,
72
+ };
73
+
74
+ for (let index = 0; index < argv.length; index += 1) {
75
+ const arg = argv[index];
76
+ if (arg === undefined) continue;
77
+ const value = (): string => {
78
+ const next = argv[++index];
79
+ if (next === undefined || next.startsWith('--'))
80
+ throw new CliError(`${arg} requires a value`);
81
+ return next;
82
+ };
83
+ if (arg === '--url') result.url = value();
84
+ else if (arg === '--admin-key') result.adminKey = value();
85
+ else if (arg === '--output') {
86
+ const output = value();
87
+ if (output !== 'json' && output !== 'yaml' && output !== 'table') {
88
+ throw new CliError('--output must be json, yaml, or table');
89
+ }
90
+ result.output = output;
91
+ } else if (arg === '--param') {
92
+ const parameter = value();
93
+ const separator = parameter.indexOf('=');
94
+ if (separator < 1) throw new CliError('--param must use name=value');
95
+ result.params.set(
96
+ parameter.slice(0, separator),
97
+ parseJsonLiteral(parameter.slice(separator + 1))
98
+ );
99
+ } else if (arg === '--body') result.body = value();
100
+ else if (arg === '--body-file') result.bodyFile = value();
101
+ else if (arg === '--yes') result.yes = true;
102
+ else if (arg === '--all') result.all = true;
103
+ else if (arg === '--help' || arg === '-h') result.help = true;
104
+ else if (arg.startsWith('--')) throw new CliError(`Unknown option: ${arg}`);
105
+ else result.positionals.push(arg);
106
+ }
107
+ if (result.body && result.bodyFile)
108
+ throw new CliError('Use either --body or --body-file, not both');
109
+ return result;
110
+ }
111
+
112
+ export function discoverOperations(document: {
113
+ paths?: Record<string, Record<string, unknown>>;
114
+ }): Operation[] {
115
+ const candidates: Operation[] = [];
116
+ for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
117
+ if (!ALLOWED_PATH.test(path)) continue;
118
+ for (const [method, value] of Object.entries(pathItem)) {
119
+ if (!HTTP_METHODS.has(method) || !value || typeof value !== 'object') continue;
120
+ const operation = value as OpenApiOperation;
121
+ if (isStreamOperation(operation)) continue;
122
+ candidates.push({
123
+ id: operation.operationId ?? fallbackId(method, path, operation.tags),
124
+ method,
125
+ path,
126
+ operation,
127
+ });
128
+ }
129
+ }
130
+ const counts = new Map<string, number>();
131
+ for (const candidate of candidates) counts.set(candidate.id, (counts.get(candidate.id) ?? 0) + 1);
132
+ return candidates
133
+ .map((candidate) =>
134
+ counts.get(candidate.id) === 1
135
+ ? candidate
136
+ : {
137
+ ...candidate,
138
+ id: fallbackId(candidate.method, candidate.path, candidate.operation.tags),
139
+ }
140
+ )
141
+ .sort((left, right) => left.id.localeCompare(right.id));
142
+ }
143
+
144
+ function fallbackId(method: string, path: string, tags?: string[]): string {
145
+ const tag = tags?.[0]?.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
146
+ const route = path
147
+ .replace(/^\/v0\//, '')
148
+ .replace(/[{}]/g, '')
149
+ .replace(/[^a-z0-9]+/gi, '-')
150
+ .replace(/^-|-$/g, '');
151
+ return [tag, method, route].filter(Boolean).join('-').toLowerCase();
152
+ }
153
+
154
+ export function isStreamOperation(operation: OpenApiOperation): boolean {
155
+ return Object.values(operation.responses ?? {}).some((response) =>
156
+ Object.keys(response.content ?? {}).some(
157
+ (contentType) => contentType.toLowerCase() === 'text/event-stream'
158
+ )
159
+ );
160
+ }
161
+
162
+ export function isRisky(operation: Operation): boolean {
163
+ return operation.method === 'delete' || RISKY_OPERATION.test(`${operation.id} ${operation.path}`);
164
+ }
165
+
166
+ export function buildRequest(
167
+ operation: Operation,
168
+ params: Map<string, Json>
169
+ ): { url: string; headers: Headers } {
170
+ let path = operation.path;
171
+ const query = new URLSearchParams();
172
+ const headers = new Headers();
173
+ const parameters = operation.operation.parameters ?? [];
174
+ for (const parameter of parameters) {
175
+ const value = params.get(parameter.name);
176
+ if (value === undefined) {
177
+ if (parameter.required)
178
+ throw new CliError(`Missing required --param ${parameter.name}=value`);
179
+ continue;
180
+ }
181
+ const encoded = typeof value === 'string' ? value : JSON.stringify(value);
182
+ if (parameter.in === 'path')
183
+ path = path.replace(`{${parameter.name}}`, encodeURIComponent(encoded));
184
+ if (parameter.in === 'query') query.set(parameter.name, encoded);
185
+ if (parameter.in === 'header') headers.set(parameter.name, encoded);
186
+ }
187
+ for (const name of params.keys()) {
188
+ if (!parameters.some((parameter) => parameter.name === name))
189
+ throw new CliError(`Unknown parameter: ${name}`);
190
+ }
191
+ return { url: `${path}${query.size ? `?${query}` : ''}`, headers };
192
+ }
193
+
194
+ function yaml(value: unknown, indent = ''): string {
195
+ if (Array.isArray(value)) {
196
+ return value.length === 0
197
+ ? '[]'
198
+ : value.map((item) => `${indent}- ${yaml(item, `${indent} `)}`).join('\n');
199
+ }
200
+ if (value && typeof value === 'object') {
201
+ const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
202
+ return entries.length === 0
203
+ ? '{}'
204
+ : entries
205
+ .map(([key, item]) =>
206
+ item && typeof item === 'object'
207
+ ? `${indent}${key}:\n${yaml(item, `${indent} `)}`
208
+ : `${indent}${key}: ${yaml(item, `${indent} `)}`
209
+ )
210
+ .join('\n');
211
+ }
212
+ return JSON.stringify(value) ?? 'null';
213
+ }
214
+
215
+ function table(value: unknown): string {
216
+ const rows = Array.isArray(value) ? value : [value];
217
+ if (!rows.every((row) => row && typeof row === 'object' && !Array.isArray(row)))
218
+ return JSON.stringify(value, null, 2);
219
+ const records = rows as Array<Record<string, unknown>>;
220
+ const columns = [...new Set(records.flatMap((row) => Object.keys(row)))].sort();
221
+ const cells = records.map((row) => columns.map((column) => formatCell(row[column])));
222
+ const widths = columns.map((column, index) =>
223
+ Math.max(column.length, ...cells.map((row) => (row[index] ?? '').length))
224
+ );
225
+ const line = (row: string[]) =>
226
+ row
227
+ .map((cell, index) => cell.padEnd(widths[index] ?? 0))
228
+ .join(' ')
229
+ .trimEnd();
230
+ return [line(columns), line(widths.map((width) => '-'.repeat(width))), ...cells.map(line)].join(
231
+ '\n'
232
+ );
233
+ }
234
+
235
+ function formatCell(value: unknown): string {
236
+ if (value === undefined) return '';
237
+ return typeof value === 'string' ? value : JSON.stringify(value);
238
+ }
239
+
240
+ function getPaginatedData(value: Json): { data: Json[]; total: number } | undefined {
241
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
242
+ const { data, total } = value;
243
+ if (!Array.isArray(data) || typeof total !== 'number') return undefined;
244
+ return { data, total };
245
+ }
246
+
247
+ async function fetchAllPages(
248
+ baseUrl: string,
249
+ operation: Operation,
250
+ params: Map<string, Json>,
251
+ headers: Headers,
252
+ fetcher: Runtime['fetch']
253
+ ): Promise<Json> {
254
+ const parameterNames = new Set(
255
+ (operation.operation.parameters ?? []).map((parameter) => parameter.name)
256
+ );
257
+ if (!parameterNames.has('limit') || !parameterNames.has('offset')) {
258
+ throw new CliError('--all requires documented limit and offset parameters');
259
+ }
260
+ const allData: Json[] = [];
261
+ let offset = typeof params.get('offset') === 'number' ? (params.get('offset') as number) : 0;
262
+ let total: number | undefined;
263
+ let limit = typeof params.get('limit') === 'number' ? (params.get('limit') as number) : undefined;
264
+
265
+ while (total === undefined || allData.length < total) {
266
+ const pageParams = new Map(params);
267
+ pageParams.set('offset', offset);
268
+ const request = buildRequest(operation, pageParams);
269
+ const response = await fetcher(`${baseUrl}${request.url}`, {
270
+ method: operation.method.toUpperCase(),
271
+ headers,
272
+ });
273
+ const text = await response.text();
274
+ if (!response.ok) throw new CliError(`HTTP ${response.status}: ${text}`, 1);
275
+ const page = getPaginatedData(JSON.parse(text) as Json);
276
+ if (!page) throw new CliError('--all requires a paginated { data, total } response');
277
+ total = page.total;
278
+ limit ??= page.data.length;
279
+ if (!limit || page.data.length === 0) break;
280
+ allData.push(...page.data);
281
+ offset += limit;
282
+ }
283
+
284
+ return { data: allData, total: total ?? allData.length, limit: limit ?? 0, offset: 0 };
285
+ }
286
+
287
+ export function formatOutput(value: unknown, output: 'json' | 'yaml' | 'table'): string {
288
+ if (output === 'yaml') return `${yaml(value)}\n`;
289
+ if (output === 'table') return `${table(value)}\n`;
290
+ return `${JSON.stringify(value, null, 2)}\n`;
291
+ }
292
+
293
+ const HELP = `Usage: plexuscli [options] api <list|describe|call> [operation]
294
+
295
+ Options:
296
+ --url URL Plexus URL (default: PLEXUS_URL or ${DEFAULT_URL})
297
+ --admin-key KEY Admin key (default: PLEXUS_ADMIN_KEY)
298
+ --param name=value Parameter; JSON literals are coerced
299
+ --body JSON JSON request body, or - to read stdin
300
+ --body-file FILE JSON request body file
301
+ --all Retrieve every page from a paginated list operation
302
+ --output json|yaml|table
303
+ --yes Skip risky-operation confirmation
304
+
305
+ Exit status: 0 success or help; 1 network or HTTP failure; 2 invalid input, refused action, or unsupported stream operation.
306
+ `;
307
+
308
+ export interface Runtime {
309
+ fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
310
+ stdin: () => Promise<string>;
311
+ stdout: (text: string) => void;
312
+ stderr: (text: string) => void;
313
+ isTTY: boolean;
314
+ confirm: (message: string) => Promise<boolean>;
315
+ }
316
+
317
+ export async function run(
318
+ argv: string[],
319
+ env: Record<string, string | undefined>,
320
+ runtime: Runtime
321
+ ): Promise<number> {
322
+ try {
323
+ const args = parseArgs(argv, env);
324
+ if (args.help || args.positionals.length === 0) {
325
+ runtime.stdout(HELP);
326
+ return 0;
327
+ }
328
+ const baseUrl = args.url.replace(/\/$/, '');
329
+ const specResponse = await runtime.fetch(`${baseUrl}${OPENAPI_PATH}`, { cache: 'no-store' });
330
+ if (!specResponse.ok)
331
+ throw new CliError(`OpenAPI fetch failed: HTTP ${specResponse.status}`, 1);
332
+ const operations = discoverOperations(
333
+ (await specResponse.json()) as { paths?: Record<string, Record<string, unknown>> }
334
+ );
335
+ const [group, action, identifier] = args.positionals;
336
+ const output = args.output ?? (runtime.isTTY ? 'table' : 'json');
337
+ if (group === 'api' && action === 'list' && !identifier) {
338
+ runtime.stdout(
339
+ formatOutput(
340
+ operations.map(({ id, method, path, operation }) => ({
341
+ id,
342
+ method: method.toUpperCase(),
343
+ path,
344
+ tags: operation.tags ?? [],
345
+ summary: operation.summary ?? '',
346
+ })),
347
+ output
348
+ )
349
+ );
350
+ return 0;
351
+ }
352
+ const operation =
353
+ group === 'api'
354
+ ? operations.find((candidate) => candidate.id === identifier)
355
+ : operations.find((candidate) => candidate.id === group);
356
+ if (!operation) throw new CliError(`Unknown or unsupported operation: ${identifier ?? group}`);
357
+ if (group === 'api' && action === 'describe') {
358
+ runtime.stdout(
359
+ formatOutput(
360
+ {
361
+ id: operation.id,
362
+ method: operation.method.toUpperCase(),
363
+ path: operation.path,
364
+ description: operation.operation.description ?? operation.operation.summary ?? '',
365
+ parameters: operation.operation.parameters ?? [],
366
+ requiresBody: Boolean(operation.operation.requestBody?.required),
367
+ },
368
+ output
369
+ )
370
+ );
371
+ return 0;
372
+ }
373
+ if (group === 'api' && action !== 'call')
374
+ throw new CliError('Use: api list, api describe <operation>, or api call <operation>');
375
+ if (
376
+ isRisky(operation) &&
377
+ !args.yes &&
378
+ !(await runtime.confirm(`Run risky ${operation.method.toUpperCase()} ${operation.path}?`))
379
+ ) {
380
+ throw new CliError('Operation cancelled');
381
+ }
382
+ let body = args.body;
383
+ if (args.bodyFile) body = await Bun.file(args.bodyFile).text();
384
+ if (body === '-') body = await runtime.stdin();
385
+ if (!body && !runtime.isTTY) body = (await runtime.stdin()).trim() || undefined;
386
+ if (operation.operation.requestBody?.required && !body) {
387
+ throw new CliError('This operation requires --body, --body-file, or JSON on stdin');
388
+ }
389
+ if (body) JSON.parse(body);
390
+ const request = buildRequest(operation, args.params);
391
+ if (args.adminKey) request.headers.set('x-admin-key', args.adminKey);
392
+ if (body) request.headers.set('content-type', 'application/json');
393
+ if (args.all) {
394
+ if (body) throw new CliError('--all cannot be used with a request body');
395
+ runtime.stdout(
396
+ formatOutput(
397
+ await fetchAllPages(baseUrl, operation, args.params, request.headers, runtime.fetch),
398
+ output
399
+ )
400
+ );
401
+ return 0;
402
+ }
403
+ const response = await runtime.fetch(`${baseUrl}${request.url}`, {
404
+ method: operation.method.toUpperCase(),
405
+ headers: request.headers,
406
+ body,
407
+ });
408
+ const responseText = await response.text();
409
+ if (!response.ok) {
410
+ runtime.stderr(`HTTP ${response.status}: ${responseText}\n`);
411
+ return 1;
412
+ }
413
+ if (!responseText) return 0;
414
+ try {
415
+ runtime.stdout(formatOutput(JSON.parse(responseText) as Json, output));
416
+ } catch {
417
+ runtime.stdout(`${responseText}\n`);
418
+ }
419
+ return 0;
420
+ } catch (error) {
421
+ const cliError =
422
+ error instanceof CliError
423
+ ? error
424
+ : new CliError(error instanceof Error ? error.message : String(error), 1);
425
+ runtime.stderr(`${cliError.message}\n`);
426
+ return cliError.exitCode;
427
+ }
428
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bun
2
+ import { run } from './cli';
3
+
4
+ const exitCode = await run(process.argv.slice(2), process.env, {
5
+ fetch,
6
+ stdin: async () => new Response(Bun.stdin.stream()).text(),
7
+ stdout: (text) => process.stdout.write(text),
8
+ stderr: (text) => process.stderr.write(text),
9
+ isTTY: Boolean(process.stdout.isTTY),
10
+ confirm: async (message) => {
11
+ if (!process.stdin.isTTY) return false;
12
+ process.stderr.write(`${message} [y/N] `);
13
+ const answer = (await new Response(Bun.stdin.stream()).text()).trim().toLowerCase();
14
+ return answer === 'y' || answer === 'yes';
15
+ },
16
+ });
17
+
18
+ process.exitCode = exitCode;
@@ -0,0 +1,112 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
5
+
6
+ const openApiDocument = {
7
+ openapi: '3.1.0',
8
+ paths: {
9
+ '/v0/management/usage': {
10
+ get: {
11
+ operationId: 'getV0ManagementUsage',
12
+ parameters: [
13
+ { name: 'limit', in: 'query' },
14
+ { name: 'offset', in: 'query' },
15
+ ],
16
+ responses: {
17
+ 200: {
18
+ content: {
19
+ 'application/json': {},
20
+ },
21
+ },
22
+ },
23
+ },
24
+ },
25
+ },
26
+ };
27
+
28
+ describe('plexuscli end-to-end', () => {
29
+ let server: ReturnType<typeof Bun.serve> | undefined;
30
+ const buildDir = join(tmpdir(), `plexuscli-e2e-${crypto.randomUUID()}`);
31
+ let cliPath: string;
32
+
33
+ beforeAll(async () => {
34
+ const result = await Bun.build({
35
+ entrypoints: [join(process.cwd(), 'src', 'index.ts')],
36
+ outdir: buildDir,
37
+ compile: true,
38
+ target: 'bun',
39
+ });
40
+ if (!result.success) throw new Error(result.logs.map((log) => log.message).join('\n'));
41
+ cliPath = result.outputs[0]!.path;
42
+ });
43
+
44
+ afterEach(() => {
45
+ server?.stop(true);
46
+ server = undefined;
47
+ });
48
+
49
+ afterAll(async () => {
50
+ await rm(buildDir, { force: true, recursive: true });
51
+ });
52
+
53
+ test('discovers and calls a paginated management operation', async () => {
54
+ const adminKeys: string[] = [];
55
+ server = Bun.serve({
56
+ port: 0,
57
+ fetch(request) {
58
+ const url = new URL(request.url);
59
+ if (url.pathname === '/.well-known/plexus/openapi.json') {
60
+ return Response.json(openApiDocument);
61
+ }
62
+
63
+ if (url.pathname === '/v0/management/usage') {
64
+ adminKeys.push(request.headers.get('x-admin-key') ?? '');
65
+ const offset = Number(url.searchParams.get('offset') ?? '0');
66
+ return Response.json(
67
+ offset === 0
68
+ ? { data: [{ requestId: 'one' }], total: 2, limit: 1, offset }
69
+ : { data: [{ requestId: 'two' }], total: 2, limit: 1, offset }
70
+ );
71
+ }
72
+
73
+ return new Response('Not Found', { status: 404 });
74
+ },
75
+ });
76
+
77
+ const child = Bun.spawn(
78
+ [
79
+ cliPath,
80
+ '--url',
81
+ `http://localhost:${server.port}`,
82
+ '--admin-key',
83
+ 'e2e-admin-key',
84
+ 'api',
85
+ 'call',
86
+ 'getV0ManagementUsage',
87
+ '--all',
88
+ '--param',
89
+ 'limit=1',
90
+ '--output',
91
+ 'json',
92
+ ],
93
+ {
94
+ stdout: 'pipe',
95
+ stderr: 'pipe',
96
+ }
97
+ );
98
+ const [stdout, stderr, exitCode] = await Promise.all([
99
+ new Response(child.stdout).text(),
100
+ new Response(child.stderr).text(),
101
+ child.exited,
102
+ ]);
103
+
104
+ expect(exitCode).toBe(0);
105
+ expect(stderr).toBe('');
106
+ expect(JSON.parse(stdout)).toMatchObject({
107
+ data: [{ requestId: 'one' }, { requestId: 'two' }],
108
+ total: 2,
109
+ });
110
+ expect(adminKeys).toEqual(['e2e-admin-key', 'e2e-admin-key']);
111
+ });
112
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "types": ["bun-types"]
5
+ },
6
+ "include": ["src", "test"]
7
+ }