@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.
- package/LICENSE +202 -0
- package/README.md +311 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.js +184 -0
- package/dist/cli-bin.d.ts +2 -0
- package/dist/cli-bin.js +8 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +699 -0
- package/dist/commands.d.ts +84 -0
- package/dist/commands.js +1672 -0
- package/dist/config.d.ts +64 -0
- package/dist/config.js +99 -0
- package/dist/help.d.ts +15 -0
- package/dist/help.js +98 -0
- package/dist/http.d.ts +29 -0
- package/dist/http.js +127 -0
- package/dist/options.d.ts +102 -0
- package/dist/options.js +232 -0
- package/dist/payload.d.ts +12 -0
- package/dist/payload.js +59 -0
- package/dist/progress.d.ts +16 -0
- package/dist/progress.js +17 -0
- package/package.json +43 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { assertConfigKey, defaultConfigPath, getConfig, loadConfig, maskSecret, setConfigValue, unsetConfigValue } from './config.js';
|
|
3
|
+
import { loginWithOAuth, refreshOAuthToken, revokeOAuthToken } from './auth.js';
|
|
4
|
+
import { progress } from './progress.js';
|
|
5
|
+
import { findCommand } from './commands.js';
|
|
6
|
+
import { helpFor, rootHelp, unknownCommandHelp } from './help.js';
|
|
7
|
+
import { JinshujuHttpClient } from './http.js';
|
|
8
|
+
import { GLOBAL_OPTIONS, LOCAL_OPTIONS, UsageError, optionKey, readJsonInput } from './options.js';
|
|
9
|
+
/**
|
|
10
|
+
* Splits the command line before a command is known: `--help` and an unknown
|
|
11
|
+
* command both have to work without one.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* The words that name the command: enough to find it, and no more.
|
|
15
|
+
*
|
|
16
|
+
* A flag may come first — `jinshuju --config local.json form list` is what a
|
|
17
|
+
* shell alias expands to, and what anyone arriving from `git -C` or
|
|
18
|
+
* `kubectl --context` writes — so a flag this CLI knows without a command is
|
|
19
|
+
* stepped over, along with its value. Stopping at it instead reported "Unknown
|
|
20
|
+
* command: jinshuju form list" while offering that very command as a
|
|
21
|
+
* suggestion.
|
|
22
|
+
*
|
|
23
|
+
* An unknown flag still ends the scan. Only a command declares those, so by the
|
|
24
|
+
* time one appears the command has been named already.
|
|
25
|
+
*/
|
|
26
|
+
function leadingWords(argv) {
|
|
27
|
+
const known = new Map();
|
|
28
|
+
for (const spec of [...GLOBAL_OPTIONS, ...LOCAL_OPTIONS]) {
|
|
29
|
+
known.set(spec.name, spec);
|
|
30
|
+
if (spec.short)
|
|
31
|
+
known.set(spec.short, spec);
|
|
32
|
+
}
|
|
33
|
+
const words = [];
|
|
34
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
35
|
+
const token = argv[index];
|
|
36
|
+
if (!token.startsWith('-') || token === '-') {
|
|
37
|
+
words.push(token);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const equals = token.indexOf('=');
|
|
41
|
+
const spec = known.get(equals === -1 ? token : token.slice(0, equals));
|
|
42
|
+
if (!spec)
|
|
43
|
+
break;
|
|
44
|
+
if (equals === -1 && spec.type !== 'boolean')
|
|
45
|
+
index += 1;
|
|
46
|
+
}
|
|
47
|
+
return words;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Splits the command line, knowing which flags take a value.
|
|
51
|
+
*
|
|
52
|
+
* Guessing from the shape of the next token gets two things wrong that a
|
|
53
|
+
* caller has every right to write: `--json -`, where the value is the very
|
|
54
|
+
* character that looks like a flag, and `--yes 12`, where a boolean must not
|
|
55
|
+
* swallow the argument behind it. Both are decided by the option's own type,
|
|
56
|
+
* so the specs are passed in rather than inferred.
|
|
57
|
+
*/
|
|
58
|
+
function splitArgs(argv, specs = []) {
|
|
59
|
+
const takesValue = new Map();
|
|
60
|
+
for (const spec of specs) {
|
|
61
|
+
takesValue.set(spec.name, spec.type !== 'boolean');
|
|
62
|
+
if (spec.short)
|
|
63
|
+
takesValue.set(spec.short, spec.type !== 'boolean');
|
|
64
|
+
}
|
|
65
|
+
const words = [];
|
|
66
|
+
const flags = {};
|
|
67
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
68
|
+
const token = argv[index];
|
|
69
|
+
if (token === '--') {
|
|
70
|
+
words.push(...argv.slice(index + 1));
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
if (!token.startsWith('-') || token === '-') {
|
|
74
|
+
words.push(token);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const equals = token.indexOf('=');
|
|
78
|
+
const flag = equals === -1 ? token : token.slice(0, equals);
|
|
79
|
+
const inline = equals === -1 ? undefined : token.slice(equals + 1);
|
|
80
|
+
const next = argv[index + 1];
|
|
81
|
+
// An unknown flag is assumed to take a value, so it reaches bindOptions
|
|
82
|
+
// with whatever followed it and is refused by name rather than by shape.
|
|
83
|
+
const wanted = takesValue.get(flag) ?? true;
|
|
84
|
+
const consumable = wanted && next !== undefined && (next === '-' || !next.startsWith('-'));
|
|
85
|
+
const value = inline ?? (consumable ? (index += 1, next) : true);
|
|
86
|
+
const existing = flags[flag];
|
|
87
|
+
flags[flag] = existing === undefined ? value : [].concat(existing, value);
|
|
88
|
+
}
|
|
89
|
+
return { words, flags };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Checks the flags against what this command declares. A flag belonging to
|
|
93
|
+
* another command is an error rather than something quietly ignored: that is
|
|
94
|
+
* how a caller learns it asked for something that was never going to happen.
|
|
95
|
+
*/
|
|
96
|
+
function bindOptions(specs, flags, label, stdin) {
|
|
97
|
+
const byFlag = new Map();
|
|
98
|
+
for (const spec of specs) {
|
|
99
|
+
byFlag.set(spec.name, spec);
|
|
100
|
+
if (spec.short)
|
|
101
|
+
byFlag.set(spec.short, spec);
|
|
102
|
+
}
|
|
103
|
+
const bound = {};
|
|
104
|
+
for (const [flag, value] of Object.entries(flags)) {
|
|
105
|
+
const spec = byFlag.get(flag);
|
|
106
|
+
if (!spec)
|
|
107
|
+
throw new UsageError(`${label} does not take ${flag}. Run it with --help to see what it does take.`);
|
|
108
|
+
bound[optionKey(spec)] = coerce(spec, value, stdin);
|
|
109
|
+
}
|
|
110
|
+
return bound;
|
|
111
|
+
}
|
|
112
|
+
function coerce(spec, value, stdin) {
|
|
113
|
+
if (spec.repeatable)
|
|
114
|
+
return [].concat(value).map((item) => coerceOne(spec, item, stdin));
|
|
115
|
+
if (Array.isArray(value))
|
|
116
|
+
throw new UsageError(`${spec.name} takes a single value, but was given more than once`);
|
|
117
|
+
return coerceOne(spec, value, stdin);
|
|
118
|
+
}
|
|
119
|
+
function coerceOne(spec, value, stdin) {
|
|
120
|
+
if (spec.type === 'boolean') {
|
|
121
|
+
if (value === true || value === 'true')
|
|
122
|
+
return true;
|
|
123
|
+
if (value === 'false')
|
|
124
|
+
return false;
|
|
125
|
+
throw new UsageError(`${spec.name} is a flag and takes no value`);
|
|
126
|
+
}
|
|
127
|
+
if (value === true)
|
|
128
|
+
throw new UsageError(`${spec.name} needs a value`);
|
|
129
|
+
const text = String(value);
|
|
130
|
+
if (spec.choices && !spec.choices.includes(text)) {
|
|
131
|
+
throw new UsageError(`${spec.name} must be one of ${spec.choices.join(', ')}, got ${JSON.stringify(text)}`);
|
|
132
|
+
}
|
|
133
|
+
if (spec.type === 'integer') {
|
|
134
|
+
if (!/^\d+$/.test(text))
|
|
135
|
+
throw new UsageError(`${spec.name} must be a whole number, got ${JSON.stringify(text)}`);
|
|
136
|
+
return Number.parseInt(text, 10);
|
|
137
|
+
}
|
|
138
|
+
if (spec.type === 'list')
|
|
139
|
+
return text.split(',').map((item) => item.trim()).filter(Boolean);
|
|
140
|
+
if (spec.type === 'json')
|
|
141
|
+
return readJsonInput(text, stdin);
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
function bindArgs(command, words) {
|
|
145
|
+
const positionals = words.slice(command.path.length);
|
|
146
|
+
const specs = command.args ?? [];
|
|
147
|
+
const args = {};
|
|
148
|
+
let rest = [];
|
|
149
|
+
specs.forEach((arg, index) => {
|
|
150
|
+
if (arg.variadic) {
|
|
151
|
+
rest = positionals.slice(index);
|
|
152
|
+
if (arg.required && rest.length === 0) {
|
|
153
|
+
throw new UsageError(`jinshuju ${command.path.join(' ')} needs <${arg.name}>: ${arg.description}`);
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const value = positionals[index];
|
|
158
|
+
if (value === undefined) {
|
|
159
|
+
if (arg.required)
|
|
160
|
+
throw new UsageError(`jinshuju ${command.path.join(' ')} needs <${arg.name}>: ${arg.description}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
args[arg.name] = value;
|
|
164
|
+
});
|
|
165
|
+
if (!specs.some((arg) => arg.variadic)) {
|
|
166
|
+
const extra = positionals.slice(specs.length);
|
|
167
|
+
if (extra.length > 0) {
|
|
168
|
+
throw new UsageError(`jinshuju ${command.path.join(' ')} takes no argument ${JSON.stringify(extra[0])}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { args, rest };
|
|
172
|
+
}
|
|
173
|
+
function localOptions(flags, stdin) {
|
|
174
|
+
const bound = bindOptions([...GLOBAL_OPTIONS, ...LOCAL_OPTIONS], flags, 'this command', stdin);
|
|
175
|
+
return {
|
|
176
|
+
output: bound.output ?? 'text',
|
|
177
|
+
configPath: bound.config ?? defaultConfigPath,
|
|
178
|
+
apiKey: bound.api_key,
|
|
179
|
+
apiSecret: bound.api_secret,
|
|
180
|
+
host: bound.host,
|
|
181
|
+
authHost: bound.auth_host,
|
|
182
|
+
clientId: bound.client_id,
|
|
183
|
+
scopes: bound.scopes,
|
|
184
|
+
noOpen: Boolean(bound.no_open),
|
|
185
|
+
verify: Boolean(bound.verify),
|
|
186
|
+
showSecret: Boolean(bound.show_secret)
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export const VERSION = '0.1.0';
|
|
190
|
+
function ok(stdout) {
|
|
191
|
+
return { exitCode: 0, stdout: stdout.endsWith('\n') ? stdout : `${stdout}\n`, stderr: '' };
|
|
192
|
+
}
|
|
193
|
+
function fail(message, exitCode = 2) {
|
|
194
|
+
return { exitCode, stdout: '', stderr: `Error: ${message}\n` };
|
|
195
|
+
}
|
|
196
|
+
const MAX_CELL = 120;
|
|
197
|
+
/** What a table is allowed to be wide when nobody is watching it on a screen. */
|
|
198
|
+
const PIPED_WIDTH = 120;
|
|
199
|
+
/** Columns that say which row this is; they earn their place before any value. */
|
|
200
|
+
const LEADING_COLUMNS = ['token', 'api_code', 'serial_number', 'id', 'name', 'title', 'label', 'type', 'state', 'status'];
|
|
201
|
+
/**
|
|
202
|
+
* Timestamps go last, however early they appear in the payload. On a listing of
|
|
203
|
+
* rows they are the least of what the reader came for, and taking them in
|
|
204
|
+
* payload order is what left `entry list` showing two of a form's ten fields.
|
|
205
|
+
*/
|
|
206
|
+
const TRAILING_COLUMNS = ['created_at', 'updated_at'];
|
|
207
|
+
/**
|
|
208
|
+
* A column that names the row rather than saying anything about it. The listed
|
|
209
|
+
* ones plus whatever ends in `_token` or `_id`, because a search answers with
|
|
210
|
+
* `form_token` and a row showing only that has told the reader nothing.
|
|
211
|
+
*/
|
|
212
|
+
function identifies(column) {
|
|
213
|
+
return LEADING_COLUMNS.includes(column) || /(^|_)(token|id)$/.test(column);
|
|
214
|
+
}
|
|
215
|
+
export function terminalWidth(stream = process.stdout) {
|
|
216
|
+
return stream.isTTY && stream.columns > 0 ? stream.columns : PIPED_WIDTH;
|
|
217
|
+
}
|
|
218
|
+
function json(value) {
|
|
219
|
+
return JSON.stringify(value, null, 2);
|
|
220
|
+
}
|
|
221
|
+
function text(value, width) {
|
|
222
|
+
if (typeof value === 'string')
|
|
223
|
+
return value;
|
|
224
|
+
if (value === undefined || value === null)
|
|
225
|
+
return '';
|
|
226
|
+
if (Array.isArray(value))
|
|
227
|
+
return renderList(value, width);
|
|
228
|
+
if (typeof value === 'object')
|
|
229
|
+
return renderObject(value, width);
|
|
230
|
+
return String(value);
|
|
231
|
+
}
|
|
232
|
+
function renderObject(value, width) {
|
|
233
|
+
const listKey = ['data', 'items', 'forms', 'entries', 'views'].find((key) => Array.isArray(value[key]));
|
|
234
|
+
if (listKey) {
|
|
235
|
+
// Everything beside the listing is rendered, not just its scalars. Filtering
|
|
236
|
+
// to scalars here was another way for a payload to lose a key on the way to
|
|
237
|
+
// the page — the warnings an import answers with, say.
|
|
238
|
+
const heading = Object.entries(value)
|
|
239
|
+
.filter(([key]) => key !== listKey)
|
|
240
|
+
.map(([key, fieldValue]) => renderEntry(key, fieldValue, width));
|
|
241
|
+
const listText = renderList(value[listKey], width);
|
|
242
|
+
return [...heading, `${listKey}:`, listText].filter(Boolean).join('\n');
|
|
243
|
+
}
|
|
244
|
+
const entries = Object.entries(value);
|
|
245
|
+
if (entries.length === 0)
|
|
246
|
+
return '{}';
|
|
247
|
+
return entries.map(([key, fieldValue]) => renderEntry(key, fieldValue, width)).join('\n');
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* A setting is an object of objects, and printing it as JSON asks the reader to
|
|
251
|
+
* parse braces to find one flag. Nesting goes one indent deeper instead, so the
|
|
252
|
+
* shape stays visible and every leaf reads as `key: value`.
|
|
253
|
+
*/
|
|
254
|
+
function renderEntry(key, value, width) {
|
|
255
|
+
if (Array.isArray(value)) {
|
|
256
|
+
return value.length === 0 ? `${key}: (empty)` : `${key}:\n${indent(renderList(value, width))}`;
|
|
257
|
+
}
|
|
258
|
+
if (value !== null && typeof value === 'object') {
|
|
259
|
+
const block = renderObject(value, width);
|
|
260
|
+
return block === '{}' ? `${key}: {}` : `${key}:\n${indent(block)}`;
|
|
261
|
+
}
|
|
262
|
+
return `${key}: ${formatCell(value)}`;
|
|
263
|
+
}
|
|
264
|
+
function indent(block) {
|
|
265
|
+
return block.split('\n').map((line) => (line ? ` ${line}` : line)).join('\n');
|
|
266
|
+
}
|
|
267
|
+
function renderList(values, width) {
|
|
268
|
+
if (values.length === 0)
|
|
269
|
+
return '(empty)';
|
|
270
|
+
if (!values.every(isRecord))
|
|
271
|
+
return values.map((item) => formatField(item)).join('\n');
|
|
272
|
+
const { rows, headings } = splitLabels(values.map(unwrapKeyed));
|
|
273
|
+
if (rows.some(hasEssentialList))
|
|
274
|
+
return rows.map((row) => renderObject(row, width)).join('\n\n');
|
|
275
|
+
const heading = (column) => headings.get(column) ?? column;
|
|
276
|
+
const candidates = candidateColumns(rows);
|
|
277
|
+
if (candidates.length === 0)
|
|
278
|
+
return rows.map((row) => json(row)).join('\n');
|
|
279
|
+
const columns = [];
|
|
280
|
+
const widths = [];
|
|
281
|
+
let used = 0;
|
|
282
|
+
for (const column of candidates) {
|
|
283
|
+
const columnWidth = Math.max(displayWidth(heading(column)), ...rows.map((row) => displayWidth(formatCell(row[column]))));
|
|
284
|
+
const next = used + (columns.length === 0 ? 0 : 2) + columnWidth;
|
|
285
|
+
// The first column goes in whatever it costs: a table of nothing is worse
|
|
286
|
+
// than a table too wide.
|
|
287
|
+
if (columns.length > 0 && next > width)
|
|
288
|
+
break;
|
|
289
|
+
columns.push(column);
|
|
290
|
+
widths.push(columnWidth);
|
|
291
|
+
used = next;
|
|
292
|
+
}
|
|
293
|
+
// A row that says nothing but its own name says nothing at all — and the
|
|
294
|
+
// column that would have explained it is exactly the one a narrow terminal
|
|
295
|
+
// drops. `entry search` keeps a form it could not read *with the reason*, and
|
|
296
|
+
// the budget must not be what throws that reason away. A row left with only
|
|
297
|
+
// its identity buys back one column, whatever the width says.
|
|
298
|
+
const told = (row, column) => !identifies(column) && formatCell(row[column]) !== '';
|
|
299
|
+
for (const row of rows) {
|
|
300
|
+
if (columns.some((column) => told(row, column)))
|
|
301
|
+
continue;
|
|
302
|
+
const rescued = candidates.find((column) => !columns.includes(column) && told(row, column));
|
|
303
|
+
if (!rescued)
|
|
304
|
+
continue;
|
|
305
|
+
columns.push(rescued);
|
|
306
|
+
widths.push(Math.max(displayWidth(heading(rescued)), ...rows.map((other) => displayWidth(formatCell(other[rescued])))));
|
|
307
|
+
}
|
|
308
|
+
const header = columns.map((column, index) => pad(heading(column), widths[index])).join(' ');
|
|
309
|
+
const separator = widths.map((columnWidth) => '-'.repeat(columnWidth)).join(' ');
|
|
310
|
+
const body = rows.map((row) => columns.map((column, index) => pad(formatCell(row[column]), widths[index])).join(' '));
|
|
311
|
+
return [header, separator, ...body].join('\n');
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Every column the rows could show, best first. How many of them fit is the
|
|
315
|
+
* caller's question, and it needs their widths to answer it.
|
|
316
|
+
*/
|
|
317
|
+
function candidateColumns(rows) {
|
|
318
|
+
const present = (key) => rows.some((row) => Object.prototype.hasOwnProperty.call(row, key) && isCell(row[key]));
|
|
319
|
+
const seen = new Set(LEADING_COLUMNS.filter(present));
|
|
320
|
+
for (const row of rows) {
|
|
321
|
+
for (const [key, value] of Object.entries(row)) {
|
|
322
|
+
if (isCell(value) && !TRAILING_COLUMNS.includes(key))
|
|
323
|
+
seen.add(key);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const key of TRAILING_COLUMNS.filter(present))
|
|
327
|
+
seen.add(key);
|
|
328
|
+
return [...seen];
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* A form's fields arrive as `{ "field_1": { label, type, ... } }`, one key per
|
|
332
|
+
* row. A table of those reads as a column per field and nothing in it, so the
|
|
333
|
+
* key becomes a cell of its own row instead.
|
|
334
|
+
*/
|
|
335
|
+
function unwrapKeyed(row) {
|
|
336
|
+
const entries = Object.entries(row);
|
|
337
|
+
if (entries.length !== 1)
|
|
338
|
+
return row;
|
|
339
|
+
const [key, value] = entries[0];
|
|
340
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
341
|
+
return row;
|
|
342
|
+
return { api_code: key, ...value };
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* `--labels` answers each field as `{ label, value }`. A cell cannot hold a
|
|
346
|
+
* pair, and a column of pairs is no column at all — which is why the values
|
|
347
|
+
* used to vanish from the table entirely. The pair is split instead: the value
|
|
348
|
+
* becomes the cell, the label becomes the column's heading. Columns stay keyed
|
|
349
|
+
* by api_code, because two fields may carry the same label.
|
|
350
|
+
*/
|
|
351
|
+
function splitLabels(rows) {
|
|
352
|
+
const headings = new Map();
|
|
353
|
+
const split = rows.map((row) => {
|
|
354
|
+
const out = {};
|
|
355
|
+
for (const [key, value] of Object.entries(row)) {
|
|
356
|
+
if (!isLabelled(value)) {
|
|
357
|
+
out[key] = value;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const { label, value: cell } = value;
|
|
361
|
+
if (typeof label === 'string' && label !== '')
|
|
362
|
+
headings.set(key, label);
|
|
363
|
+
out[key] = cell;
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
});
|
|
367
|
+
return { rows: split, headings };
|
|
368
|
+
}
|
|
369
|
+
function isLabelled(value) {
|
|
370
|
+
if (!isRecord(value))
|
|
371
|
+
return false;
|
|
372
|
+
const keys = Object.keys(value);
|
|
373
|
+
return keys.length === 2 && keys.includes('label') && keys.includes('value');
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* A row carrying a list of its own has no cell a table could put it in, and the
|
|
377
|
+
* table drops it. Usually that is the right trade — a field's `choices` are
|
|
378
|
+
* detail, and the row still says what the field is. An analysis' buckets are
|
|
379
|
+
* not detail: they are the answer, and a table of `entry summary` without them
|
|
380
|
+
* prints how many people answered and never what they answered.
|
|
381
|
+
*
|
|
382
|
+
* Which is which is not readable off the shape — both are a list of objects
|
|
383
|
+
* beside a handful of scalars — so the lists worth breaking the table for are
|
|
384
|
+
* named, the way `renderObject` names the keys that hold a listing.
|
|
385
|
+
*/
|
|
386
|
+
const ESSENTIAL_LISTS = ['buckets'];
|
|
387
|
+
function hasEssentialList(row) {
|
|
388
|
+
return ESSENTIAL_LISTS.some((key) => {
|
|
389
|
+
const value = row[key];
|
|
390
|
+
return Array.isArray(value) && value.length > 0 && value.every(isRecord);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
function isRecord(value) {
|
|
394
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
395
|
+
}
|
|
396
|
+
function isScalar(value) {
|
|
397
|
+
return value === null || ['string', 'number', 'boolean'].includes(typeof value);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Whether a value earns its key a column. A list of values does: a
|
|
401
|
+
* multiple-choice answer is a list, and so is the `serial_numbers` a search
|
|
402
|
+
* answers with. Treating those as unprintable dropped the column — which meant
|
|
403
|
+
* `--fields field_6` left out field_6, and `entry search` said how many rows
|
|
404
|
+
* matched without ever saying which.
|
|
405
|
+
*
|
|
406
|
+
* An empty list earns nothing, though. A column that is `[]` in every row is a
|
|
407
|
+
* heading with a blank under it for as far as the table goes.
|
|
408
|
+
*/
|
|
409
|
+
function isCell(value) {
|
|
410
|
+
if (Array.isArray(value))
|
|
411
|
+
return value.length > 0 && value.every(isScalar);
|
|
412
|
+
return isScalar(value);
|
|
413
|
+
}
|
|
414
|
+
function formatField(value) {
|
|
415
|
+
if (isScalar(value))
|
|
416
|
+
return formatCell(value);
|
|
417
|
+
return json(value);
|
|
418
|
+
}
|
|
419
|
+
function formatCell(value) {
|
|
420
|
+
if (value === undefined || value === null)
|
|
421
|
+
return '';
|
|
422
|
+
if (typeof value === 'string')
|
|
423
|
+
return clip(value);
|
|
424
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
425
|
+
return String(value);
|
|
426
|
+
if (Array.isArray(value) && value.every(isScalar))
|
|
427
|
+
return clip(value.map(formatCell).join(', '));
|
|
428
|
+
return clip(JSON.stringify(value));
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* One rich text field is longer than the rest of a form put together, and it
|
|
432
|
+
* wraps over a screen of terminal. Text is the readable format; whoever wants
|
|
433
|
+
* the whole value asks for --output json.
|
|
434
|
+
*/
|
|
435
|
+
function clip(value) {
|
|
436
|
+
return value.length <= MAX_CELL ? value : `${value.slice(0, MAX_CELL)}… (${value.length} chars)`;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* A column is padded to what the terminal shows, not to how many code points
|
|
440
|
+
* the value holds: a Chinese label takes two cells per character, so counting
|
|
441
|
+
* length leaves every table with a Chinese column ragged.
|
|
442
|
+
*/
|
|
443
|
+
function pad(value, width) {
|
|
444
|
+
return value + ' '.repeat(Math.max(0, width - displayWidth(value)));
|
|
445
|
+
}
|
|
446
|
+
function displayWidth(value) {
|
|
447
|
+
let width = 0;
|
|
448
|
+
for (const char of value)
|
|
449
|
+
width += isWide(char.codePointAt(0)) ? 2 : 1;
|
|
450
|
+
return width;
|
|
451
|
+
}
|
|
452
|
+
function isWide(code) {
|
|
453
|
+
return ((code >= 0x1100 && code <= 0x115f) ||
|
|
454
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
455
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
456
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
457
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
458
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
459
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
460
|
+
(code >= 0x1f300 && code <= 0x1faff) ||
|
|
461
|
+
(code >= 0x20000 && code <= 0x3fffd));
|
|
462
|
+
}
|
|
463
|
+
export async function runCli(args = [], runtime = {}) {
|
|
464
|
+
// The command has to be found before the line can be split, because only it
|
|
465
|
+
// says which flags take a value. Its own words come first and hold no flags,
|
|
466
|
+
// so they are readable without knowing anything.
|
|
467
|
+
const command = findCommand(leadingWords(args));
|
|
468
|
+
const specs = [...(command?.options ?? []), ...GLOBAL_OPTIONS, ...LOCAL_OPTIONS];
|
|
469
|
+
const { words, flags } = splitArgs(args, specs);
|
|
470
|
+
const stdin = runtime.stdin ?? readStdin;
|
|
471
|
+
if (flags['--version'] || flags['-V'])
|
|
472
|
+
return ok(VERSION);
|
|
473
|
+
if (args.length === 0)
|
|
474
|
+
return ok(rootHelp());
|
|
475
|
+
if (flags['--help'] || flags['-h'])
|
|
476
|
+
return ok(helpFor(words));
|
|
477
|
+
const resource = words[0];
|
|
478
|
+
try {
|
|
479
|
+
if (resource === 'auth' || resource === 'config')
|
|
480
|
+
return await runLocal(words, flags, runtime, stdin);
|
|
481
|
+
if (!command)
|
|
482
|
+
return { exitCode: 1, stdout: '', stderr: unknownCommandHelp(words) };
|
|
483
|
+
return await runRemote(command, words, flags, runtime, stdin);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
return fail(error.message);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async function runRemote(command, words, flags, runtime, stdin) {
|
|
490
|
+
const label = `jinshuju ${command.path.join(' ')}`;
|
|
491
|
+
const specs = [...(command.options ?? []), ...GLOBAL_OPTIONS, ...LOCAL_OPTIONS];
|
|
492
|
+
const options = bindOptions(specs, flags, label, stdin);
|
|
493
|
+
const input = { ...bindArgs(command, words), options };
|
|
494
|
+
const output = options.output ?? 'text';
|
|
495
|
+
const width = runtime.width ?? terminalWidth();
|
|
496
|
+
const client = runtime.client ?? new JinshujuHttpClient(loadConfig({
|
|
497
|
+
configPath: options.config ?? defaultConfigPath,
|
|
498
|
+
env: runtime.env,
|
|
499
|
+
cli: { apiKey: options.api_key, apiSecret: options.api_secret, host: options.host }
|
|
500
|
+
}));
|
|
501
|
+
// A command that needs more than one round trip handles itself. Answering
|
|
502
|
+
// undefined means "this call is the ordinary one", so `entry create` only
|
|
503
|
+
// takes the long way when a file is actually attached.
|
|
504
|
+
if (command.run) {
|
|
505
|
+
const payload = await command.run(input, client);
|
|
506
|
+
if (payload !== undefined)
|
|
507
|
+
return ok(output === 'json' ? json(payload) : text(payload, width));
|
|
508
|
+
}
|
|
509
|
+
const request = command.request?.(input);
|
|
510
|
+
if (!request)
|
|
511
|
+
throw new UsageError(`${label} is not available yet`);
|
|
512
|
+
if (options.all && command.paginate) {
|
|
513
|
+
const rows = await readAllPages(client, request, command.paginate, progress());
|
|
514
|
+
const payload = { count: rows.length, data: rows };
|
|
515
|
+
return ok(output === 'json' ? json(payload) : text(payload, width));
|
|
516
|
+
}
|
|
517
|
+
const result = await client.request({ method: request.method, path: withQuery(request.path, request.query), body: request.body });
|
|
518
|
+
const selected = command.select ? command.select(result) : result;
|
|
519
|
+
if (output === 'json')
|
|
520
|
+
return ok(json(selected));
|
|
521
|
+
return ok(text(command.render ? command.render(selected) : selected, width));
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Every page of a listing. A cursor is opaque: it goes back exactly as it came.
|
|
525
|
+
*/
|
|
526
|
+
async function readAllPages(client, request, paginate, watching = { step: () => { }, done: () => { } }) {
|
|
527
|
+
const rows = [];
|
|
528
|
+
let cursor;
|
|
529
|
+
let page = 0;
|
|
530
|
+
for (;;) {
|
|
531
|
+
const query = { ...request.query, ...(cursor ? { next: cursor } : {}) };
|
|
532
|
+
const body = await client.request({ method: request.method, path: withQuery(request.path, query), body: request.body });
|
|
533
|
+
rows.push(...(body?.[paginate.items] ?? []));
|
|
534
|
+
page += 1;
|
|
535
|
+
watching.step(`read ${page} page${page === 1 ? '' : 's'}, ${rows.length} rows…`);
|
|
536
|
+
const next = body?.[paginate.cursor];
|
|
537
|
+
if (next === undefined || next === null || next === '')
|
|
538
|
+
break;
|
|
539
|
+
cursor = String(next);
|
|
540
|
+
}
|
|
541
|
+
watching.done();
|
|
542
|
+
return rows;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* A list value repeats its parameter as `name[]=a&name[]=b`, which is how Rails
|
|
546
|
+
* reads a list. Joining them with a comma would ask for one keyword containing
|
|
547
|
+
* a comma instead of two keywords.
|
|
548
|
+
*/
|
|
549
|
+
function withQuery(path, query) {
|
|
550
|
+
const params = new URLSearchParams();
|
|
551
|
+
for (const [name, value] of Object.entries(query ?? {})) {
|
|
552
|
+
if (value === undefined)
|
|
553
|
+
continue;
|
|
554
|
+
if (typeof value === 'string') {
|
|
555
|
+
params.set(name, value);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
for (const item of value)
|
|
559
|
+
params.append(`${name}[]`, item);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const search = params.toString();
|
|
563
|
+
return search ? `${path}?${search}` : path;
|
|
564
|
+
}
|
|
565
|
+
function readStdin() {
|
|
566
|
+
try {
|
|
567
|
+
return readFileSync(0, 'utf8');
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
throw new UsageError('could not read JSON from stdin');
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async function runLocal(words, flags, runtime, stdin) {
|
|
574
|
+
const options = localOptions(flags, stdin);
|
|
575
|
+
const key = words.slice(0, 2).join(' ');
|
|
576
|
+
switch (key) {
|
|
577
|
+
case 'auth login':
|
|
578
|
+
return await authLogin(options, runtime);
|
|
579
|
+
case 'auth status':
|
|
580
|
+
return await authStatus(options, runtime);
|
|
581
|
+
case 'auth refresh':
|
|
582
|
+
return await authRefresh(options, runtime);
|
|
583
|
+
case 'auth logout':
|
|
584
|
+
return await authLogout(options, runtime);
|
|
585
|
+
case 'config get':
|
|
586
|
+
return configGet(words, options);
|
|
587
|
+
case 'config set':
|
|
588
|
+
return configSet(words, options);
|
|
589
|
+
case 'config unset':
|
|
590
|
+
return configUnset(words, options);
|
|
591
|
+
default:
|
|
592
|
+
return { exitCode: 1, stdout: '', stderr: unknownCommandHelp(words) };
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function requireArg(value, name) {
|
|
596
|
+
if (!value)
|
|
597
|
+
throw new Error(`Missing argument <${name}>`);
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
function createClient(options, runtime) {
|
|
601
|
+
return runtime.client ?? new JinshujuHttpClient(loadConfig({ configPath: options.configPath, env: runtime.env, cli: { apiKey: options.apiKey, apiSecret: options.apiSecret, host: options.host } }));
|
|
602
|
+
}
|
|
603
|
+
async function authLogin(options, runtime) {
|
|
604
|
+
const result = await loginWithOAuth({
|
|
605
|
+
configPath: options.configPath,
|
|
606
|
+
env: runtime.env,
|
|
607
|
+
host: options.host,
|
|
608
|
+
authHost: options.authHost,
|
|
609
|
+
clientId: options.clientId,
|
|
610
|
+
scopes: options.scopes,
|
|
611
|
+
openBrowser: !options.noOpen
|
|
612
|
+
});
|
|
613
|
+
const payload = { authenticated: true, mode: 'oauth', auth_host: result.token.auth_host, client_id: result.token.client_id, scope: result.token.scope, expires_at: result.token.expires_at };
|
|
614
|
+
if (options.output === 'json')
|
|
615
|
+
return ok(json(payload));
|
|
616
|
+
return ok(`Authenticated with OAuth.\nConfig: ${options.configPath}`);
|
|
617
|
+
}
|
|
618
|
+
async function authStatus(options, runtime) {
|
|
619
|
+
const config = loadConfig({ configPath: options.configPath, env: runtime.env, cli: { apiKey: options.apiKey, apiSecret: options.apiSecret, host: options.host, authHost: options.authHost, clientId: options.clientId } });
|
|
620
|
+
const authenticated = Boolean(config.accessToken || (config.apiKey && config.apiSecret) || config.auth?.access_token);
|
|
621
|
+
const mode = config.accessToken
|
|
622
|
+
? 'access_token'
|
|
623
|
+
: config.apiKey && config.apiSecret
|
|
624
|
+
? 'api_key_secret'
|
|
625
|
+
: config.auth?.access_token
|
|
626
|
+
? 'oauth'
|
|
627
|
+
: 'none';
|
|
628
|
+
const payload = {
|
|
629
|
+
authenticated,
|
|
630
|
+
mode,
|
|
631
|
+
host: config.host,
|
|
632
|
+
auth_host: config.authHost,
|
|
633
|
+
sources: config.sources,
|
|
634
|
+
source: mode === 'access_token' ? config.sources.accessToken
|
|
635
|
+
: mode === 'api_key_secret' ? config.sources.apiKey
|
|
636
|
+
: mode === 'oauth' ? config.sources.auth : 'missing',
|
|
637
|
+
oauth: config.auth ? { client_id: config.auth.client_id, scope: config.auth.scope, expires_at: config.auth.expires_at, has_refresh_token: Boolean(config.auth.refresh_token) } : undefined
|
|
638
|
+
};
|
|
639
|
+
if (options.verify && authenticated) {
|
|
640
|
+
await createClient(options, runtime).request({ method: 'GET', path: '/api/v1/forms' });
|
|
641
|
+
}
|
|
642
|
+
if (options.output === 'json')
|
|
643
|
+
return ok(json(payload));
|
|
644
|
+
if (mode === 'access_token')
|
|
645
|
+
return ok(`Authenticated with an access token (from ${config.sources.accessToken}).`);
|
|
646
|
+
if (mode === 'oauth')
|
|
647
|
+
return ok('Authenticated with OAuth.');
|
|
648
|
+
if (mode === 'api_key_secret')
|
|
649
|
+
return ok('Authenticated with API Key / Secret.');
|
|
650
|
+
return ok('Missing authentication. Run `jinshuju auth login`, or set an access token, or configure API Key / Secret.');
|
|
651
|
+
}
|
|
652
|
+
async function authRefresh(options, runtime) {
|
|
653
|
+
const config = loadConfig({ configPath: options.configPath, env: runtime.env, cli: { host: options.host, authHost: options.authHost, clientId: options.clientId } });
|
|
654
|
+
const auth = await refreshOAuthToken(config);
|
|
655
|
+
if (options.output === 'json')
|
|
656
|
+
return ok(json({ authenticated: true, mode: 'oauth', expires_at: auth.expires_at, scope: auth.scope }));
|
|
657
|
+
return ok('OAuth token refreshed.');
|
|
658
|
+
}
|
|
659
|
+
async function authLogout(options, runtime) {
|
|
660
|
+
const config = loadConfig({ configPath: options.configPath, env: runtime.env });
|
|
661
|
+
await revokeOAuthToken(config);
|
|
662
|
+
return ok(options.output === 'json' ? json({ authenticated: false }) : 'Logged out.');
|
|
663
|
+
}
|
|
664
|
+
function configGet(positionals, options) {
|
|
665
|
+
const requestedKey = positionals[2];
|
|
666
|
+
if (requestedKey)
|
|
667
|
+
assertConfigKey(requestedKey);
|
|
668
|
+
const config = getConfig(options.configPath);
|
|
669
|
+
const secrets = new Set(['access_token', 'api_key', 'api_secret']);
|
|
670
|
+
const renderValue = (key, value) => options.showSecret || !secrets.has(key) ? value : maskSecret(value);
|
|
671
|
+
let payload;
|
|
672
|
+
if (requestedKey) {
|
|
673
|
+
const configKey = requestedKey;
|
|
674
|
+
payload = { [configKey]: renderValue(configKey, config[configKey]) };
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
payload = {
|
|
678
|
+
access_token: renderValue('access_token', config.access_token),
|
|
679
|
+
api_key: renderValue('api_key', config.api_key),
|
|
680
|
+
api_secret: renderValue('api_secret', config.api_secret)
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
if (options.output === 'json')
|
|
684
|
+
return ok(json(payload));
|
|
685
|
+
return ok(Object.entries(payload).map(([k, v]) => `${k}: ${v ?? '(unset)'}`).join('\n'));
|
|
686
|
+
}
|
|
687
|
+
function configSet(positionals, options) {
|
|
688
|
+
const key = requireArg(positionals[2], 'key');
|
|
689
|
+
assertConfigKey(key);
|
|
690
|
+
const value = requireArg(positionals[3], 'value');
|
|
691
|
+
setConfigValue(options.configPath, key, value);
|
|
692
|
+
return ok(`Set ${key}`);
|
|
693
|
+
}
|
|
694
|
+
function configUnset(positionals, options) {
|
|
695
|
+
const key = requireArg(positionals[2], 'key');
|
|
696
|
+
assertConfigKey(key);
|
|
697
|
+
unsetConfigValue(options.configPath, key);
|
|
698
|
+
return ok(`Unset ${key}`);
|
|
699
|
+
}
|