@thinger-io/cli-core 0.4.2 → 0.5.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/README.md +82 -31
  3. package/lib/auth.js +25 -1
  4. package/lib/bucket-export.js +91 -0
  5. package/lib/cli.js +106 -50
  6. package/lib/commands/_shared.js +89 -1
  7. package/lib/commands/api.js +90 -0
  8. package/lib/commands/arguments.js +62 -0
  9. package/lib/commands/bucket.js +52 -44
  10. package/lib/commands/dashboard.js +5 -2
  11. package/lib/commands/data.js +539 -0
  12. package/lib/commands/device.js +128 -241
  13. package/lib/commands/grammar.js +279 -0
  14. package/lib/commands/product.js +104 -214
  15. package/lib/commands/profile.js +1 -0
  16. package/lib/commands/resources.js +975 -0
  17. package/lib/dashboard-init.js +4 -3
  18. package/lib/dashboard.js +10 -2
  19. package/lib/mcp/catalog.js +302 -64
  20. package/lib/mcp/core-types.js +36 -0
  21. package/lib/mcp/helpers.js +23 -2
  22. package/lib/mcp/naming.js +20 -0
  23. package/lib/mcp/registry.js +18 -67
  24. package/lib/mcp/resource-hooks.js +19 -5
  25. package/lib/mcp/server.js +43 -11
  26. package/lib/mcp/tools-resource.js +1234 -330
  27. package/lib/mcp/types/bucket-data.js +151 -0
  28. package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
  29. package/lib/mcp/types/device-resource.js +117 -0
  30. package/lib/mcp/types/doc.js +35 -0
  31. package/lib/mcp/types/plugin-call.js +75 -0
  32. package/lib/mcp/types/product-api.js +44 -0
  33. package/lib/mcp/types/profile.js +37 -0
  34. package/lib/mcp/types/storage-file.js +170 -0
  35. package/lib/openapi.js +18 -0
  36. package/lib/product-apply.js +121 -0
  37. package/lib/raw-api.js +111 -0
  38. package/lib/storage.js +41 -13
  39. package/lib/tokens.js +16 -2
  40. package/package.json +1 -1
  41. package/lib/mcp/tools-buckets.js +0 -130
  42. package/lib/mcp/tools-devices.js +0 -134
  43. package/lib/mcp/tools-misc.js +0 -48
  44. package/lib/mcp/tools-plugins.js +0 -63
  45. package/lib/mcp/tools-products.js +0 -52
  46. package/lib/mcp/tools-storage.js +0 -173
package/lib/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // @ts-check
2
2
  import { Command } from 'commander';
3
3
  import { setActiveProfile } from './config.js';
4
- import { identity } from './identity.js';
4
+ import { env, identity } from './identity.js';
5
+
5
6
  import { classifyError, detectJsonModeFromArgv } from './output.js';
6
7
  import { startMCPServer } from './mcp/server.js';
7
8
  import { registerAuthCommands } from './commands/auth.js';
@@ -10,66 +11,82 @@ import { registerDeviceCommands } from './commands/device.js';
10
11
  import { registerProductCommands } from './commands/product.js';
11
12
  import { registerBucketCommands } from './commands/bucket.js';
12
13
  import { registerDashboardCommands } from './commands/dashboard.js';
