@shuffl/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ export interface CliIO {
2
+ env: NodeJS.ProcessEnv;
3
+ stdin: () => Promise<string>;
4
+ stdout: (value: string) => void;
5
+ stderr: (value: string) => void;
6
+ }
7
+ export declare function runCli(args: string[], io?: CliIO): Promise<number>;
package/dist/index.js ADDED
@@ -0,0 +1,207 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile, } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { createShuffl, ORPCError } from '@shuffl/sdk';
7
+ import { commands } from './commands.generated.js';
8
+ // package.json sits one level above both src/ and dist/.
9
+ const { version } = createRequire(import.meta.url)('../package.json');
10
+ async function request(run) {
11
+ try {
12
+ return await run();
13
+ }
14
+ catch (error) {
15
+ if (error instanceof ORPCError)
16
+ throw error;
17
+ throw new ORPCError('SERVICE_UNAVAILABLE', {
18
+ message: 'Could not read a response from Shuffl. Check the current resource or command receipt before retrying a write.',
19
+ });
20
+ }
21
+ }
22
+ const stdin = async () => {
23
+ const chunks = [];
24
+ let size = 0;
25
+ for await (const value of process.stdin) {
26
+ const chunk = Buffer.from(value);
27
+ size += chunk.length;
28
+ if (size > 1024 * 1024)
29
+ throw new Error('Input exceeds 1 MiB.');
30
+ chunks.push(chunk);
31
+ }
32
+ return Buffer.concat(chunks).toString('utf8');
33
+ };
34
+ function parse(args) {
35
+ const positional = [];
36
+ const flags = {};
37
+ for (let i = 0; i < args.length; i++) {
38
+ const arg = args[i];
39
+ if (!arg.startsWith('--')) {
40
+ positional.push(arg);
41
+ continue;
42
+ }
43
+ const [name, ...value] = arg.slice(2).split('=');
44
+ if (!name || name in flags)
45
+ throw new Error(`Duplicate or invalid flag: ${arg}`);
46
+ if (['help', 'json', 'token-stdin', 'version'].includes(name)) {
47
+ if (value.length)
48
+ throw new Error(`--${name} takes no value`);
49
+ flags[name] = true;
50
+ continue;
51
+ }
52
+ const next = value.length ? value.join('=') : args[++i];
53
+ if (next === undefined || next.startsWith('--'))
54
+ throw new Error(`--${name} requires a value`);
55
+ flags[name] = next;
56
+ }
57
+ return { flags, positional };
58
+ }
59
+ function scalar(raw, schema) {
60
+ if (schema.type === 'integer' || schema.type === 'number') {
61
+ const value = Number(raw);
62
+ if (!Number.isFinite(value))
63
+ throw new Error(`Expected a number, received ${raw}`);
64
+ return value;
65
+ }
66
+ if (schema.type === 'boolean') {
67
+ if (raw !== 'true' && raw !== 'false')
68
+ throw new Error('Boolean flags accept true or false.');
69
+ return raw === 'true';
70
+ }
71
+ if (schema.type === 'object' || schema.type === 'array')
72
+ return JSON.parse(raw);
73
+ return raw;
74
+ }
75
+ export async function runCli(args, io = {
76
+ env: process.env,
77
+ stderr: (s) => process.stderr.write(s),
78
+ stdin,
79
+ stdout: (s) => process.stdout.write(s),
80
+ }) {
81
+ const json = args.includes('--json');
82
+ try {
83
+ const { positional, flags } = parse(args);
84
+ const configFile = join(io.env.SHUFFL_CONFIG_DIR ??
85
+ join(io.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'shuffl'), 'config.json');
86
+ if (flags.version && !positional.length) {
87
+ io.stdout(json ? `${JSON.stringify({ version })}\n` : `${version}\n`);
88
+ return 0;
89
+ }
90
+ if (flags.help || !positional.length) {
91
+ const definition = commands.find((c) => c.command.join(' ') === positional.join(' '));
92
+ io.stdout(definition
93
+ ? `${definition.summary}\nPermission: ${definition.permission} (${definition.access})\n\nshuffl ${definition.command.join(' ')} [--input JSON | --input-file FILE] [--json]\n${Object.entries(definition.input?.properties ?? {})
94
+ .map(([key, value]) => ` --${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} <${value.type ?? 'json'}>`)
95
+ .join('\n')}\n`
96
+ : `Shuffl CLI ${version}\n\n shuffl login --base-url URL --token-stdin\n shuffl logout\n shuffl --version\n${commands.map((c) => ` shuffl ${c.command.join(' ')} — ${c.summary}`).join('\n')}\n\nUse --help on a command for inputs. Use --json for machine-readable output.\nCreate a key in Settings → API keys and choose the endpoint permissions this CLI needs.\n`);
97
+ return 0;
98
+ }
99
+ if (positional.length === 1 && positional[0] === 'logout') {
100
+ if (Object.keys(flags).some((k) => k !== 'json'))
101
+ throw new Error('Unknown logout flag.');
102
+ await rm(configFile, { force: true });
103
+ io.stdout(json
104
+ ? '{}\n'
105
+ : 'Signed out locally. Revoke the token in Settings to disable other copies.\n');
106
+ return 0;
107
+ }
108
+ if (positional.length === 1 && positional[0] === 'login') {
109
+ if (Object.keys(flags).some((k) => !['base-url', 'token-stdin', 'json'].includes(k)))
110
+ throw new Error('Unknown login flag.');
111
+ if (!flags['token-stdin'] || typeof flags['base-url'] !== 'string')
112
+ throw new Error('Use login --base-url URL --token-stdin. The token is read from standard input.');
113
+ const token = (await io.stdin()).trim();
114
+ if (!/^shuffl_api_[A-Za-z0-9_-]{43}$/.test(token))
115
+ throw new Error('Expected a Shuffl API token.');
116
+ const baseUrl = new URL(flags['base-url']).origin;
117
+ const client = createShuffl({
118
+ baseUrl,
119
+ token,
120
+ });
121
+ const workspace = await request(() => client.workspaces.current());
122
+ await mkdir(dirname(configFile), { mode: 0o700, recursive: true });
123
+ const temporary = `${configFile}.${randomUUID()}.tmp`;
124
+ try {
125
+ await writeFile(temporary, JSON.stringify({ baseUrl, token }) + '\n', {
126
+ flag: 'wx',
127
+ mode: 0o600,
128
+ });
129
+ await rename(temporary, configFile);
130
+ await chmod(configFile, 0o600);
131
+ }
132
+ finally {
133
+ await rm(temporary, { force: true });
134
+ }
135
+ io.stdout(json
136
+ ? JSON.stringify({ workspace }) + '\n'
137
+ : `Signed in to ${workspace.name}.\n`);
138
+ return 0;
139
+ }
140
+ const command = commands.find((c) => c.command.join(' ') === positional.join(' '));
141
+ if (!command)
142
+ throw new Error('Unknown command. Run shuffl --help.');
143
+ const fields = command.input?.properties ?? {};
144
+ const names = Object.fromEntries(Object.keys(fields).map((key) => [
145
+ key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`),
146
+ key,
147
+ ]));
148
+ for (const key of Object.keys(flags))
149
+ if (!['json', 'input', 'input-file', 'base-url'].includes(key) &&
150
+ !(key in names))
151
+ throw new Error(`Unknown flag --${key}.`);
152
+ if (flags.input && flags['input-file'])
153
+ throw new Error('Choose --input or --input-file.');
154
+ let input;
155
+ if (typeof flags.input === 'string')
156
+ input = JSON.parse(flags.input === '-' ? await io.stdin() : flags.input);
157
+ else if (typeof flags['input-file'] === 'string')
158
+ input = JSON.parse(await readFile(flags['input-file'], 'utf8'));
159
+ else
160
+ input = command.input ? {} : undefined;
161
+ if (input !== undefined &&
162
+ (typeof input !== 'object' || input === null || Array.isArray(input)))
163
+ throw new Error('Input must be a JSON object.');
164
+ for (const [flag, key] of Object.entries(names))
165
+ if (typeof flags[flag] === 'string') {
166
+ if (input && Object.hasOwn(input, key))
167
+ throw new Error(`Input ${key} was provided more than once.`);
168
+ input[key] = scalar(flags[flag], fields[key]);
169
+ }
170
+ let saved;
171
+ try {
172
+ saved = JSON.parse(await readFile(configFile, 'utf8'));
173
+ }
174
+ catch (error) {
175
+ if (error.code !== 'ENOENT')
176
+ throw new Error('Cannot read CLI configuration. Run shuffl login again.');
177
+ }
178
+ const token = io.env.SHUFFL_TOKEN ?? saved?.token;
179
+ const baseUrl = typeof flags['base-url'] === 'string'
180
+ ? flags['base-url']
181
+ : (io.env.SHUFFL_BASE_URL ?? saved?.baseUrl);
182
+ if (!token || !baseUrl)
183
+ throw new Error('Run shuffl login, or set SHUFFL_TOKEN and SHUFFL_BASE_URL.');
184
+ if (!io.env.SHUFFL_TOKEN &&
185
+ saved &&
186
+ new URL(baseUrl).origin !== new URL(saved.baseUrl).origin)
187
+ throw new Error('Saved credentials belong to a different server. Sign in to the target server first.');
188
+ let method = createShuffl({ baseUrl, token });
189
+ for (const name of command.path)
190
+ method = method[name];
191
+ const output = await request(() => method(input));
192
+ io.stdout(JSON.stringify(output, null, json ? undefined : 2) + '\n');
193
+ return 0;
194
+ }
195
+ catch (error) {
196
+ const code = error instanceof ORPCError ? error.code : 'CLI_ERROR';
197
+ const message = error instanceof Error ? error.message : 'Command failed.';
198
+ (json ? io.stdout : io.stderr)(json
199
+ ? JSON.stringify({ error: { code, message } }) + '\n'
200
+ : `${message}\n`);
201
+ return code === 'UNAUTHORIZED' || code === 'FORBIDDEN'
202
+ ? 3
203
+ : code === 'CLI_ERROR' || code === 'BAD_REQUEST'
204
+ ? 2
205
+ : 1;
206
+ }
207
+ }
@@ -0,0 +1,16 @@
1
+ export interface JsonSchema {
2
+ [key: string]: unknown;
3
+ type?: string | string[];
4
+ properties?: Record<string, JsonSchema>;
5
+ required?: string[];
6
+ description?: string;
7
+ }
8
+ export interface CommandDefinition {
9
+ permission: string;
10
+ access: 'read' | 'write';
11
+ path: string[];
12
+ command: string[];
13
+ summary: string;
14
+ scope: string | null;
15
+ input: JsonSchema | null;
16
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "dependencies": {
3
+ "@orpc/client": "1.15.3",
4
+ "@shuffl/api-contracts": "0.1.0",
5
+ "@shuffl/sdk": "0.1.0"
6
+ },
7
+ "description": "Shuffl command line: every public API operation as a scriptable command with JSON output.",
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "LICENSE",
20
+ "README.md"
21
+ ],
22
+ "homepage": "https://shuffl-rebuild.vercel.app/docs/sdk",
23
+ "keywords": [
24
+ "shuffl",
25
+ "cli",
26
+ "hr"
27
+ ],
28
+ "license": "MIT",
29
+ "name": "@shuffl/cli",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "sideEffects": false,
34
+ "type": "module",
35
+ "types": "./dist/index.d.ts",
36
+ "version": "0.1.0",
37
+ "bin": {
38
+ "shuffl": "./dist/bin.js"
39
+ }
40
+ }