@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +82 -31
  3. package/docs/thinger-concepts.md +7 -7
  4. package/docs/thinger-product-code.md +2 -2
  5. package/docs/thinger-server-admin.md +2 -2
  6. package/lib/bucket-export.js +91 -0
  7. package/lib/cli.js +106 -50
  8. package/lib/commands/_shared.js +89 -1
  9. package/lib/commands/api.js +90 -0
  10. package/lib/commands/arguments.js +62 -0
  11. package/lib/commands/bucket.js +52 -44
  12. package/lib/commands/dashboard.js +5 -2
  13. package/lib/commands/data.js +539 -0
  14. package/lib/commands/device.js +128 -241
  15. package/lib/commands/grammar.js +279 -0
  16. package/lib/commands/product.js +104 -214
  17. package/lib/commands/profile.js +1 -0
  18. package/lib/commands/resources.js +975 -0
  19. package/lib/dashboard-init.js +4 -3
  20. package/lib/dashboard.js +10 -2
  21. package/lib/mcp/catalog.js +303 -65
  22. package/lib/mcp/core-types.js +36 -0
  23. package/lib/mcp/helpers.js +23 -2
  24. package/lib/mcp/naming.js +20 -0
  25. package/lib/mcp/registry.js +18 -67
  26. package/lib/mcp/resource-hooks.js +19 -5
  27. package/lib/mcp/server.js +43 -11
  28. package/lib/mcp/tools-resource.js +1232 -330
  29. package/lib/mcp/types/bucket-data.js +151 -0
  30. package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
  31. package/lib/mcp/types/device-resource.js +117 -0
  32. package/lib/mcp/types/doc.js +35 -0
  33. package/lib/mcp/types/plugin-call.js +75 -0
  34. package/lib/mcp/types/product-api.js +44 -0
  35. package/lib/mcp/types/profile.js +37 -0
  36. package/lib/mcp/types/storage-file.js +170 -0
  37. package/lib/openapi.js +18 -0
  38. package/lib/product-apply.js +121 -0
  39. package/lib/raw-api.js +111 -0
  40. package/lib/storage.js +41 -13
  41. package/lib/tokens.js +16 -2
  42. package/package.json +1 -1
  43. package/lib/mcp/tools-buckets.js +0 -130
  44. package/lib/mcp/tools-devices.js +0 -134
  45. package/lib/mcp/tools-misc.js +0 -48
  46. package/lib/mcp/tools-plugins.js +0 -63
  47. package/lib/mcp/tools-products.js +0 -52
  48. 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
+ }
@@ -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 <action> <productId> …` — products and their
31
- * product-level properties.
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('Operations:')
37
- .description('Products: list, inspect, create, delete and product properties');
40
+ .helpGroup('Resources:')
41
+ .description('Products: records, properties, profile and their export');
38
42
 
39
- product
40
- .command('list')
41
- .helpGroup('Discovery:')
42
- .description('List every product configured on the account')
43
- .option('-j, --json', 'Output as JSON')
44
- .option(
45
- '-c, --concurrency <n>',
46
- 'Max parallel API-resource lookups (default: 10)',
47
- parsePositiveInt('concurrency'),
48
- 10,
49
- )
50
- .action(async (opts, cmd) => {
51
- applyJsonFlag(opts);
52
- ensureConfigured();
53
- const user = getGlobalUser(cmd);
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
- const spinner = createSpinner('Fetching products...').start();
56
- let products;
57
- try {
58
- products = await getProducts(user);
59
- } catch (error) {
60
- spinner.fail('Failed to fetch products');
61
- const { message, code } = classifyError(error);
62
- printErr(message, { code });
63
- return;
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
- // API-resource counts come from each product's profile.
67
- // Parallelise so a large catalogue doesn't crawl serially.
68
- const apiResults = await runPool(products, opts.concurrency, async (p) => {
69
- const apis = await getProductApi(p.product, user);
70
- return Object.keys(apis || {}).length;
71
- });
72
- const rows = products.map((p, i) => ({
73
- product: p.product,
74
- name: p.name && p.name !== p.product ? p.name : null,
75
- enabled: !!p.enabled,
76
- api_resources: apiResults[i]?.ok ? apiResults[i].value : null,
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
- spinner.succeed(`Found ${rows.length} product(s)`);
86
+ spinner.succeed(`Found ${rows.length} product(s)`);
80
87
 
81
- if (isJsonMode()) {
82
- printOk({ products: rows });
83
- return;
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
- product
98
- .command('info <productId>')
99
- .helpGroup('Discovery:')
100
- .description('Print the full product document (config and profile) as JSON')
101
- .option('-j, --json', 'Output as JSON envelope')
102
- .action(async (productId, opts, cmd) => {
103
- applyJsonFlag(opts);
104
- ensureConfigured();
105
- const user = getGlobalUser(cmd);
106
- await runCommand({
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
- product
119
- .command('create <productId>')
120
- .helpGroup('Records:')
121
- .description('Create a product, bare or from a JSON document (config + profile)')
122
- .option('-n, --name <name>', 'Display name')
123
- .option('-d, --description <text>', 'Description')
124
- .option('--from <file>', 'JSON document with `config` and/or `profile` ("-" for stdin)')
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 (productId, opts, cmd) => {
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 ${productId}...`,
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 <productId>')
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 (productId, opts, cmd) => {
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(productId, user);
170
+ const removed = await deleteProduct(id, user);
174
171
  if (isJsonMode()) {
175
- printOk({ product: productId, removed });
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
  }