14
+ import { registerApiCommand } from './commands/api.js';
15
+ import { registerResourceCommands } from './commands/resources.js';
16
+ import { registerDataCommands } from './commands/data.js';
17
+ import { describeArguments } from './commands/arguments.js';
18
+ import { NOUN_GROUPS, ROOT_GROUPS } from './commands/grammar.js';
19
+
20
+ /** Verbs of the records, in the order the help shows them. */
21
+ const RECORD_VERBS = ['list', 'get', 'create', 'update', 'delete', 'clone', 'set', 'describe'];
22
+
23
+ /**
24
+ * Sort the commands of `cmd` (and below) by help group, records by verb,
25
+ * keeping the order they were added in otherwise: the hand-written ones
26
+ * and the generated ones of a group come together.
27
+ *
28
+ * @param {Command} cmd
29
+ * @param {string[]} groups
30
+ */
31
+ function orderHelp(cmd, groups) {
32
+ const rank = (c) => {
33
+ const i = groups.indexOf(c.helpGroup());
34
+ // Other verbs of the records (thinr's `logs presets`) go after these.
35
+ const verb = RECORD_VERBS.indexOf(c.name());
36
+ const at = c.helpGroup() === 'Records:' ? (verb === -1 ? RECORD_VERBS.length : verb) : 0;
37
+ return (i === -1 ? groups.length : i) * 100 + at;
38
+ };
39
+ // `commands` is typed readonly, but it is a plain array the help reads in order.
40
+ /** @type {Command[]} */ (cmd.commands).sort((a, b) => rank(a) - rank(b));
41
+ for (const sub of cmd.commands) orderHelp(sub, NOUN_GROUPS);
42
+ }
43
+
44
+ /**
45
+ * @typedef {object} CliOptions
46
+ * @property {string} version
47
+ * @property {(program: Command, groups: { device: Command, product: Command, bucket: Command, dashboard: Command }) => void} [register]
48
+ * Adds the CLI's own commands, at the top level or inside the generic
49
+ * groups.
50
+ * @property {(program: Command) => Promise<void> | void} [onNoCommand]
51
+ * Runs when the CLI is called without arguments (default: print the help).
52
+ * @property {string} [mcpInstructions]
53
+ * @property {Record<string, import('./mcp/resource-hooks.js').ResourceHooks>} [resourceHooks]
54
+ * The CLI's hooks on the catalog types the generic `resource_*` tools
55
+ * (and the generated commands) serve.
56
+ * @property {Record<string, import('./mcp/tools-resource.js').LocalType>} [resourceTypes]
57
+ * Types the CLI adds to that catalog (its platform's own things).
58
+ * @property {Record<string, Record<string, import('./mcp/tools-resource.js').LocalAction>>} [resourceActions]
59
+ * Actions it adds to the catalog's types and its own.
60
+ * @property {Parameters<typeof import('./docs.js').defineCliDocs>[0]} [docs]
61
+ * Guides the CLI ships, served as type `doc` of the resource tools and as
62
+ * MCP resources next to the server's documentation.
63
+ * @property {Parameters<typeof describeArguments>[1]} [argumentHelp]
64
+ * Help of the positional arguments of the CLI's own commands.
65
+ */
13
66
 
14
67
  /**
15
- * Build and run a CLI on top of the core: global options, the generic
16
- * commands (login, logout, profile, device, product, bucket, dashboard) and `mcp`.
17
- * The identity must be defined before calling it.
68
+ * The command tree of a CLI on top of the core: global options, the
69
+ * commands written by hand (login, logout, profile, device, product,
70
+ * bucket, dashboard, api), the CLI's own (`register`), the commands over
71
+ * the MCP tools, the generated resource commands and `mcp`. The identity
72
+ * must be defined before calling it.
18
73
  *
19
- * @param {{
20
- * version: string,
21
- * argv?: string[],
22
- * register?: (program: Command, groups: { device: Command, product: Command, bucket: Command, dashboard: Command }) => void,
23
- * onNoCommand?: (program: Command) => Promise<void> | void,
24
- * mcpTools?: Array<object>,
25
- * mcpInstructions?: string,
26
- * resourceHooks?: Record<string, import('./mcp/resource-hooks.js').ResourceHooks>,
27
- * resourceTypes?: Record<string, import('./mcp/tools-resource.js').LocalType>,
28
- * resourceActions?: Record<string, Record<string, import('./mcp/tools-resource.js').LocalAction>>,
29
- * docs?: Parameters<typeof import('./docs.js').defineCliDocs>[0],
30
- * }} opts
31
- * `register` adds the CLI's own commands, at the top level or inside the
32
- * generic groups. `onNoCommand` runs when the CLI is called without
33
- * arguments (default: print the help). `mcpTools` are the CLI's own MCP
34
- * tools (see buildTools), and `resourceHooks` its hooks on the
35
- * catalog types the generic `resource_*` tools serve; `resourceTypes` and
36
- * `resourceActions` are types and actions it adds to that catalog (its
37
- * platform's own things, served by the same tools). `docs` are guides
38
- * the CLI ships, served by `doc_read` and as MCP resources next to the
39
- * server's documentation.
74
+ * @param {CliOptions & { strict?: boolean }} opts
75
+ * `strict` (tests): a generated group a CLI's command takes the place of
76
+ * throws instead of being left out.
40
77
  */
