Skip to content

Commit 4143e9a

Browse files
committed
feat: 🎸 add CLI implementation
1 parent 43edb69 commit 4143e9a

33 files changed

+1212
-6
lines changed

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@
6161
"dependencies": {
6262
"@jsonjoy.com/json-pack": "^1.1.0",
6363
"@jsonjoy.com/json-type": "^1.0.0",
64+
"@jsonjoy.com/reactive-rpc": "^2.1.0",
6465
"@jsonjoy.com/util": "^1.5.0",
66+
"json-joy": "^17.0.0",
6567
"tree-dump": "^1.0.2"
6668
},
6769
"devDependencies": {

‎src/Cli.ts‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import {parseArgs} from 'node:util';
2+
import {TypeSystem} from '@jsonjoy.com/json-type/lib/system/TypeSystem';
3+
import {ObjectValueCaller} from '@jsonjoy.com/reactive-rpc/lib/common/rpc/caller/ObjectValueCaller';
4+
import {bufferToUint8Array} from '@jsonjoy.com/util/lib/buffers/bufferToUint8Array';
5+
import {formatError} from './util';
6+
import {defineBuiltinRoutes} from './methods';
7+
import {defaultParams} from './defaultParams';
8+
import {ObjectValue} from '@jsonjoy.com/json-type/lib/value/ObjectValue';
9+
import type {CliCodecs} from './CliCodecs';
10+
import type {TypeBuilder} from '@jsonjoy.com/json-type/lib/type/TypeBuilder';
11+
import type {WriteStream, ReadStream} from 'tty';
12+
import type {CliCodec, CliContext, CliParam, CliParamInstance} from './types';
13+
import type {Value} from '@jsonjoy.com/json-type/lib/value/Value';
14+
15+
export interface CliOptions<Router extends ObjectValue<any>> {
16+
codecs: CliCodecs;
17+
params?: CliParam[];
18+
router?: Router;
19+
version?: string;
20+
cmd?: string;
21+
argv?: string[];
22+
stdout?: WriteStream;
23+
stderr?: WriteStream;
24+
stdin?: ReadStream;
25+
exit?: (errno: number) => void;
26+
}
27+
28+
export class Cli<Router extends ObjectValue<any> = ObjectValue<any>> {
29+
public router: Router;
30+
public readonly params: CliParam[];
31+
public readonly paramMap: Map<string, CliParam>;
32+
public readonly types: TypeSystem;
33+
public readonly t: TypeBuilder;
34+
public readonly caller: ObjectValueCaller<Router>;
35+
public readonly codecs: CliCodecs;
36+
public request?: unknown;
37+
public response?: unknown;
38+
public argv: string[];
39+
public stdout: WriteStream;
40+
public stderr: WriteStream;
41+
public stdin: ReadStream;
42+
public exit: (errno: number) => void;
43+
public requestCodec: CliCodec;
44+
public responseCodec: CliCodec;
45+
public rawStdinInput?: Uint8Array;
46+
public stdinInput?: unknown;
47+
protected paramInstances: CliParamInstance[] = [];
48+
49+
public constructor(public readonly options: CliOptions<Router>) {
50+
let router = options.router ?? (ObjectValue.create(new TypeSystem()) as any);
51+
router = defineBuiltinRoutes(router);
52+
this.router = router;
53+
this.params = options.params ?? defaultParams;
54+
this.paramMap = new Map();
55+
for (const param of this.params) {
56+
this.paramMap.set(param.param, param);
57+
if (param.short) this.paramMap.set(param.short, param);
58+
}
59+
this.caller = new ObjectValueCaller({router, wrapInternalError: (err) => err});
60+
this.types = router.system;
61+
this.t = this.types.t;
62+
this.codecs = options.codecs;
63+
this.requestCodec = this.codecs.get(this.codecs.defaultCodec);
64+
this.responseCodec = this.codecs.get(this.codecs.defaultCodec);
65+
this.argv = options.argv ?? process.argv.slice(2);
66+
this.stdin = options.stdin ?? process.stdin;
67+
this.stdout = options.stdout ?? process.stdout;
68+
this.stderr = options.stderr ?? process.stderr;
69+
this.exit = options.exit ?? process.exit;
70+
}
71+
72+
public run(): void {
73+
this.runAsync();
74+
}
75+
76+
public param(param: string): CliParam | undefined {
77+
return this.paramMap.get(param);
78+
}
79+
80+
public async runAsync(): Promise<void> {
81+
try {
82+
const system = this.router.system;
83+
for (const key of this.router.keys()) system.alias(key, this.router.get(key).type);
84+
const args = parseArgs({
85+
args: this.argv,
86+
strict: false,
87+
allowPositionals: true,
88+
});
89+
for (let argKey of Object.keys(args.values)) {
90+
let pointer = '';
91+
const value = args.values[argKey];
92+
const slashIndex = argKey.indexOf('/');
93+
if (slashIndex !== -1) {
94+
pointer = argKey.slice(slashIndex);
95+
argKey = argKey.slice(0, slashIndex);
96+
}
97+
const param = this.param(argKey);
98+
if (!param) {
99+
throw new Error(`Unknown parameter "${argKey}"`);
100+
}
101+
const instance = param.createInstance(this, pointer, value);
102+
this.paramInstances.push(instance);
103+
if (instance.onParam) await instance.onParam();
104+
}
105+
const method = args.positionals[0];
106+
if (!method) {
107+
const param = this.param('help');
108+
const instance = param?.createInstance(this, '', undefined);
109+
instance?.onParam?.();
110+
throw new Error('No method specified');
111+
}
112+
this.request = JSON.parse(args.positionals[1] || '{}');
113+
await this.readStdin();
114+
for (const instance of this.paramInstances) if (instance.onStdin) await instance.onStdin();
115+
for (const instance of this.paramInstances) if (instance.onRequest) await instance.onRequest();
116+
try {
117+
const ctx: CliContext<Router> = {cli: this};
118+
for (const instance of this.paramInstances) if (instance.onBeforeCall) await instance.onBeforeCall(method, ctx);
119+
const value = await this.caller.call(method, this.request as any, ctx);
120+
this.response = (value as Value<any>).data;
121+
for (const instance of this.paramInstances) if (instance.onResponse) await instance.onResponse();
122+
const buf = this.responseCodec.encode(this.response);
123+
this.stdout.write(buf);
124+
} catch (err) {
125+
const error = formatError(err);
126+
const buf = this.responseCodec.encode(error);
127+
this.stderr.write(buf);
128+
this.exit(1);
129+
}
130+
} catch (err) {
131+
const error = formatError(err);
132+
const buf = JSON.stringify(error, null, 4);
133+
this.stderr.write(buf);
134+
this.exit(1);
135+
}
136+
}
137+
138+
public cmd(): string {
139+
return this.options.cmd ?? '<cmd>';
140+
}
141+
142+
private async getStdin(): Promise<Buffer> {
143+
const stdin = this.stdin;
144+
if (stdin.isTTY) return Buffer.alloc(0);
145+
const result = [];
146+
let length = 0;
147+
for await (const chunk of stdin) {
148+
result.push(chunk);
149+
length += chunk.length;
150+
}
151+
return Buffer.concat(result, length);
152+
}
153+
154+
private async readStdin(): Promise<void> {
155+
const stdin = this.stdin;
156+
const codec = this.requestCodec;
157+
if (stdin.isTTY) return Object.create(null);
158+
const input = await this.getStdin();
159+
if (codec.id === 'json') {
160+
const str = input.toString().trim();
161+
if (!str) return Object.create(null);
162+
}
163+
this.rawStdinInput = bufferToUint8Array(input);
164+
this.stdinInput = codec.decode(this.rawStdinInput);
165+
}
166+
}

‎src/CliCodecs.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type {CliCodec} from './types';
2+
3+
const CODEC_REGEX = /(\w{0,32})(?:\:(\w{0,32}))?/;
4+
5+
export class CliCodecs {
6+
public defaultCodec: string = 'json4';
7+
public readonly codecs: Map<string, CliCodec<string>> = new Map();
8+
9+
public register(codec: CliCodec<string>): void {
10+
this.codecs.set(codec.id, codec);
11+
}
12+
13+
public get(id: '' | string): CliCodec<string> {
14+
let codec = this.codecs.get(id);
15+
if (!id) codec = this.codecs.get(this.defaultCodec);
16+
if (!codec) throw new Error(`Codec not found: ${id}`);
17+
return codec;
18+
}
19+
20+
/**
21+
* Select codecs for the given format specifier. The format specifier is a
22+
* string of the form:
23+
*
24+
* <request-and-response>
25+
* <request>:<response>
26+
*
27+
* Examples:
28+
*
29+
* json
30+
* json:json
31+
* cbor:json
32+
* cbor
33+
*
34+
* @param format Codec specifier, e.g. `json:json` or `json`.
35+
* @returns 2-tuple of selected codecs.
36+
*/
37+
public getCodecs(format: unknown): [request: CliCodec<string>, response: CliCodec<string>] {
38+
if (typeof format !== 'string') throw new Error(`Invalid --format type.`);
39+
if (!format) {
40+
const codec = this.get('');
41+
return [codec, codec];
42+
}
43+
const match = CODEC_REGEX.exec(format);
44+
if (!match) throw new Error(`Invalid format: ${format}`);
45+
const request = match[1];
46+
const response = match[2] ?? request;
47+
return [this.get(request), this.get(response)];
48+
}
49+
}

‎src/RequestParam.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class RequestParam {}

‎src/codecs/cbor.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {CborDecoder} from '@jsonjoy.com/json-pack/lib/cbor/CborDecoder';
2+
import {CborEncoder} from '@jsonjoy.com/json-pack/lib/cbor/CborEncoder';
3+
import type {Writer} from '@jsonjoy.com/util/lib/buffers/Writer';
4+
import type {CliCodec} from '../types';
5+
6+
export class CliCodecCbor implements CliCodec<'cbor'> {
7+
public readonly id = 'cbor';
8+
public readonly description = 'CBOR codec';
9+
protected readonly encoder: CborEncoder;
10+
protected readonly decoder: CborDecoder;
11+
12+
constructor(protected readonly writer: Writer) {
13+
this.encoder = new CborEncoder(writer);
14+
this.decoder = new CborDecoder();
15+
}
16+
17+
encode(value: unknown): Uint8Array {
18+
return this.encoder.encode(value);
19+
}
20+
21+
decode(bytes: Uint8Array): unknown {
22+
return this.decoder.read(bytes);
23+
}
24+
}

‎src/codecs/json.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {JsonDecoder} from '@jsonjoy.com/json-pack/lib/json/JsonDecoder';
2+
import {JsonEncoder} from '@jsonjoy.com/json-pack/lib/json/JsonEncoder';
3+
import type {Writer} from '@jsonjoy.com/util/lib/buffers/Writer';
4+
import type {CliCodec} from '../types';
5+
6+
export class CliCodecJson implements CliCodec<'json'> {
7+
public readonly id = 'json';
8+
public readonly description = 'JSON codec, which also supports binary data';
9+
protected readonly encoder: JsonEncoder;
10+
protected readonly decoder: JsonDecoder;
11+
12+
constructor(protected readonly writer: Writer) {
13+
this.encoder = new JsonEncoder(writer);
14+
this.decoder = new JsonDecoder();
15+
}
16+
17+
encode(value: unknown): Uint8Array {
18+
return this.encoder.encode(value);
19+
}
20+
21+
decode(bytes: Uint8Array): unknown {
22+
return this.decoder.read(bytes);
23+
}
24+
}

‎src/codecs/json2.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {JsonDecoder} from '@jsonjoy.com/json-pack/lib/json/JsonDecoder';
2+
import {JsonEncoder} from '@jsonjoy.com/json-pack/lib/json/JsonEncoder';
3+
import {bufferToUint8Array} from '@jsonjoy.com/util/lib/buffers/bufferToUint8Array';
4+
import type {Writer} from '@jsonjoy.com/util/lib/buffers/Writer';
5+
import type {CliCodec} from '../types';
6+
7+
/**
8+
* JSON codec with 2 space pretty-printing.
9+
*/
10+
export class CliCodecJson2 implements CliCodec<'json2'> {
11+
public readonly id = 'json2';
12+
public readonly description = 'JSON codec with 2 space pretty-printing';
13+
protected readonly encoder: JsonEncoder;
14+
protected readonly decoder: JsonDecoder;
15+
16+
constructor(protected readonly writer: Writer) {
17+
this.encoder = new JsonEncoder(writer);
18+
this.decoder = new JsonDecoder();
19+
}
20+
21+
encode(value: unknown): Uint8Array {
22+
const uint8 = this.encoder.encode(value);
23+
const pojo = JSON.parse(Buffer.from(uint8).toString('utf8'));
24+
const json = JSON.stringify(pojo, null, 2) + '\n';
25+
return bufferToUint8Array(Buffer.from(json, 'utf8'));
26+
}
27+
28+
decode(bytes: Uint8Array): unknown {
29+
return this.decoder.read(bytes);
30+
}
31+
}

‎src/codecs/json4.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {JsonDecoder} from '@jsonjoy.com/json-pack/lib/json/JsonDecoder';
2+
import {JsonEncoder} from '@jsonjoy.com/json-pack/lib/json/JsonEncoder';
3+
import type {Writer} from '@jsonjoy.com/util/lib/buffers/Writer';
4+
import {bufferToUint8Array} from '@jsonjoy.com/util/lib/buffers/bufferToUint8Array';
5+
import type {CliCodec} from '../types';
6+
7+
/**
8+
* JSON codec with 4 space pretty-printing.
9+
*/
10+
export class CliCodecJson4 implements CliCodec<'json4'> {
11+
public readonly id = 'json4';
12+
public readonly description = 'JSON codec with 4 space pretty-printing';
13+
protected readonly encoder: JsonEncoder;
14+
protected readonly decoder: JsonDecoder;
15+
16+
constructor(protected readonly writer: Writer) {
17+
this.encoder = new JsonEncoder(writer);
18+
this.decoder = new JsonDecoder();
19+
}
20+
21+
encode(value: unknown): Uint8Array {
22+
const uint8 = this.encoder.encode(value);
23+
const pojo = JSON.parse(Buffer.from(uint8).toString('utf8'));
24+
const json = JSON.stringify(pojo, null, 4) + '\n';
25+
return bufferToUint8Array(Buffer.from(json, 'utf8'));
26+
}
27+
28+
decode(bytes: Uint8Array): unknown {
29+
return this.decoder.read(bytes);
30+
}
31+
}

‎src/codecs/msgpack.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {MsgPackEncoder} from '@jsonjoy.com/json-pack/lib/msgpack';
2+
import {MsgPackDecoder} from '@jsonjoy.com/json-pack/lib/msgpack/MsgPackDecoder';
3+
import type {Writer} from '@jsonjoy.com/util/lib/buffers/Writer';
4+
import type {CliCodec} from '../types';
5+
6+
export class CliCodecMsgpack implements CliCodec<'msgpack'> {
7+
public readonly id = 'msgpack';
8+
public readonly description = 'MessagePack codec';
9+
protected readonly encoder: MsgPackEncoder;
10+
protected readonly decoder: MsgPackDecoder;
11+
12+
constructor(protected readonly writer: Writer) {
13+
this.encoder = new MsgPackEncoder(writer);
14+
this.decoder = new MsgPackDecoder();
15+
}
16+
17+
encode(value: unknown): Uint8Array {
18+
return this.encoder.encode(value);
19+
}
20+
21+
decode(bytes: Uint8Array): unknown {
22+
return this.decoder.read(bytes);
23+
}
24+
}

‎src/codecs/raw.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type {CliCodec} from '../types';
2+
3+
export class CliCodecRaw implements CliCodec<'raw'> {
4+
public readonly id = 'raw';
5+
public readonly description = 'Raw data, useful for strings and binary data';
6+
7+
encode(value: unknown): Uint8Array {
8+
if (value instanceof Uint8Array) return value;
9+
const str = String(value);
10+
return new TextEncoder().encode(str);
11+
}
12+
13+
decode(bytes: Uint8Array): unknown {
14+
throw new Error('Not available');
15+
}
16+
}

0 commit comments

Comments
 (0)