@thinger-io/cli-core 0.4.1 → 0.5.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.
- package/CHANGELOG.md +204 -0
- package/README.md +82 -31
- package/docs/thinger-concepts.md +7 -7
- package/docs/thinger-product-code.md +2 -2
- package/docs/thinger-server-admin.md +2 -2
- package/lib/bucket-export.js +91 -0
- package/lib/cli.js +106 -50
- package/lib/commands/_shared.js +89 -1
- package/lib/commands/api.js +90 -0
- package/lib/commands/arguments.js +62 -0
- package/lib/commands/bucket.js +52 -44
- package/lib/commands/dashboard.js +5 -2
- package/lib/commands/data.js +539 -0
- package/lib/commands/device.js +128 -241
- package/lib/commands/grammar.js +279 -0
- package/lib/commands/product.js +104 -214
- package/lib/commands/profile.js +1 -0
- package/lib/commands/resources.js +975 -0
- package/lib/dashboard-init.js +4 -3
- package/lib/dashboard.js +10 -2
- package/lib/mcp/catalog.js +303 -65
- package/lib/mcp/core-types.js +36 -0
- package/lib/mcp/helpers.js +23 -2
- package/lib/mcp/naming.js +20 -0
- package/lib/mcp/registry.js +18 -67
- package/lib/mcp/resource-hooks.js +19 -5
- package/lib/mcp/server.js +43 -11
- package/lib/mcp/tools-resource.js +1232 -330
- package/lib/mcp/types/bucket-data.js +151 -0
- package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
- package/lib/mcp/types/device-resource.js +117 -0
- package/lib/mcp/types/doc.js +35 -0
- package/lib/mcp/types/plugin-call.js +75 -0
- package/lib/mcp/types/product-api.js +44 -0
- package/lib/mcp/types/profile.js +37 -0
- package/lib/mcp/types/storage-file.js +170 -0
- package/lib/openapi.js +18 -0
- package/lib/product-apply.js +121 -0
- package/lib/raw-api.js +111 -0
- package/lib/storage.js +41 -13
- package/lib/tokens.js +16 -2
- package/package.json +1 -1
- package/lib/mcp/tools-buckets.js +0 -130
- package/lib/mcp/tools-devices.js +0 -134
- package/lib/mcp/tools-misc.js +0 -48
- package/lib/mcp/tools-plugins.js +0 -63
- package/lib/mcp/tools-products.js +0 -52
- package/lib/mcp/tools-storage.js +0 -173
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { CATALOG } from '../mcp/catalog.js';
|
|
4
|
+
import {
|
|
5
|
+
CORE_COMMANDS,
|
|
6
|
+
actionName,
|
|
7
|
+
commandPath,
|
|
8
|
+
idArgs,
|
|
9
|
+
parentArgs,
|
|
10
|
+
registerResourceCommands,
|
|
11
|
+
TYPE_AT,
|
|
12
|
+
} from './resources.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The grammar every CLI on the core follows, as a check its tests run over
|
|
16
|
+
* the whole command tree: `<cli> <noun> [<noun>…] <verb> [<parent ids>…]
|
|
17
|
+
* [<id>] [<extra>] [options]`.
|
|
18
|
+
*
|
|
19
|
+
* - Groups are nouns (kebab case, at most 3 below the root, `admin` not
|
|
20
|
+
* counted) and run nothing themselves; leaves are verbs of a closed set.
|
|
21
|
+
* - Positionals are ids, outermost parent first, named after their noun
|
|
22
|
+
* (`<device> <property>`), never `deviceId` or `device_id`, and described.
|
|
23
|
+
* - Short options keep one meaning everywhere (RESERVED); on create, update
|
|
24
|
+
* and clone `-n`/`-d` are name and description.
|
|
25
|
+
* - Commands that send JSON take `--from` and `-s, --set`; deletes take
|
|
26
|
+
* `-y`; lists take `--search --filter --count --index`; every command
|
|
27
|
+
* that runs something takes `-j, --json`.
|
|
28
|
+
* - Every catalog type has its commands at commandPath(type), and a command
|
|
29
|
+
* written by hand in the place of a generated one takes at least its
|
|
30
|
+
* arguments and options. The operations of the core's types and actions
|
|
31
|
+
* (bucket_data, storage_file, doc…) have theirs at CORE_COMMANDS.
|
|
32
|
+
* - Every command is in a help group; the root's are ROOT_GROUPS.
|
|
33
|
+
*
|
|
34
|
+
* A CLI states what it adds or does otherwise in `exceptions`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Help groups of the root, in the order the help shows them. */
|
|
38
|
+
export const ROOT_GROUPS = [
|
|
39
|
+
'Resources:',
|
|
40
|
+
'Administration:',
|
|
41
|
+
'Configuration:',
|
|
42
|
+
'Integration:',
|
|
43
|
+
'Operations:',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Help groups inside a noun, first; a CLI's own come after. */
|
|
47
|
+
export const NOUN_GROUPS = ['Records:', 'Data:', 'Actions:', 'Subresources:'];
|
|
48
|
+
|
|
49
|
+
/** Verbs a leaf may be named after. */
|
|
50
|
+
export const VERBS = {
|
|
51
|
+
records: ['list', 'get', 'create', 'update', 'delete', 'clone', 'describe', 'set'],
|
|
52
|
+
data: ['read', 'write', 'send', 'export', 'import', 'overview', 'call', 'upload', 'download'],
|
|
53
|
+
actions: ['set-product', 'apply', 'replace-all', 'stats'],
|
|
54
|
+
local: ['init', 'dev', 'publish', 'login', 'logout', 'use', 'current', 'add'],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Short options and the one long option each stands for. */
|
|
58
|
+
export const RESERVED = {
|
|
59
|
+
'-h': '--help',
|
|
60
|
+
'-V': '--version',
|
|
61
|
+
'-u': '--user',
|
|
62
|
+
'-j': '--json',
|
|
63
|
+
'-o': '--output',
|
|
64
|
+
'-y': '--yes',
|
|
65
|
+
'-s': '--set',
|
|
66
|
+
'-q': '--query',
|
|
67
|
+
'-X': '--method',
|
|
68
|
+
'-f': '--field',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Commands at the root that are not resources. */
|
|
72
|
+
const ROOT_COMMANDS = ['admin', 'profile', 'login', 'logout', 'api', 'mcp', 'doc'];
|
|
73
|
+
|
|
74
|
+
/** Verbs whose body comes from `--from` and `-s`. */
|
|
75
|
+
const BODY_VERBS = ['create', 'update', 'clone', 'call', 'send', 'write'];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {object} GrammarExceptions
|
|
79
|
+
* @property {string[]} [root] Root commands the CLI adds (`agent`, `playbook`).
|
|
80
|
+
* @property {string[]} [verbs] Leaf names the CLI adds (`exec`, `ls`, `tcp`…).
|
|
81
|
+
* @property {Record<string, Record<string, string>>} [shorts]
|
|
82
|
+
* Per command path, short options with another meaning:
|
|
83
|
+
* `{ 'device logs': { '-f': '--follow' } }`.
|
|
84
|
+
* @property {Record<string, Array<'json' | 'body' | 'yes' | 'list' | 'parents'>>} [skip]
|
|
85
|
+
* Per command path, rules it does not follow: `json` (no -j), `body`
|
|
86
|
+
* (no --from/-s), `yes` (a delete without -y), `list` (no list options),
|
|
87
|
+
* `parents` (does not take the ids of the nouns above it).
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/** What the core's own commands do otherwise. */
|
|
91
|
+
const CORE_EXCEPTIONS = {
|
|
92
|
+
shorts: {
|
|
93
|
+
'dashboard init': { '-s': '--storage' },
|
|
94
|
+
'dashboard publish': { '-s': '--storage' },
|
|
95
|
+
},
|
|
96
|
+
skip: {
|
|
97
|
+
// Interactive, or a server on stdio.
|
|
98
|
+
login: ['json'],
|
|
99
|
+
logout: ['json'],
|
|
100
|
+
mcp: ['json'],
|
|
101
|
+
// Local profiles, and lists that are not server collections.
|
|
102
|
+
'profile list': ['list'],
|
|
103
|
+
'profile delete': ['yes'],
|
|
104
|
+
'device resource list': ['list'],
|
|
105
|
+
'storage file list': ['list'],
|
|
106
|
+
'doc list': ['list'],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const KEBAB = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
111
|
+
|
|
112
|
+
const visible = (cmd) => cmd.commands.filter((c) => !c._hidden);
|
|
113
|
+
const find = (root, path) =>
|
|
114
|
+
path.reduce((cmd, name) => cmd?.commands.find((c) => c.name() === name), root);
|
|
115
|
+
const longs = (cmd) => new Set(cmd.options.map((o) => o.long).filter(Boolean));
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check a CLI's command tree against the grammar.
|
|
119
|
+
*
|
|
120
|
+
* @param {Command} program As buildProgram makes it.
|
|
121
|
+
* @param {{ exceptions?: GrammarExceptions }} [opts]
|
|
122
|
+
* @returns {string[]} The problems found, one line each: none when it follows it.
|
|
123
|
+
*/
|
|
124
|
+
export function checkGrammar(program, { exceptions = {} } = {}) {
|
|
125
|
+
const problems = [];
|
|
126
|
+
const say = (path, message) => problems.push(`${path.join(' ') || '(root)'}: ${message}`);
|
|
127
|
+
const skip = (key, rule) =>
|
|
128
|
+
[...(CORE_EXCEPTIONS.skip[key] || []), ...(exceptions.skip?.[key] || [])].includes(rule);
|
|
129
|
+
const shortFor = (key, short) =>
|
|
130
|
+
exceptions.shorts?.[key]?.[short] ?? CORE_EXCEPTIONS.shorts[key]?.[short];
|
|
131
|
+
|
|
132
|
+
const catalogActions = Object.entries(CATALOG).flatMap(([t, s]) =>
|
|
133
|
+
Object.keys(s.actions || {}).map((a) => actionName(t, a)),
|
|
134
|
+
);
|
|
135
|
+
const verbs = new Set([
|
|
136
|
+
...Object.values(VERBS).flat(),
|
|
137
|
+
...catalogActions,
|
|
138
|
+
...(exceptions.verbs || []),
|
|
139
|
+
]);
|
|
140
|
+
const rootNames = new Set([
|
|
141
|
+
...Object.keys(CATALOG).map((t) => commandPath(t)[0]),
|
|
142
|
+
...ROOT_COMMANDS,
|
|
143
|
+
...(exceptions.root || []),
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
// Options of the root (-u, --profile) are never redefined below it.
|
|
147
|
+
for (const o of program.options) {
|
|
148
|
+
if (o.short && RESERVED[o.short] && RESERVED[o.short] !== o.long) {
|
|
149
|
+
say([], `${o.short} is ${o.long}, not ${RESERVED[o.short]}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const walk = (cmd, path) => {
|
|
154
|
+
for (const sub of visible(cmd)) {
|
|
155
|
+
const at = [...path, sub.name()];
|
|
156
|
+
const key = at.join(' ');
|
|
157
|
+
const nouns = (sub.commands.length ? at : path).filter((n, i) => i || n !== 'admin');
|
|
158
|
+
if (!KEBAB.test(sub.name())) say(at, 'not kebab case');
|
|
159
|
+
if (nouns.length > 3) say(at, `${nouns.length} nouns (at most 3)`);
|
|
160
|
+
if (!sub.helpGroup()) say(at, 'in no help group');
|
|
161
|
+
if (!path.length) {
|
|
162
|
+
if (!rootNames.has(sub.name())) say(at, 'not a command of the root');
|
|
163
|
+
if (!ROOT_GROUPS.includes(sub.helpGroup())) {
|
|
164
|
+
say(at, `help group "${sub.helpGroup()}" is not one of the root's`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (sub.commands.length) {
|
|
168
|
+
// @ts-ignore -- commander keeps the handler private
|
|
169
|
+
if (sub._actionHandler) say(at, 'a group that runs something');
|
|
170
|
+
if (VERBS.records.includes(sub.name())) say(at, 'a group named after a verb');
|
|
171
|
+
walk(sub, at);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (path.length && !verbs.has(sub.name())) say(at, 'not a verb of the grammar');
|
|
175
|
+
checkArguments(sub, path, at, key);
|
|
176
|
+
checkOptions(sub, at, key);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Positionals: kebab case, described, the parents' ids first, then the own id. */
|
|
181
|
+
const checkArguments = (leaf, path, at, key) => {
|
|
182
|
+
const names = leaf.registeredArguments.map((a) => a.name());
|
|
183
|
+
for (const arg of leaf.registeredArguments) {
|
|
184
|
+
if (!KEBAB.test(arg.name())) say(at, `argument <${arg.name()}> is not kebab case`);
|
|
185
|
+
if (!arg.description) say(at, `argument <${arg.name()}> has no description`);
|
|
186
|
+
}
|
|
187
|
+
if (!path.length || skip(key, 'parents')) return;
|
|
188
|
+
// The ids of the types above the leaf's group, outermost first.
|
|
189
|
+
const parents = path.slice(0, -1).flatMap((_, i) => {
|
|
190
|
+
const type = TYPE_AT.get(path.slice(0, i + 1).join(' '));
|
|
191
|
+
return type ? idArgs(type) : [];
|
|
192
|
+
});
|
|
193
|
+
const own = TYPE_AT.get(path.join(' '));
|
|
194
|
+
const verb = leaf.name();
|
|
195
|
+
const expected = own && verb !== 'describe' ? [...parentArgs(own)] : parents;
|
|
196
|
+
if (verb === 'describe') {
|
|
197
|
+
if (names.length) say(at, 'describe takes no arguments');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (names.slice(0, expected.length).join(' ') !== expected.join(' ')) {
|
|
201
|
+
say(at, `arguments <${names.join('> <')}> do not start with <${expected.join('> <')}>`);
|
|
202
|
+
}
|
|
203
|
+
if (own && ['get', 'update', 'delete', 'clone', 'set'].includes(verb)) {
|
|
204
|
+
const ids = idArgs(own);
|
|
205
|
+
const mine = names.slice(expected.length, expected.length + ids.length);
|
|
206
|
+
if (mine.join(' ') !== ids.join(' ')) {
|
|
207
|
+
say(at, `the id of a ${own} is <${ids.join('> <')}>, not <${mine.join('> <')}>`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const checkOptions = (leaf, at, key) => {
|
|
213
|
+
const verb = leaf.name();
|
|
214
|
+
const has = longs(leaf);
|
|
215
|
+
for (const o of leaf.options) {
|
|
216
|
+
if (o.short === '-u' || o.long === '--user' || o.long === '--profile') {
|
|
217
|
+
say(at, `${o.flags} redefines an option of the root`);
|
|
218
|
+
}
|
|
219
|
+
const meaning = shortFor(key, o.short) ?? RESERVED[o.short];
|
|
220
|
+
if (o.short && meaning && meaning !== o.long) {
|
|
221
|
+
say(at, `${o.short} is ${o.long}, not ${meaning}`);
|
|
222
|
+
}
|
|
223
|
+
if (['create', 'update', 'clone'].includes(verb)) {
|
|
224
|
+
if (o.short === '-n' && o.long !== '--name') say(at, `-n is ${o.long}, not --name`);
|
|
225
|
+
if (o.short === '-d' && o.long !== '--description') {
|
|
226
|
+
say(at, `-d is ${o.long}, not --description`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const short = (long) => leaf.options.find((o) => o.long === long)?.short;
|
|
231
|
+
if (!skip(key, 'json') && short('--json') !== '-j') say(at, 'no -j, --json');
|
|
232
|
+
if (BODY_VERBS.includes(verb) && !skip(key, 'body')) {
|
|
233
|
+
if (!has.has('--from') || short('--set') !== '-s') say(at, 'no --from and -s, --set');
|
|
234
|
+
}
|
|
235
|
+
if ((verb === 'delete' || verb === 'replace-all') && !skip(key, 'yes')) {
|
|
236
|
+
if (short('--yes') !== '-y') say(at, 'no -y, --yes');
|
|
237
|
+
}
|
|
238
|
+
if (verb === 'list' && !skip(key, 'list')) {
|
|
239
|
+
for (const o of ['--search', '--filter', '--count', '--index']) {
|
|
240
|
+
if (!has.has(o)) say(at, `no ${o}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
walk(program, []);
|
|
246
|
+
|
|
247
|
+
// Every type has its commands, and a command written by hand in the
|
|
248
|
+
// place of a generated one takes at least its arguments and options.
|
|
249
|
+
const generated = new Command();
|
|
250
|
+
registerResourceCommands(generated);
|
|
251
|
+
for (const type of Object.keys(CATALOG)) {
|
|
252
|
+
const path = commandPath(type);
|
|
253
|
+
const mine = find(program, path);
|
|
254
|
+
if (!mine) {
|
|
255
|
+
say(path, `no commands for ${type}`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
for (const ref of find(generated, path).commands.filter((c) => !c.commands.length)) {
|
|
259
|
+
const at = [...path, ref.name()];
|
|
260
|
+
const leaf = mine.commands.find((c) => c.name() === ref.name());
|
|
261
|
+
if (!leaf || leaf._hidden) {
|
|
262
|
+
say(at, `missing (${type})`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const args = (c) => c.registeredArguments.map((a) => a.name()).join(' ');
|
|
266
|
+
if (args(leaf) !== args(ref)) {
|
|
267
|
+
say(at, `takes <${args(leaf)}> where the generated command takes <${args(ref)}>`);
|
|
268
|
+
}
|
|
269
|
+
const has = longs(leaf);
|
|
270
|
+
const lacking = [...longs(ref)].filter((o) => !has.has(o));
|
|
271
|
+
if (lacking.length) say(at, `lacks ${lacking.join(', ')} of the generated command`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const [op, words] of Object.entries(CORE_COMMANDS)) {
|
|
275
|
+
const leaf = find(program, words.split(' '));
|
|
276
|
+
if (!leaf || leaf._hidden || leaf.commands.length) say(words.split(' '), `missing (${op})`);
|
|
277
|
+
}
|
|
278
|
+
return problems;
|
|
279
|
+
}
|
package/lib/commands/product.js
CHANGED
|
@@ -2,138 +2,139 @@
|
|
|
2
2
|
import {
|
|
3
3
|
createProduct,
|
|
4
4
|
deleteProduct,
|
|
5
|
-
deleteProductProperty,
|
|
6
|
-
getProduct,
|
|
7
5
|
getProductApi,
|
|
8
|
-
getProductProperties,
|
|
9
|
-
getProductProperty,
|
|
10
6
|
getProducts,
|
|
11
7
|
productExists,
|
|
12
|
-
setProductProperty,
|
|
13
8
|
} from '../product.js';
|
|
14
9
|
import { runPool } from '../concurrency.js';
|
|
15
|
-
import { alreadyExistsError } from '../errors.js';
|
|
10
|
+
import { alreadyExistsError, inputError } from '../errors.js';
|
|
16
11
|
import { hint, info, label, success } from '../format.js';
|
|
17
12
|
import { classifyError, createSpinner, isJsonMode, printErr, printOk } from '../output.js';
|
|
13
|
+
import {
|
|
14
|
+
hasListOptions,
|
|
15
|
+
listOptions,
|
|
16
|
+
listRecords,
|
|
17
|
+
projectsOption,
|
|
18
|
+
setProjects,
|
|
19
|
+
} from './resources.js';
|
|
18
20
|
import {
|
|
19
21
|
applyJsonFlag,
|
|
22
|
+
bodyFromOptions,
|
|
23
|
+
bodyOptions,
|
|
20
24
|
confirmDestructive,
|
|
21
25
|
ensureConfigured,
|
|
22
26
|
getGlobalUser,
|
|
23
|
-
parseJsonValue,
|
|
24
27
|
parsePositiveInt,
|
|
25
|
-
readJsonArgument,
|
|
26
28
|
runCommand,
|
|
27
29
|
} from './_shared.js';
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
|
-
* `<cli> product <
|
|
31
|
-
*
|
|
32
|
+
* `<cli> product <verb> <product> …` — the product records written by hand
|
|
33
|
+
* (list as a table with API counts, create from the CLI's template,
|
|
34
|
+
* delete); the rest of the group (get, update, property, profile, export,
|
|
35
|
+
* apply…) comes from the tool and resource commands.
|
|
32
36
|
*/
|
|
33
37
|
export function registerProductCommands(program) {
|
|
34
38
|
const product = program
|
|
35
39
|
.command('product')
|
|
36
|
-
.helpGroup('
|
|
37
|
-
.description('Products:
|
|
40
|
+
.helpGroup('Resources:')
|
|
41
|
+
.description('Products: records, properties, profile and their export');
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
listOptions(
|
|
44
|
+
product
|
|
45
|
+
.command('list')
|
|
46
|
+
.helpGroup('Records:')
|
|
47
|
+
.description('List the products of the account, with their API resources')
|
|
48
|
+
.option('-j, --json', 'Output as JSON')
|
|
49
|
+
.option(
|
|
50
|
+
'-c, --concurrency <n>',
|
|
51
|
+
'Max parallel API-resource lookups (default: 10)',
|
|
52
|
+
parsePositiveInt('concurrency'),
|
|
53
|
+
10,
|
|
54
|
+
),
|
|
55
|
+
).action(async (opts, cmd) => {
|
|
56
|
+
applyJsonFlag(opts);
|
|
57
|
+
ensureConfigured();
|
|
58
|
+
const user = getGlobalUser(cmd);
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
60
|
+
const spinner = createSpinner('Fetching products...').start();
|
|
61
|
+
let products;
|
|
62
|
+
try {
|
|
63
|
+
products = hasListOptions(opts)
|
|
64
|
+
? await listRecords('product', opts, user)
|
|
65
|
+
: await getProducts(user);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
spinner.fail('Failed to fetch products');
|
|
68
|
+
const { message, code } = classifyError(error);
|
|
69
|
+
printErr(message, { code });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
// API-resource counts come from each product's profile.
|
|
74
|
+
// Parallelise so a large catalogue doesn't crawl serially.
|
|
75
|
+
const apiResults = await runPool(products, opts.concurrency, async (p) => {
|
|
76
|
+
const apis = await getProductApi(p.product, user);
|
|
77
|
+
return Object.keys(apis || {}).length;
|
|
78
|
+
});
|
|
79
|
+
const rows = products.map((p, i) => ({
|
|
80
|
+
product: p.product,
|
|
81
|
+
name: p.name && p.name !== p.product ? p.name : null,
|
|
82
|
+
enabled: !!p.enabled,
|
|
83
|
+
api_resources: apiResults[i]?.ok ? apiResults[i].value : null,
|
|
84
|
+
}));
|
|
78
85
|
|
|
79
|
-
|
|
86
|
+
spinner.succeed(`Found ${rows.length} product(s)`);
|
|
80
87
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const idWidth = rows.reduce((w, r) => Math.max(w, String(r.product ?? '').length), 0);
|
|
87
|
-
for (const r of rows) {
|
|
88
|
-
const flag = r.enabled ? success('enabled ') : hint('disabled');
|
|
89
|
-
const id = label(String(r.product ?? '(no id)').padEnd(idWidth));
|
|
90
|
-
const name = r.name ? hint(`(${r.name})`) : '';
|
|
91
|
-
const apis = r.api_resources ? hint(`${r.api_resources} API resource(s)`) : '';
|
|
92
|
-
const extras = [name, apis].filter(Boolean).join(' ');
|
|
93
|
-
console.log(` ${flag} ${id}${extras ? ' ' + extras : ''}`);
|
|
94
|
-
}
|
|
95
|
-
});
|
|
88
|
+
if (isJsonMode()) {
|
|
89
|
+
printOk({ products: rows });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
96
92
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
start: `Fetching ${productId}...`,
|
|
108
|
-
fn: () => getProduct(productId, user),
|
|
109
|
-
success: null,
|
|
110
|
-
failure: 'Failed to read product',
|
|
111
|
-
onSuccess: (doc) => {
|
|
112
|
-
if (isJsonMode()) printOk(doc);
|
|
113
|
-
else console.log(JSON.stringify(doc, null, 2));
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
});
|
|
93
|
+
const idWidth = rows.reduce((w, r) => Math.max(w, String(r.product ?? '').length), 0);
|
|
94
|
+
for (const r of rows) {
|
|
95
|
+
const flag = r.enabled ? success('enabled ') : hint('disabled');
|
|
96
|
+
const id = label(String(r.product ?? '(no id)').padEnd(idWidth));
|
|
97
|
+
const name = r.name ? hint(`(${r.name})`) : '';
|
|
98
|
+
const apis = r.api_resources ? hint(`${r.api_resources} API resource(s)`) : '';
|
|
99
|
+
const extras = [name, apis].filter(Boolean).join(' ');
|
|
100
|
+
console.log(` ${flag} ${id}${extras ? ' ' + extras : ''}`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
117
103
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
104
|
+
projectsOption(
|
|
105
|
+
bodyOptions(
|
|
106
|
+
product
|
|
107
|
+
.command('create [product]')
|
|
108
|
+
.helpGroup('Records:')
|
|
109
|
+
.description(
|
|
110
|
+
'Create a product, bare or from a JSON document (config + profile); the CLI template fills in the rest',
|
|
111
|
+
)
|
|
112
|
+
.option('-n, --name <name>', 'Display name')
|
|
113
|
+
.option('-d, --description <text>', 'Description'),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
125
116
|
.option('-j, --json', 'Output as JSON')
|
|
126
|
-
.action(async (
|
|
117
|
+
.action(async (id, opts, cmd) => {
|
|
127
118
|
applyJsonFlag(opts);
|
|
128
119
|
ensureConfigured();
|
|
129
120
|
const user = getGlobalUser(cmd);
|
|
121
|
+
let productId = id;
|
|
130
122
|
await runCommand({
|
|
131
|
-
start: `Creating ${
|
|
123
|
+
start: `Creating ${id ?? 'product'}...`,
|
|
132
124
|
fn: async () => {
|
|
125
|
+
const doc = (await bodyFromOptions(opts)) ?? {};
|
|
126
|
+
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
|
|
127
|
+
throw inputError('create takes a JSON object');
|
|
128
|
+
}
|
|
129
|
+
productId ??= doc.product;
|
|
130
|
+
if (!productId) {
|
|
131
|
+
throw inputError(
|
|
132
|
+
'give the product id: <product>, or the body field "product"',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
133
135
|
if (await productExists(productId, user)) {
|
|
134
136
|
throw alreadyExistsError(`Product "${productId}" already exists`);
|
|
135
137
|
}
|
|
136
|
-
const doc = opts.from ? await readJsonArgument(opts.from) : {};
|
|
137
138
|
await createProduct(productId, user, {
|
|
138
139
|
name: opts.name || doc.name,
|
|
139
140
|
description: opts.description || doc.description,
|
|
@@ -141,8 +142,9 @@ export function registerProductCommands(program) {
|
|
|
141
142
|
config: doc.config,
|
|
142
143
|
profile: doc.profile,
|
|
143
144
|
});
|
|
145
|
+
if (opts.projects) await setProjects('product', productId, opts.projects, user);
|
|
144
146
|
},
|
|
145
|
-
success: `Created product ${productId}`,
|
|
147
|
+
success: () => `Created product ${productId}`,
|
|
146
148
|
failure: 'Failed to create product',
|
|
147
149
|
onSuccess: () => {
|
|
148
150
|
if (isJsonMode()) printOk({ product: productId });
|
|
@@ -151,143 +153,31 @@ export function registerProductCommands(program) {
|
|
|
151
153
|
});
|
|
152
154
|
|
|
153
155
|
product
|
|
154
|
-
.command('delete <
|
|
156
|
+
.command('delete <product>')
|
|
155
157
|
.helpGroup('Records:')
|
|
156
158
|
.description('Delete a product (its devices stay, unassigned)')
|
|
157
159
|
.option('-y, --yes', 'Skip the interactive confirmation prompt')
|
|
158
160
|
.option('-j, --json', 'Output as JSON')
|
|
159
|
-
.action(async (
|
|
161
|
+
.action(async (id, opts, cmd) => {
|
|
160
162
|
applyJsonFlag(opts);
|
|
161
163
|
ensureConfigured();
|
|
162
164
|
const user = getGlobalUser(cmd);
|
|
163
|
-
if (
|
|
164
|
-
!(await confirmDestructive(
|
|
165
|
-
opts,
|
|
166
|
-
`Delete product ${productId}? This cannot be undone.`,
|
|
167
|
-
))
|
|
168
|
-
) {
|
|
165
|
+
if (!(await confirmDestructive(opts, `Delete product ${id}? This cannot be undone.`))) {
|
|
169
166
|
console.log(hint('Cancelled.'));
|
|
170
167
|
return;
|
|
171
168
|
}
|
|
172
169
|
try {
|
|
173
|
-
const removed = await deleteProduct(
|
|
170
|
+
const removed = await deleteProduct(id, user);
|
|
174
171
|
if (isJsonMode()) {
|
|
175
|
-
printOk({ product:
|
|
172
|
+
printOk({ product: id, removed });
|
|
176
173
|
return;
|
|
177
174
|
}
|
|
178
|
-
console.log(
|
|
179
|
-
removed
|
|
180
|
-
? `Deleted ${info(productId)}`
|
|
181
|
-
: `Product ${info(productId)} did not exist`,
|
|
182
|
-
);
|
|
175
|
+
console.log(removed ? `Deleted ${info(id)}` : `Product ${info(id)} did not exist`);
|
|
183
176
|
} catch (error) {
|
|
184
177
|
const { message, code } = classifyError(error);
|
|
185
178
|
printErr(message, { code });
|
|
186
179
|
}
|
|
187
180
|
});
|
|
188
181
|
|
|
189
|
-
product
|
|
190
|
-
.command('property-list <productId>')
|
|
191
|
-
.helpGroup('Product properties:')
|
|
192
|
-
.description('List the names of the properties attached to a product')
|
|
193
|
-
.option('-j, --json', 'Output as JSON')
|
|
194
|
-
.action(async (productId, opts, cmd) => {
|
|
195
|
-
applyJsonFlag(opts);
|
|
196
|
-
ensureConfigured();
|
|
197
|
-
const user = getGlobalUser(cmd);
|
|
198
|
-
await runCommand({
|
|
199
|
-
start: `Getting properties of ${productId}...`,
|
|
200
|
-
fn: () => getProductProperties(productId, user),
|
|
201
|
-
success: null,
|
|
202
|
-
failure: 'Property retrieval failed',
|
|
203
|
-
onSuccess: (list) => {
|
|
204
|
-
const names = Array.isArray(list)
|
|
205
|
-
? list.map((p) => p.property).filter(Boolean)
|
|
206
|
-
: [];
|
|
207
|
-
if (isJsonMode()) {
|
|
208
|
-
printOk({ product: productId, properties: names });
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
if (!names.length) {
|
|
212
|
-
console.log(`No properties on product ${info(productId)}`);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
console.log(`Properties of product ${info(productId)}:`);
|
|
216
|
-
for (const n of names) console.log(`- ${n}`);
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
product
|
|
222
|
-
.command('property-get <productId> <propertyId>')
|
|
223
|
-
.helpGroup('Product properties:')
|
|
224
|
-
.description('Read a property attached to a product')
|
|
225
|
-
.option('-j, --json', 'Output as JSON')
|
|
226
|
-
.action(async (productId, propertyId, opts, cmd) => {
|
|
227
|
-
applyJsonFlag(opts);
|
|
228
|
-
ensureConfigured();
|
|
229
|
-
const user = getGlobalUser(cmd);
|
|
230
|
-
await runCommand({
|
|
231
|
-
start: `Reading ${propertyId}...`,
|
|
232
|
-
fn: () => getProductProperty(productId, propertyId, user),
|
|
233
|
-
success: null,
|
|
234
|
-
failure: 'Property retrieval failed',
|
|
235
|
-
onSuccess: (value) => {
|
|
236
|
-
if (isJsonMode()) printOk({ product: productId, property: propertyId, value });
|
|
237
|
-
else console.log(JSON.stringify(value, null, 2));
|
|
238
|
-
},
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
product
|
|
243
|
-
.command('property-set <productId> <propertyId> <value>')
|
|
244
|
-
.helpGroup('Product properties:')
|
|
245
|
-
.description('Create or overwrite a product property (value parsed as JSON)')
|
|
246
|
-
.option('-j, --json', 'Output as JSON')
|
|
247
|
-
.action(async (productId, propertyId, value, opts, cmd) => {
|
|
248
|
-
applyJsonFlag(opts);
|
|
249
|
-
ensureConfigured();
|
|
250
|
-
const user = getGlobalUser(cmd);
|
|
251
|
-
const parsed = parseJsonValue(value);
|
|
252
|
-
await runCommand({
|
|
253
|
-
start: `Saving ${propertyId} on ${productId}...`,
|
|
254
|
-
fn: () => setProductProperty(productId, propertyId, parsed, user),
|
|
255
|
-
success: `Saved ${propertyId} on ${productId}`,
|
|
256
|
-
failure: 'Failed to save property',
|
|
257
|
-
onSuccess: () => {
|
|
258
|
-
if (isJsonMode())
|
|
259
|
-
printOk({ product: productId, property: propertyId, value: parsed });
|
|
260
|
-
},
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
product
|
|
265
|
-
.command('property-delete <productId> <propertyId>')
|
|
266
|
-
.helpGroup('Product properties:')
|
|
267
|
-
.description('Delete a product property (idempotent)')
|
|
268
|
-
.option('-j, --json', 'Output as JSON')
|
|
269
|
-
.action(async (productId, propertyId, opts, cmd) => {
|
|
270
|
-
applyJsonFlag(opts);
|
|
271
|
-
ensureConfigured();
|
|
272
|
-
const user = getGlobalUser(cmd);
|
|
273
|
-
await runCommand({
|
|
274
|
-
start: `Deleting ${propertyId}...`,
|
|
275
|
-
fn: () => deleteProductProperty(productId, propertyId, user),
|
|
276
|
-
success: null,
|
|
277
|
-
failure: 'Failed to delete property',
|
|
278
|
-
onSuccess: (removed) => {
|
|
279
|
-
if (isJsonMode()) {
|
|
280
|
-
printOk({ product: productId, property: propertyId, removed });
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
console.log(
|
|
284
|
-
removed
|
|
285
|
-
? `Deleted ${info(propertyId)} from ${info(productId)}`
|
|
286
|
-
: `Property ${info(propertyId)} was not set on ${info(productId)}`,
|
|
287
|
-
);
|
|
288
|
-
},
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
|
|
292
182
|
return product;
|
|
293
183
|
}
|