@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.
- package/CHANGELOG.md +207 -0
- package/README.md +82 -31
- package/lib/auth.js +25 -1
- 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 +302 -64
- 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 +1234 -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
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
|
-
*
|
|
16
|
-
* commands (login, logout, profile, device, product,
|
|
17
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
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();
|
package/lib/commands/_shared.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/commands/bucket.js
CHANGED
|
@@ -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
|
-
/**
|
|
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('
|
|
18
|
-
.description('Data buckets:
|
|
22
|
+
.helpGroup('Resources:')
|
|
23
|
+
.description('Data buckets: records, time series, exports and tags');
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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 <
|
|
65
|
+
.command('read <bucket>')
|
|
66
|
+
.helpGroup('Data:')
|
|
59
67
|
.description('Read samples from a bucket (latest first by default)')
|
|
60
|
-
.option('-d, --device <
|
|
61
|
-
.option('-n, --items <n>', 'Number of samples
|
|
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
|
|
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 (
|
|
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 ${
|
|
82
|
+
start: `Reading ${id}...`,
|
|
75
83
|
fn: () =>
|
|
76
84
|
getBucketData({
|
|
77
|
-
bucket:
|
|
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('
|
|
38
|
+
.helpGroup('Resources:')
|
|
39
39
|
.description(
|
|
40
|
-
'
|
|
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(
|