@jinshuju/cli 0.1.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.
@@ -0,0 +1,232 @@
1
+ import { readFileSync } from 'node:fs';
2
+ export const OUTPUT_FORMATS = ['text', 'json'];
3
+ /** Accepted by every command. */
4
+ export const GLOBAL_OPTIONS = [
5
+ { name: '--output', type: 'string', choices: OUTPUT_FORMATS, placeholder: '<format>', description: 'Output format: text, json' },
6
+ { name: '--config', type: 'string', placeholder: '<path>', description: 'Config file path' },
7
+ { name: '--help', short: '-h', type: 'boolean', description: 'Show help' },
8
+ // -V rather than -v: nearly every CLI reads -v as verbose, and a flag that
9
+ // prints a version where the reader expected more output is a small betrayal.
10
+ // -v is left unclaimed, so it is free for --verbose if that ever exists.
11
+ { name: '--version', short: '-V', type: 'boolean', description: 'Show version' }
12
+ ];
13
+ /**
14
+ * What the local commands — auth and config, the ones that never reach the API —
15
+ * accept on top of the global options. They live here so the command table can
16
+ * describe them: help is rendered from that table, and a flag nobody can find
17
+ * in it is a flag nobody knows about.
18
+ */
19
+ export const LOCAL_OPTIONS = [
20
+ { name: '--api-key', type: 'string', placeholder: '<key>', description: 'Override API key' },
21
+ { name: '--api-secret', type: 'string', placeholder: '<secret>', description: 'Override API secret' },
22
+ { name: '--host', type: 'string', placeholder: '<url>', description: 'API host' },
23
+ { name: '--auth-host', type: 'string', placeholder: '<url>', description: 'OAuth host' },
24
+ { name: '--client-id', type: 'string', placeholder: '<id>', description: 'OAuth public client id' },
25
+ { name: '--scopes', type: 'string', placeholder: '<scopes>', description: 'Space-separated OAuth scopes' },
26
+ { name: '--no-open', type: 'boolean', description: 'Print the login URL instead of opening a browser' },
27
+ { name: '--verify', type: 'boolean', description: 'Verify the credentials with a lightweight call' },
28
+ { name: '--show-secret', type: 'boolean', description: 'Show secrets unmasked' }
29
+ ];
30
+ /** The data container a command acts on. Mutually exclusive; both map to form_token. */
31
+ export const CONTAINER_OPTIONS = [
32
+ { name: '--form', type: 'string', placeholder: '<token>', description: 'Form token, six letters and digits, e.g. Kp7mQ2' },
33
+ { name: '--table', type: 'string', placeholder: '<token>', description: 'Table token, six letters and digits, e.g. Vn4xR8' }
34
+ ];
35
+ /**
36
+ * The same container, repeatable, for the reads that answer about several at
37
+ * once. Still one kind per call: `--form` and `--table` stay mutually exclusive.
38
+ */
39
+ export const CONTAINER_LIST_OPTIONS = [
40
+ { name: '--form', type: 'string', repeatable: true, placeholder: '<token>', description: 'Form token, repeatable, e.g. Kp7mQ2' },
41
+ { name: '--table', type: 'string', repeatable: true, placeholder: '<token>', description: 'Table token, repeatable, e.g. Vn4xR8' }
42
+ ];
43
+ export const FILTER_OPTION = {
44
+ name: '--filter',
45
+ type: 'string',
46
+ repeatable: true,
47
+ placeholder: "'<field> <op> [value]'",
48
+ description: "Filter condition, repeatable, AND-combined. e.g. 'field_3 gte 80', 'created_at within_last 30d', 'field_4 between 1,10'"
49
+ };
50
+ export const FILTERS_OPTION = {
51
+ name: '--filters',
52
+ type: 'json',
53
+ placeholder: '<json|@file>',
54
+ description: 'Filter conditions as raw JSON, for conditions --filter cannot express'
55
+ };
56
+ export const SORT_OPTION = {
57
+ name: '--sort',
58
+ type: 'string',
59
+ repeatable: true,
60
+ placeholder: '<field>:asc|desc',
61
+ description: 'Sort rule, repeatable'
62
+ };
63
+ /** Asks for a smaller page; a listing's default is also its cap. */
64
+ export const LIMIT_OPTION = {
65
+ name: '--limit', type: 'integer', placeholder: '<n>', description: 'Rows per page, up to the listing default'
66
+ };
67
+ export const PAGINATION_OPTIONS = [
68
+ LIMIT_OPTION,
69
+ { name: '--next', type: 'string', placeholder: '<cursor>', description: 'Cursor from the previous response, passed back verbatim' },
70
+ { name: '--all', type: 'boolean', description: 'Follow the cursor and return every page' }
71
+ ];
72
+ export const JSON_OPTION = {
73
+ name: '--json',
74
+ type: 'json',
75
+ placeholder: '<json|@file|->',
76
+ description: 'JSON payload: inline, @file, or - to read stdin'
77
+ };
78
+ export const MINE_OPTION = {
79
+ name: '--mine',
80
+ type: 'boolean',
81
+ description: 'Switch to what the current user submitted'
82
+ };
83
+ export class UsageError extends Error {
84
+ }
85
+ /** `--api-key` reads back as `api_key`. */
86
+ export function optionKey(spec) {
87
+ return spec.name.replace(/^--/, '').replace(/-/g, '_');
88
+ }
89
+ const NO_VALUE_OPERATORS = new Set(['null', 'not_null']);
90
+ const PAIR_OPERATORS = new Set(['between', 'not_between']);
91
+ const LIST_OPERATORS = new Set(['any_in', 'none_in']);
92
+ const RELATIVE_UNITS = { d: 'day', w: 'week', m: 'month' };
93
+ /**
94
+ * `field_3 gte 80` into `{field, operator, value}`.
95
+ *
96
+ * Values stay strings. The server converts a condition value by the field's own
97
+ * type — a number field runs it through to_f — so a phone number keeps its
98
+ * digits instead of being guessed into a number here. Only the operators whose
99
+ * value has a shape get one built: a pair, a list, or a relative window.
100
+ */
101
+ export function parseFilter(input) {
102
+ const match = /^\s*(\S+)\s+(\S+)\s*(.*)$/.exec(input);
103
+ if (!match) {
104
+ throw new UsageError(`--filter must be '<field> <op> [value]', got ${JSON.stringify(input)}`);
105
+ }
106
+ const [, field, operator, rest] = match;
107
+ const raw = rest.trim();
108
+ if (NO_VALUE_OPERATORS.has(operator)) {
109
+ if (raw)
110
+ throw new UsageError(`--filter operator '${operator}' takes no value, got ${JSON.stringify(raw)}`);
111
+ return { field, operator };
112
+ }
113
+ if (!raw)
114
+ throw new UsageError(`--filter operator '${operator}' needs a value`);
115
+ if (PAIR_OPERATORS.has(operator)) {
116
+ const parts = splitList(raw);
117
+ if (parts.length !== 2) {
118
+ throw new UsageError(`--filter operator '${operator}' needs two values separated by a comma, got ${JSON.stringify(raw)}`);
119
+ }
120
+ return { field, operator, value: parts };
121
+ }
122
+ if (LIST_OPERATORS.has(operator))
123
+ return { field, operator, value: splitList(raw) };
124
+ if (operator === 'within_last')
125
+ return { field, operator, value: parseRelativeWindow(raw) };
126
+ return { field, operator, value: raw };
127
+ }
128
+ /** `30d` into `{unit: 'day', n: 30}`. */
129
+ function parseRelativeWindow(raw) {
130
+ const match = /^(\d+)\s*([dwm])$/i.exec(raw);
131
+ if (!match) {
132
+ throw new UsageError(`--filter within_last needs a window like 30d, 4w or 6m, got ${JSON.stringify(raw)}`);
133
+ }
134
+ const n = Number.parseInt(match[1], 10);
135
+ if (n <= 0)
136
+ throw new UsageError(`--filter within_last needs a positive window, got ${JSON.stringify(raw)}`);
137
+ return { unit: RELATIVE_UNITS[match[2].toLowerCase()], n };
138
+ }
139
+ /** A comma-separated list, with backslash-escaped commas kept as text. */
140
+ function splitList(raw) {
141
+ return raw
142
+ .split(/(?<!\\),/)
143
+ .map((part) => part.replace(/\\,/g, ',').trim())
144
+ .filter((part) => part.length > 0);
145
+ }
146
+ /** `created_at:desc`; the order defaults to asc, as a bare field reads. */
147
+ export function parseSort(input) {
148
+ const [field, order = 'asc'] = input.split(':');
149
+ if (!field)
150
+ throw new UsageError(`--sort must be '<field>:asc|desc', got ${JSON.stringify(input)}`);
151
+ if (order !== 'asc' && order !== 'desc') {
152
+ throw new UsageError(`--sort order must be asc or desc, got ${JSON.stringify(order)}`);
153
+ }
154
+ return { field, order };
155
+ }
156
+ export const TIME_BUCKETS = ['day', 'week', 'month'];
157
+ /**
158
+ * `avg:field_3`. Which functions a field takes is the field's own answer — read
159
+ * `analytics.agg_funcs` off `form get --include-analytics` — so the function is
160
+ * passed through rather than checked against a list kept here, the same way an
161
+ * operator is.
162
+ */
163
+ export function parseMetric(input) {
164
+ const [func, field] = input.split(':');
165
+ if (!func || !field)
166
+ throw new UsageError(`--metric must be '<func>:<field>', got ${JSON.stringify(input)}`);
167
+ return { func, field };
168
+ }
169
+ /** `field_7`, or `created_at:month` for a date. */
170
+ export function parseDimension(input) {
171
+ const [field, bucket] = input.split(':');
172
+ if (!field)
173
+ throw new UsageError(`--by must be '<field>[:${TIME_BUCKETS.join('|')}]', got ${JSON.stringify(input)}`);
174
+ if (bucket === undefined)
175
+ return { field };
176
+ if (!TIME_BUCKETS.includes(bucket)) {
177
+ throw new UsageError(`--by bucket must be ${TIME_BUCKETS.join(', ')}, got ${JSON.stringify(bucket)}`);
178
+ }
179
+ return { field, bucket };
180
+ }
181
+ // --- json input ------------------------------------------------------------
182
+ /** Inline JSON, `@path`, or `-` for stdin. */
183
+ export function readJsonInput(raw, stdin) {
184
+ let source = raw;
185
+ if (raw === '-') {
186
+ source = stdin();
187
+ }
188
+ else if (raw.startsWith('@')) {
189
+ const path = raw.slice(1);
190
+ try {
191
+ source = readFileSync(path, 'utf8');
192
+ }
193
+ catch (error) {
194
+ throw new UsageError(`could not read ${path}: ${error.message}`);
195
+ }
196
+ }
197
+ try {
198
+ return JSON.parse(source);
199
+ }
200
+ catch (error) {
201
+ throw new UsageError(`invalid JSON: ${error.message}`);
202
+ }
203
+ }
204
+ /**
205
+ * Both `--form` and `--table` address the same API parameter, so exactly one has
206
+ * to be given: a command that guessed would read the wrong object silently.
207
+ */
208
+ export function resolveContainer(options) {
209
+ const form = options.form;
210
+ const table = options.table;
211
+ if (form && table)
212
+ throw new UsageError('--form and --table are mutually exclusive');
213
+ if (form)
214
+ return { token: form, kind: 'form' };
215
+ if (table)
216
+ return { token: table, kind: 'table' };
217
+ throw new UsageError('one of --form <token> or --table <token> is required');
218
+ }
219
+ /** The repeatable form of the above, for a read that answers about several. */
220
+ export function resolveContainers(options, max) {
221
+ const forms = options.form ?? [];
222
+ const tables = options.table ?? [];
223
+ if (forms.length > 0 && tables.length > 0)
224
+ throw new UsageError('--form and --table are mutually exclusive');
225
+ const tokens = forms.length > 0 ? forms : tables;
226
+ if (tokens.length === 0)
227
+ throw new UsageError('one of --form <token> or --table <token> is required');
228
+ if (tokens.length > max) {
229
+ throw new UsageError(`at most ${max} containers can be asked about in one call, got ${tokens.length}`);
230
+ }
231
+ return { tokens, kind: forms.length > 0 ? 'form' : 'table' };
232
+ }
@@ -0,0 +1,12 @@
1
+ export type FormCreatePayload = {
2
+ name: string;
3
+ description?: string;
4
+ fields: Array<Record<string, unknown> & {
5
+ type: string;
6
+ label?: string;
7
+ }>;
8
+ setting?: Record<string, unknown>;
9
+ folder_token?: string;
10
+ };
11
+ export declare function parseJsonPayload(value: string): unknown;
12
+ export declare function validateCreateFormPayload(payload: unknown): FormCreatePayload;
@@ -0,0 +1,59 @@
1
+ const API_V1_FIELD_TYPES = new Set([
2
+ 'TextField', 'TextArea', 'NumberField', 'EmailField', 'MobileField', 'TelephoneField', 'IdCardField',
3
+ 'NameField', 'AddressField', 'LinkField', 'GeoField', 'AttachmentField', 'DateTimeField', 'TimeField',
4
+ 'RatingField', 'NpsField', 'RadioButton', 'CheckBox', 'DropDown', 'TableField', 'CascadeDropDown',
5
+ 'SortField', 'LikertField', 'MatrixField', 'MatrixScaleField', 'ImageRadioButton', 'ImageCheckBox',
6
+ 'GoodsField', 'FormulaField', 'ReservationField', 'FormAssociation', 'ESignatureField', 'AudioField',
7
+ 'PageBreak', 'SectionBreak', 'WidgetButton', 'WidgetContact', 'WidgetMap', 'WidgetMarquee'
8
+ ]);
9
+ /**
10
+ * The question types only a scorable scene has. They are not Fields::* classes
11
+ * of their own — each persists as a base field plus a customized_type and the
12
+ * correct answers — which is why they are absent from the list above and were
13
+ * being refused here: `--type exam` could create an exam-scene form and then
14
+ * not one question that scores, the only thing the scene is for.
15
+ *
16
+ * Which scene accepts which is the server's answer, and it names them in the
17
+ * refusal; there is nothing to duplicate here beyond letting them through.
18
+ */
19
+ const SCENE_FIELD_TYPES = new Set([
20
+ 'SingleSelect', 'MultiSelect', 'ImageSingleSelect', 'ImageMultiSelect', 'TrueOrFalse', 'DropDownSelect',
21
+ 'FillInBlank', 'ShortAnswer', 'FillInNumber', 'Rating', 'Nps',
22
+ 'Department', 'Grade'
23
+ ]);
24
+ export function parseJsonPayload(value) {
25
+ try {
26
+ return JSON.parse(value);
27
+ }
28
+ catch (error) {
29
+ throw new Error(`Invalid JSON payload: ${error.message}`);
30
+ }
31
+ }
32
+ export function validateCreateFormPayload(payload) {
33
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
34
+ throw new Error('Form payload must be a JSON object');
35
+ }
36
+ const form = payload;
37
+ if (typeof form.name !== 'string' || form.name.trim() === '') {
38
+ throw new Error('Form payload requires non-empty name');
39
+ }
40
+ if (!Array.isArray(form.fields) || form.fields.length === 0) {
41
+ throw new Error('Form payload requires at least one field');
42
+ }
43
+ for (const field of form.fields) {
44
+ if (!field || typeof field !== 'object' || Array.isArray(field)) {
45
+ throw new Error('Each field must be an object');
46
+ }
47
+ const fieldObject = field;
48
+ if ('api_code' in fieldObject) {
49
+ throw new Error('Do not pass api_code when creating fields; backend generates it');
50
+ }
51
+ if (typeof fieldObject.type !== 'string' || !(API_V1_FIELD_TYPES.has(fieldObject.type) || SCENE_FIELD_TYPES.has(fieldObject.type))) {
52
+ throw new Error(`Field type must be an API v1 field type, got ${String(fieldObject.type)}`);
53
+ }
54
+ if (typeof fieldObject.label !== 'string' && fieldObject.type !== 'PageBreak') {
55
+ throw new Error('Each field requires label');
56
+ }
57
+ }
58
+ return form;
59
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Progress for the person watching, and nothing for anyone else.
3
+ *
4
+ * It writes to stderr and only when stderr is a terminal. That is what lets a
5
+ * command say "uploading…" to someone waiting at a prompt while `--output json
6
+ * | jq` stays byte-for-byte what it was, and while an agent reading the pipe
7
+ * sees no decoration it would have to strip.
8
+ *
9
+ * A step overwrites the line before it, so a long run stays one line rather
10
+ * than a scroll of near-identical ones. done() clears whatever is left.
11
+ */
12
+ export interface Progress {
13
+ step(message: string): void;
14
+ done(): void;
15
+ }
16
+ export declare function progress(stream?: NodeJS.WriteStream): Progress;
@@ -0,0 +1,17 @@
1
+ const SILENT = { step: () => { }, done: () => { } };
2
+ export function progress(stream = process.stderr) {
3
+ if (!stream.isTTY)
4
+ return SILENT;
5
+ let width = 0;
6
+ return {
7
+ step(message) {
8
+ stream.write(`\r${' '.repeat(width)}\r${message}`);
9
+ width = message.length;
10
+ },
11
+ done() {
12
+ if (width > 0)
13
+ stream.write(`\r${' '.repeat(width)}\r`);
14
+ width = 0;
15
+ }
16
+ };
17
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@jinshuju/cli",
3
+ "version": "0.1.1",
4
+ "description": "Command line interface for Jinshuju Open API v1",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jinshuju/cli.git"
9
+ },
10
+ "homepage": "https://github.com/jinshuju/cli#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/jinshuju/cli/issues"
13
+ },
14
+ "type": "module",
15
+ "bin": {
16
+ "jinshuju": "dist/cli-bin.js",
17
+ "jsj": "dist/cli-bin.js"
18
+ },
19
+ "scripts": {
20
+ "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"",
21
+ "build": "npm run clean && tsc -p tsconfig.json && chmod +x dist/cli-bin.js",
22
+ "prepare": "npm run build",
23
+ "test": "npm run build && node --test dist/*.test.js",
24
+ "typecheck": "tsc -p tsconfig.json --noEmit",
25
+ "lint": "tsc -p tsconfig.json --noEmit"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "!dist/**/*.test.js",
30
+ "!dist/**/*.test.d.ts"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.10.0",
37
+ "typescript": "^5.9.3"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "registry": "https://registry.npmjs.org"
42
+ }
43
+ }