41
- export async function runCli({
78
+ export function buildProgram({
42
79
  version,
43
- argv = process.argv,
44
80
  register,
45
- onNoCommand,
46
- mcpTools = [],
47
81
  mcpInstructions,
48
82
  resourceHooks,
49
83
  resourceTypes,
50
84
  resourceActions,
51
85
  docs,
86
+ argumentHelp,
87
+ strict = false,
52
88
  }) {
53
89
  const id = identity();
54
-
55
- // Pick up --profile early so subsequent config reads use the right one.
56
- // Commander parses options later, but the config is touched before that.
57
- for (let i = 2; i < argv.length; i++) {
58
- const a = argv[i];
59
- if (a === '--') break;
60
- if (a === '--profile' && argv[i + 1]) {
61
- setActiveProfile(argv[i + 1]);
62
- break;
63
- }
64
- if (a.startsWith('--profile=')) {
65
- setActiveProfile(a.slice('--profile='.length));
66
- break;
67
- }
68
- }
69
-
70
- // Set JSON mode before any command runs so spinners/logs can opt out.
71
- detectJsonModeFromArgv(argv);
72
-
73
90
  const program = new Command();
74
91
  program
75
92
  .name(id.name)
@@ -86,17 +103,26 @@ export async function runCli({
86
103
  };
87
104
  registerAuthCommands(program);
88
105
  registerProfileCommands(program);
106
+ registerApiCommand(program);
89
107
  register?.(program, groups);
108
+ // After the CLI's own: a command it registers keeps its name.
109
+ registerDataCommands(program);
110
+ registerResourceCommands(program, { hooks: resourceHooks, strict });
111
+ describeArguments(program, argumentHelp);
90
112
 
91
113
  program
92
114
  .command('mcp')
93
115
  .helpGroup('Integration:')
94
116
  .description('Start the MCP server (stdio) for AI tool integration')
95
- .action(async () => {
117
+ .option(
118
+ '--read-only',
119
+ `Serve only the tools that read: describe, list, get and the read-only actions (or set ${identity().envPrefix}_MCP_READ_ONLY=1)`,
120
+ )
121
+ .action(async (opts) => {
96
122
  try {
97
123
  await startMCPServer({
98
124
  version,
99
- tools: mcpTools,
125
+ readOnly: !!opts.readOnly || /^(1|true|yes)$/i.test(env('MCP_READ_ONLY') ?? ''),
100
126
  instructions: mcpInstructions,
101
127
  resourceHooks,
102
128
  resourceTypes,
@@ -109,6 +135,36 @@ export async function runCli({
109
135
  }
110
136
  });
111
137
 
138
+ orderHelp(program, ROOT_GROUPS);
139
+ return program;
140
+ }
141
+
142
+ /**
143
+ * Build and run a CLI on top of the core (see buildProgram). The identity
144
+ * must be defined before calling it.
145
+ *
146
+ * @param {CliOptions & { argv?: string[] }} opts
147
+ */
148
+ export async function runCli({ argv = process.argv, onNoCommand, ...opts }) {
149
+ // Pick up --profile early so subsequent config reads use the right one.
150
+ // Commander parses options later, but the config is touched before that.
151
+ for (let i = 2; i < argv.length; i++) {
152
+ const a = argv[i];
153
+ if (a === '--') break;
154
+ if (a === '--profile' && argv[i + 1]) {
155
+ setActiveProfile(argv[i + 1]);
156
+ break;
157
+ }
158
+ if (a.startsWith('--profile=')) {
159
+ setActiveProfile(a.slice('--profile='.length));
160
+ break;
161
+ }
162
+ }
163
+
164
+ // Set JSON mode before any command runs so spinners/logs can opt out.
165
+ detectJsonModeFromArgv(argv);
166
+
167
+ const program = buildProgram(opts);
112
168
  if (argv.length <= 2) {
113
169
  if (onNoCommand) await onNoCommand(program);
114
170
  else program.help();
@@ -34,6 +34,16 @@ export function getGlobalUser(cmd) {
34
34
  return root.opts().user || null;
35
35
  }
36
36
 
37
+ export function parseNonNegativeInt(label) {
38
+ return (value) => {
39
+ const n = Number(value);
40
+ if (!Number.isInteger(n) || n < 0) {
41
+ throw new InvalidArgumentError(`${label} must be a non-negative integer`);
42
+ }
43
+ return n;
44
+ };
45
+ }
46
+
37
47
  export function parsePositiveInt(label) {
38
48
  return (value) => {
39
49
  const n = Number(value);
@@ -54,6 +64,37 @@ export function collectKeyValue(value, previous = {}) {
54
64
  return { ...previous, [value.slice(0, idx)]: parseJsonValue(value.slice(idx + 1)) };
55
65
  }
56
66
 
67
+ // Like collectKeyValue, values kept as written: query parameters, where
68
+ // "007" must not become 7.
69
+ export function collectKeyString(value, previous = {}) {
70
+ const idx = value.indexOf('=');
71
+ if (idx === -1) throw new InvalidArgumentError('must be key=value');
72
+ return { ...previous, [value.slice(0, idx)]: value.slice(idx + 1) };
73
+ }
74
+
75
+ /**
76
+ * Set `value` at a dot path of `target` (`data.source` → target.data.source),
77
+ * creating the objects on the way. Keys that would reach the prototype are
78
+ * refused.
79
+ *
80
+ * @param {Record<string, any>} target
81
+ * @param {string} path
82
+ * @param {any} value
83
+ */
84
+ export function setPath(target, path, value) {
85
+ const keys = path.split('.');
86
+ if (keys.some((k) => !k || k === '__proto__' || k === 'constructor' || k === 'prototype')) {
87
+ throw new InvalidArgumentError(`invalid field: ${path}`);
88
+ }
89
+ let node = target;
90
+ for (const k of keys.slice(0, -1)) {
91
+ if (!node[k] || typeof node[k] !== 'object' || Array.isArray(node[k])) node[k] = {};
92
+ node = node[k];
93
+ }
94
+ node[keys[keys.length - 1]] = value;
95
+ return target;
96
+ }
97
+
57
98
  // Parse a command-line value as JSON, falling back to the raw string so
58
99
  // `property set dev name foo` works without shell-escaping quotes.
59
100
  export function parseJsonValue(raw) {
@@ -66,7 +107,9 @@ export function parseJsonValue(raw) {
66
107
  }
67
108
 
68
109
  // Read a JSON document from a file path, or from stdin when the path is `-`.
69
- export async function readJsonArgument(source) {
110
+ // With `lines`, a file of one JSON document per line (NDJSON) is read as
111
+ // the array of them.
112
+ export async function readJsonArgument(source, { lines = false } = {}) {
70
113
  let text;
71
114
  if (source === '-') {
72
115
  const chunks = [];
@@ -78,10 +121,55 @@ export async function readJsonArgument(source) {
78
121
  try {
79
122
  return JSON.parse(text);
80
123
  } catch (error) {
124
+ if (lines) {
125
+ try {
126
+ return text
127
+ .split('\n')
128
+ .filter((l) => l.trim())
129
+ .map((l) => JSON.parse(l));
130
+ } catch {
131
+ // Report the error of the whole document.
132
+ }
133
+ }
81
134
  throw new InvalidArgumentError(`${source} is not valid JSON: ${error.message}`);
82
135
  }
83
136
  }
84
137
 
138
+ /**
139
+ * The body a command builds from `--from <file|->` and repeatable
140
+ * `--set key=value` (dot paths nest) on top of it. Undefined when neither
141
+ * is given.
142
+ *
143
+ * @param {{ from?: string, set?: Record<string, any> }} opts
144
+ * @param {{ lines?: boolean }} [read] Passed to readJsonArgument.
145
+ */
146
+ export async function bodyFromOptions(opts, read) {
147
+ let body;
148
+ if (opts.from) body = await readJsonArgument(opts.from, read);
149
+ if (opts.set) {
150
+ if (
151
+ body !== undefined &&
152
+ (typeof body !== 'object' || body === null || Array.isArray(body))
153
+ ) {
154
+ throw new InvalidArgumentError('--set needs the --from body to be a JSON object');
155
+ }
156
+ body = body ?? {};
157
+ for (const [k, v] of Object.entries(opts.set)) setPath(body, k, v);
158
+ }
159
+ return body;
160
+ }
161
+
162
+ /** `--from` and `--set`, the options of bodyFromOptions. */
163
+ export function bodyOptions(cmd, setFlag = '-s, --set <key=value>') {
164
+ return cmd
165
+ .option('--from <file>', 'JSON body from a file, or - for stdin')
166
+ .option(
167
+ setFlag,
168
+ 'Body field (repeatable; values parsed as JSON when they are; dots nest: data.source=resource)',
169
+ collectKeyValue,
170
+ );
171
+ }
172
+
85
173
  /**
86
174
  * Ask before an irreversible action unless `--yes` was given. Refuses in a
87
175
  * non-interactive shell (or JSON mode) without `--yes` rather than
@@ -0,0 +1,90 @@
1
+ // @ts-check
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { rawApiCall } from '../raw-api.js';
4
+ import { inputError } from '../errors.js';
5
+ import { classifyError, isJsonMode, printErr, printOk } from '../output.js';
6
+ import {
7
+ applyJsonFlag,
8
+ collectKeyString,
9
+ ensureConfigured,
10
+ getGlobalUser,
11
+ bodyFromOptions,
12
+ bodyOptions,
13
+ } from './_shared.js';
14
+
15
+ /** `<cli> api <path>` — any request to the server, as `gh api` does for GitHub. */
16
+ export function registerApiCommand(program) {
17
+ bodyOptions(
18
+ program
19
+ .command('api <path>')
20
+ .helpGroup('Integration:')
21
+ .description('Send a request to the server API (anything the commands do not cover)')
22
+ .option(
23
+ '-X, --method <method>',
24
+ 'GET, POST, PUT, PATCH or DELETE (default: GET, or POST with a body)',
25
+ ),
26
+ )
27
+ .option('-q, --query <key=value>', 'Query parameter (repeatable)', collectKeyString)
28
+ .option('-o, --output <file>', 'Write the answer to a file (downloads, binary answers)')
29
+ .option('-j, --json', 'Output as JSON ({ ok, data })')
30
+ .addHelpText(
31
+ 'after',
32
+ `
33
+ The path is relative to the profile's server; {user} stands for the account
34
+ (the profile's, or --user). The answer is printed as it comes: JSON pretty
35
+ printed, text as is.
36
+
37
+ Examples:
38
+ $ <cli> api /v1/users/{user}/endpoints
39
+ $ <cli> api /v1/users/{user}/buckets -s bucket=temps -s name=Temperatures -s source=write
40
+ $ <cli> api -X PUT /v1/users/{user}/devices/sensor1 --from device.json
41
+ $ <cli> api /v1/users/{user}/buckets/temps/data -q items=100 -q sort=desc
42
+ $ <cli> api /v2/users/{user}/storages/web/files/index.html -o index.html`.replaceAll(
43
+ '<cli>',
44
+ program.name(),
45
+ ),
46
+ )
47
+ .action(async (path, opts, cmd) => {
48
+ applyJsonFlag(opts);
49
+ ensureConfigured();
50
+ try {
51
+ const body = await bodyFromOptions(opts);
52
+ if (
53
+ body !== undefined &&
54
+ ['GET', 'DELETE'].includes(String(opts.method).toUpperCase())
55
+ ) {
56
+ throw inputError(
57
+ `${opts.method.toUpperCase()} takes no body: pass query parameters with -q`,
58
+ );
59
+ }
60
+ const res = await rawApiCall({
61
+ method: opts.method,
62
+ path,
63
+ query: opts.query,
64
+ body,
65
+ user: getGlobalUser(cmd),
66
+ });
67
+ if (opts.output) {
68
+ await writeFile(opts.output, res.raw);
69
+ if (isJsonMode()) printOk({ status: res.status, file: opts.output });
70
+ else process.stderr.write(`Saved to ${opts.output}\n`);
71
+ return;
72
+ }
73
+ if (res.kind === 'binary') {
74
+ if (isJsonMode() || process.stdout.isTTY) {
75
+ throw inputError(
76
+ `the answer is binary (${res.contentType || 'unknown type'}, ${res.body.length} bytes): save it with -o <file>`,
77
+ );
78
+ }
79
+ process.stdout.write(res.body);
80
+ return;
81
+ }
82
+ if (isJsonMode()) printOk(res.body);
83
+ else if (res.kind === 'json') console.log(JSON.stringify(res.body, null, 2));
84
+ else if (res.kind === 'text') process.stdout.write(res.raw);
85
+ } catch (error) {
86
+ const { message, code } = classifyError(error);
87
+ printErr(message, { code });
88
+ }
89
+ });
90
+ }
@@ -0,0 +1,62 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Help for the positional arguments of the commands written by hand, kept
5
+ * in one table instead of spread over every `.command('x <id>')`. A command
6
+ * path ('dashboard init') wins over the argument name alone; a CLI adds its
7
+ * own tables (runCli's `argumentHelp`).
8
+ */
9
+ const BY_COMMAND = {
10
+ api: { path: 'API path, e.g. /v1/users/{user}/devices ({user}: your account)' },
11
+ 'device set-product': { product: 'Product ID ("" removes the device from its product)' },
12
+ 'device resource call': { resource: 'Resource of the device to call' },
13
+ 'product profile api call': { resource: 'API resource of the product profile' },
14
+ 'doc get': { doc: 'ID of the guide (doc list gives them)' },
15
+
16
+ 'dashboard init': { dir: 'New or empty folder for the app' },
17
+ 'dashboard dev': {
18
+ url: 'Address of the app on its dev server (default: dev.url of the manifest)',
19
+ },
20
+ 'dashboard publish': { dir: 'Folder to upload (default: dir of the manifest)' },
21
+ 'storage file list': { folder: 'Folder of the storage to list (default: all the files)' },
22
+ 'storage file upload': { local: 'Local file or folder' },
23
+ 'storage file download': { path: 'Path of the file in the storage' },
24
+ 'storage file delete': { path: 'Path of the file in the storage' },
25
+ 'plugin call': { path: 'Path in the API of the plugin, e.g. /api/status' },
26
+ 'profile add': { name: 'Profile name (asked when missing)' },
27
+ };
28
+
29
+ const BY_NAME = {
30
+ device: 'Device ID',
31
+ product: 'Product ID',
32
+ bucket: 'Data Bucket ID',
33
+ property: 'Property ID',
34
+ value: 'Value, parsed as JSON (text when it is not JSON)',
35
+ storage: 'File storage ID',
36
+ plugin: 'Plugin ID',
37
+ name: 'Profile name',
38
+ };
39
+
40
+ /**
41
+ * Describe the arguments that have no description yet, in the whole tree.
42
+ *
43
+ * @param {import('commander').Command} program
44
+ * @param {{ byCommand?: Record<string, Record<string, string>>, byName?: Record<string, string> }} [extra]
45
+ * A CLI's own tables, read before the core's.
46
+ */
47
+ export function describeArguments(program, { byCommand = {}, byName = {} } = {}) {
48
+ const walk = (cmd, path) => {
49
+ for (const arg of cmd.registeredArguments) {
50
+ if (arg.description) continue;
51
+ const name = arg.name();
52
+ arg.description =
53
+ byCommand[path]?.[name] ??
54
+ BY_COMMAND[path]?.[name] ??
55
+ byName[name] ??
56
+ BY_NAME[name] ??
57
+ '';
58
+ }
59
+ for (const sub of cmd.commands) walk(sub, path ? `${path} ${sub.name()}` : sub.name());
60
+ };
61
+ walk(program, '');
62
+ }
@@ -2,6 +2,7 @@
2
2
  import { getBucketData, getBuckets } from '../bucket.js';
3
3
  import { hint, info, label, success } from '../format.js';
4
4
  import { isJsonMode, printOk } from '../output.js';
5
+ import { hasListOptions, listOptions, listRecords } from './resources.js';
5
6
  import {
6
7
  applyJsonFlag,
7
8
  ensureConfigured,
@@ -10,71 +11,78 @@ import {
10
11
  runCommand,
11
12
  } from './_shared.js';
12
13
 
13
- /** `<cli> bucket list|read` — data buckets and their time series. */
14
+ /**
15
+ * `<cli> bucket list|read` — data buckets as a table and their time
16
+ * series; the rest of the group (write, export, records, tags…) comes from
17
+ * the tool and resource commands.
18
+ */
14
19
  export function registerBucketCommands(program) {
15
20
  const bucket = program
16
21
  .command('bucket')
17
- .helpGroup('Operations:')
18
- .description('Data buckets: list them and read their time series');
22
+ .helpGroup('Resources:')
23
+ .description('Data buckets: records, time series, exports and tags');
19
24
 
20
- bucket
21
- .command('list')
22
- .description('List the data buckets of the account')
23
- .option('-j, --json', 'Output as JSON')
24
- .action(async (opts, cmd) => {
25
- applyJsonFlag(opts);
26
- ensureConfigured();
27
- const user = getGlobalUser(cmd);
28
- await runCommand({
29
- start: 'Fetching buckets...',
30
- fn: () => getBuckets(user),
31
- success: (buckets) => `Found ${buckets.length} bucket(s)`,
32
- failure: 'Failed to list buckets',
33
- onSuccess: (buckets) => {
34
- if (isJsonMode()) {
35
- printOk(buckets);
36
- return;
37
- }
38
- const idWidth = buckets.reduce(
39
- (w, b) => Math.max(w, String(b.bucket ?? '').length),
40
- 0,
25
+ listOptions(
26
+ bucket
27
+ .command('list')
28
+ .helpGroup('Records:')
29
+ .description('List the data buckets of the account')
30
+ .option('-j, --json', 'Output as JSON'),
31
+ ).action(async (opts, cmd) => {
32
+ applyJsonFlag(opts);
33
+ ensureConfigured();
34
+ const user = getGlobalUser(cmd);
35
+ await runCommand({
36
+ start: 'Fetching buckets...',
37
+ fn: () => (hasListOptions(opts) ? listRecords('bucket', opts, user) : getBuckets(user)),
38
+ success: (buckets) => `Found ${buckets.length} bucket(s)`,
39
+ failure: 'Failed to list buckets',
40
+ onSuccess: (buckets) => {
41
+ if (isJsonMode()) {
42
+ printOk(buckets);
43
+ return;
44
+ }
45
+ const idWidth = buckets.reduce(
46
+ (w, b) => Math.max(w, String(b.bucket ?? '').length),
47
+ 0,
48
+ );
49
+ for (const b of buckets) {
50
+ const flag = b.enabled === false ? hint('disabled') : success('enabled ');
51
+ const extras = [
52
+ b.name && b.name !== b.bucket ? hint(`(${b.name})`) : '',
53
+ b.product ? info(`[${b.product}]`) : '',
54
+ b.config?.source ? hint(`source: ${b.config.source}`) : '',
55
+ ].filter(Boolean);
56
+ console.log(
57
+ ` ${flag} ${label(String(b.bucket ?? '(no id)').padEnd(idWidth))}${extras.length ? ' ' + extras.join(' ') : ''}`,
41
58
  );
42
- for (const b of buckets) {
43
- const flag = b.enabled === false ? hint('disabled') : success('enabled ');
44
- const extras = [
45
- b.name && b.name !== b.bucket ? hint(`(${b.name})`) : '',
46
- b.product ? info(`[${b.product}]`) : '',
47
- b.config?.source ? hint(`source: ${b.config.source}`) : '',
48
- ].filter(Boolean);
49
- console.log(
50
- ` ${flag} ${label(String(b.bucket ?? '(no id)').padEnd(idWidth))}${extras.length ? ' ' + extras.join(' ') : ''}`,
51
- );
52
- }
53
- },
54
- });
59
+ }
60
+ },
55
61
  });
62
+ });
56
63
 
57
64
  bucket
58
- .command('read <bucketId>')
65
+ .command('read <bucket>')
66
+ .helpGroup('Data:')
59
67
  .description('Read samples from a bucket (latest first by default)')
60
- .option('-d, --device <deviceId>', 'Series a product bucket keeps for this device')
61
- .option('-n, --items <n>', 'Number of samples (default: 10)', parsePositiveInt('items'), 10)
68
+ .option('-d, --device <device>', 'Series a product bucket keeps for this device')
69
+ .option('-n, --items <n>', 'Number of samples', parsePositiveInt('items'), 10)
62
70
  .option('-m, --minutes <n>', 'Only the last N minutes', parsePositiveInt('minutes'))
63
- .option('--sort <order>', 'asc or desc (default: desc)', 'desc')
71
+ .option('--sort <order>', 'asc or desc', 'desc')
64
72
  .option('--agg <window>', 'Aggregation window, e.g. 5m, 1h')
65
73
  .option('--agg-type <reducer>', 'Reducer: mean, max, min, sum, count, first, last…')
66
74
  .option('--fields <list>', 'Comma-separated fields to return')
67
75
  .option('--group-by <key>', 'Group key, e.g. device')
68
76
  .option('-j, --json', 'Output as JSON')
69
- .action(async (bucketId, opts, cmd) => {
77
+ .action(async (id, opts, cmd) => {
70
78
  applyJsonFlag(opts);
71
79
  ensureConfigured();
72
80
  const user = getGlobalUser(cmd);
73
81
  await runCommand({
74
- start: `Reading ${bucketId}...`,
82
+ start: `Reading ${id}...`,
75
83
  fn: () =>
76
84
  getBucketData({
77
- bucket: bucketId,
85
+ bucket: id,
78
86
  device: opts.device,
79
87
  user,
80
88
  items: opts.items,
@@ -35,9 +35,9 @@ export function registerDashboardCommands(program) {
35
35
  const cli = identity().name;
36
36
  const dashboard = program
37
37
  .command('dashboard')
38
- .helpGroup('Operations:')
38
+ .helpGroup('Resources:')
39
39
  .description(
40
- 'Dashboard apps (HTML + the dashboard SDK): develop them on their dev server and publish them',
40
+ 'Dashboards: apps on the dashboard SDK (create, develop and publish them) and their records',
41
41
  )
42
42
  .addHelpText(
43
43
  'after',
@@ -52,6 +52,7 @@ In CI, ${identity().envPrefix}_TOKEN and ${identity().envPrefix}_SERVER replace
52
52
 
53
53
  dashboard
54
54
  .command('init <dir>')
55
+ .helpGroup('Apps:')
55
56
  .description(
56
57
  'Create a dashboard app (TypeScript, on @thinger-io/dashboard-sdk) from a template of the SDK',
57
58
  )
@@ -111,6 +112,7 @@ In CI, ${identity().envPrefix}_TOKEN and ${identity().envPrefix}_SERVER replace
111
112
 
112
113
  dashboard
113
114
  .command('dev [url]')
115
+ .helpGroup('Apps:')
114
116
  .description(
115
117
  'Link that opens the app on its own dev server (ng serve, Vite…) against the server, with a temporary token',
116
118
  )
@@ -200,6 +202,7 @@ token (one per project).`,
200
202
 
201
203
  dashboard
202
204
  .command('publish [dir]')
205
+ .helpGroup('Apps:')
203
206
  .description('Upload an app to a public storage and point dashboards at it')
204
207
  .option('-s, --storage <id>', 'Storage of the app (created public if missing)')
205
208
  .option(