@thinger-io/cli-core 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,105 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-10-10
4
+
5
+ Incompatible change of the MCP tools: from 48 tools, one per type and
6
+ operation, to 23, less than half the size in `tools/list` (about 23,200
7
+ characters instead of 51,400, roughly 6,400 tokens instead of 14,300).
8
+
9
+ ### Added
10
+
11
+ - Seven generic tools over a catalog of the server's resource types:
12
+ `resource_describe`, `resource_list`, `resource_get`, `resource_create`,
13
+ `resource_update` (`merge: true` patches property values; `projects` in
14
+ the body sets the resource's projects), `resource_delete`,
15
+ `resource_clone`. The catalog (`mcp/catalog.js`) covers devices,
16
+ products, buckets, dashboards, endpoints, storages, asset types and
17
+ groups, projects, tokens, alarm rules and instances, claims, account
18
+ properties and their children (device properties, tokens, buckets, bucket
19
+ tags, services and HTTP callback; product properties, profile entries,
20
+ code and services; bucket tags and exports; asset type, asset group and
21
+ project properties; roles, project members and roles, syncs) and the
22
+ server's administration (users, hosts, domains, brands, OAuth clients,
23
+ identity providers, proxies, under /v1 rather than an account). Each tool
24
+ offers only the types that support its verb; permissions are the
25
+ server's to enforce.
26
+ - `resource_describe` reads the request schemas and examples from the
27
+ server's own OpenAPI document (`/v1/server/swagger.json`), cached per
28
+ server and version in `~/.config/<cli>/cache`; servers that do not publish
29
+ it fall back to the bundled copy (`openapi/swagger.json`, the server's
30
+ develop as of 2026-10-10). It also lists, for each verb, the permission action a token
31
+ needs (`permissions`: `Device` / `ReadDeviceStatus`…), taken from the same
32
+ document.
33
+ - Resource hooks: generated device credentials, the identity's product
34
+ template, device token expiries as durations ("30d") and permission tree
35
+ checks.
36
+ CLIs add theirs with `runCli({ resourceHooks })`.
37
+ - Plugins: type `plugin` (installed by default; the installable catalog
38
+ with filter `available`, summarised) with actions install, upgrade,
39
+ remove, start, stop, restart, pause, unpause, kill, public, private;
40
+ `plugin_property`; and `plugin_call` for the API each plugin serves.
41
+ Lists that ignore count/index are paged client-side, and `resource_list`
42
+ takes a `search`: by id or name on the server (escaped, any case), or
43
+ over every field shown on lists the server answers whole (plugins).
44
+ - `resource_call`: actions of a type that are not CRUD, declared in the
45
+ catalog with their operationId (checked against the OpenAPI document):
46
+ verify a domain, restart a host or read/replace its settings, send a
47
+ brand's test email, call an endpoint, export a product, read a device's
48
+ usage, test an alarm rule (rule, source, notification).
49
+ - Tools that are not CRUD: `bucket_data_read`, `bucket_data_write`,
50
+ `device_resource_list`, `device_resource_call`, `device_data_send`,
51
+ `device_overview`, `product_resource_call`,
52
+ `storage_file_list` / `_read` / `_write` / `_delete`.
53
+ - `dashboard_publish`: publishes HTML apps as url-source dashboards
54
+ (servers with url-source dashboards). Uploads a local folder (a build
55
+ output, subfolders included) or inline files to a public storage, with
56
+ `prune` to delete files a new build no longer has; and/or points
57
+ dashboards at it: dashboards of the collection, or the `dashboard`
58
+ property of a device, product, asset type, asset group or project
59
+ (checked before anything is uploaded), each with its own entry page and
60
+ initial hash route. One storage can serve an app shared by many
61
+ dashboards. Entry urls carry `?v=` against stale caches; scripts and
62
+ styles without a content hash are reported.
63
+ - `doc_read`: the documentation the server publishes for agents, listed in
64
+ its index (`/sdk/docs.json`): a guide to the platform's concepts, the
65
+ dashboard SDK reference, its TypeScript declarations… A guide added to the
66
+ index reaches the model without a CLI release. The same documents are MCP
67
+ resources (`<prefix>://<id>`), the concepts guide is appended to
68
+ `resource_describe` without a type, and the SDK reference to
69
+ `resource_describe` of the dashboard type. Only paths on the profile's own
70
+ server are followed. CLIs add the guides they ship with
71
+ `runCli({ docs })`, listed first and readable without a server.
72
+ - `scripts/catalog-draft.js`: drafts catalog entries from an OpenAPI
73
+ document.
74
+
75
+ ### Changed
76
+
77
+ - A 403 no longer says the token may have expired: it says the token or the
78
+ account is not allowed, with the server's reason. The error code is still
79
+ `unauthorized`.
80
+ - `profiles` is now `profile_list`; `bucket_read` is `bucket_data_read`;
81
+ `resource_list` / `resource_call` (device resources) are
82
+ `device_resource_list` / `device_resource_call`.
83
+ - `buildTools(extraTools, { admin, hooks })`; `genericTools` is now a
84
+ function of the same options.
85
+
86
+ ### Removed
87
+
88
+ - The per-type tools, replaced by `resource_*` on the matching type:
89
+ `devices`, `device_info`, `device_create`, `device_set_product`,
90
+ `device_delete`, `products`, `product_create`, `product_update`,
91
+ `product_delete`, `product_property_*`, `product_profile_*`,
92
+ `property_get`, `property_set`, `token_*`, `device_token_*`,
93
+ `alarm_rules`, `alarm_rule_*`, `alarm_instances`, `alarm_instance_*`,
94
+ `buckets`.
95
+
96
+ ## [0.1.1] - 2026-10-10
97
+
98
+ ### Fixed
99
+
100
+ - `repository.url` in the canonical `git+https` form npm expects; first release
101
+ published from GitHub Actions through Trusted Publishing.
102
+
3
103
  ## [0.1.0] - 2026-10-10
4
104
 
5
105
  First version, extracted from the core of `thinr` 1.3.5 and made
package/README.md CHANGED
@@ -18,7 +18,13 @@ It holds everything that is not specific to a product:
18
18
  account path built in `paths`.
19
19
  - **Generic commands** (`cli`, `commands/*`) — login, logout, profile, device,
20
20
  product, bucket, mcp.
21
- - **MCP** (`mcp/*`) — the stdio server and ~50 generic tools.
21
+ - **MCP** (`mcp/*`) — the stdio server and 22 generic tools: eight
22
+ `resource_*` tools over a catalog of the server's resource types, and the
23
+ operations that are not CRUD (bucket data, device resources, storage
24
+ files, dashboard publishing, the server's documentation…), plus that
25
+ documentation as MCP resources.
26
+ - **OpenAPI** (`openapi`) — the server's own API description, cached per
27
+ server and version, with a bundled copy as fallback.
22
28
 
23
29
  ## Identity
24
30
 
@@ -38,7 +44,7 @@ defineIdentity({
38
44
  token: { id: 'acme_cli', name: 'Acme CLI' }, // minted by the password login
39
45
  oauth: { clientId: null }, // OAuth device-flow client, if the platform ships one
40
46
  defaultServer: null, // suggested by the login prompt
41
- mcp: { serverName: 'acme', toolPrefix: 'acme' }, // tools become acme_devices, …
47
+ mcp: { serverName: 'acme', toolPrefix: 'acme' }, // tools become acme_resource_list, …
42
48
  productTemplate: {}, // optional defaults for new products
43
49
  });
44
50
 
@@ -58,11 +64,37 @@ servers.
58
64
  - `onNoCommand(program)` — what to do when called without arguments
59
65
  (default: help).
60
66
  - `mcpTools` — the CLI's own MCP tools, declared with bare names
61
- (`name: 'exec'`) and referring to others in descriptions as
62
- `{tool:devices}` or `{family:token}`. Unknown references fail at startup.
63
- A tool flagged `replaces: true` swaps out the generic tool of the same name.
67
+ (`name: 'device_exec'`) and referring to others in descriptions as
68
+ `{tool:resource_list}` or `{family:storage_file}`. Unknown references fail
69
+ at startup. A tool flagged `replaces: true` swaps out the generic tool of
70
+ the same name.
71
+ - `resourceHooks` — the CLI's hooks on catalog types (`{ product: {
72
+ afterDelete } }`), run after the core's: see `mcp/resource-hooks.js`.
64
73
  - `mcpInstructions` — server-level guidance sent to MCP clients.
65
74
 
75
+ ## MCP tools
76
+
77
+ Tool names follow `<object>[_<subobject>]_<verb>`, with the verbs list, get,
78
+ describe, create, update, delete, clone, call, read, write, send.
79
+
80
+ The server's collections are reached through eight generic tools —
81
+ `resource_describe`, `resource_list`, `resource_get`, `resource_create`,
82
+ `resource_update`, `resource_delete`, `resource_clone`, `resource_call`
83
+ (actions that are not CRUD) — that take a `type`
84
+ from the catalog (`lib/mcp/catalog.js`), a `parent` for child types and an
85
+ `id`. The catalog is a reviewed table, never discovered at run time: each
86
+ type has its path, API version, parent, id and the verbs it allows, with the
87
+ server's operationId for each (the tests check them against the bundled
88
+ OpenAPI document). `resource_describe` reads the request schemas and
89
+ examples from the OpenAPI document of the server the profile points at.
90
+
91
+ To draft catalog entries after an API change:
92
+
93
+ ```bash
94
+ curl -s https://<server>/v1/server/swagger.json -o openapi/swagger.json
95
+ node scripts/catalog-draft.js
96
+ ```
97
+
66
98
  Modules are imported by path: `@thinger-io/cli-core/api`,
67
99
  `@thinger-io/cli-core/mcp/registry`, …
68
100
 
package/lib/api.js CHANGED
@@ -17,6 +17,9 @@ const api = axios.create({
17
17
  timeout: TIMEOUTS.API_DEFAULT_MS,
18
18
  httpAgent,
19
19
  httpsAgent,
20
+ // Arrays as repeated keys (product=a&product=b), which the server reads
21
+ // as "any of"; axios's default product[]=a matches only the first.
22
+ paramsSerializer: { indexes: null },
20
23
  });
21
24
 
22
25
  api.interceptors.request.use(
package/lib/auth.js CHANGED
@@ -4,8 +4,11 @@ import ora from 'ora';
4
4
  import open, { apps } from 'open';
5
5
  import {
6
6
  getActiveProfile,
7
+ getProfile,
7
8
  mergeConfig,
9
+ profileTarget,
8
10
  readConfig,
11
+ restoreProfile,
9
12
  setActiveProfile,
10
13
  writeConfig,
11
14
  } from './config.js';
@@ -90,15 +93,32 @@ export async function authenticate(opts = {}) {
90
93
  // Persist the server first: the API client takes its base URL from the
91
94
  // active profile, so the login requests below already reach it.
92
95
  // Pin the profile just written, so the merges below land on it even
93
- // when another profile is the default.
94
- setActiveProfile(writeConfig({ server }));
95
-
96
- if (method === 'userpass') {
97
- await authenticateWithUserPass(opts);
98
- } else if (method === 'oauth') {
99
- await authenticateWithOAuth(opts, existing);
100
- } else {
101
- await authenticateWithToken(opts);
96
+ // when another profile is the default. A login that fails or is
97
+ // interrupted puts the previous profile back: a wrong password must
98
+ // not leave the user (and their MCP servers) signed out.
99
+ const target = profileTarget(server);
100
+ const previous = target ? getProfile(target) : undefined;
101
+ const name = writeConfig({ server });
102
+ setActiveProfile(name);
103
+ const restore = () => restoreProfile(name, previous);
104
+ const onSigint = () => {
105
+ restore();
106
+ process.exit(130);
107
+ };
108
+ process.once('SIGINT', onSigint);
109
+ try {
110
+ if (method === 'userpass') {
111
+ await authenticateWithUserPass(opts);
112
+ } else if (method === 'oauth') {
113
+ await authenticateWithOAuth(opts, existing);
114
+ } else {
115
+ await authenticateWithToken(opts);
116
+ }
117
+ } catch (error) {
118
+ restore();
119
+ throw error;
120
+ } finally {
121
+ process.removeListener('SIGINT', onSigint);
102
122
  }
103
123
  }
104
124
 
package/lib/bucket.js CHANGED
@@ -38,6 +38,7 @@ import { v1, v2, v3 } from './paths.js';
38
38
  * max_ts?: number,
39
39
  * agg?: string,
40
40
  * agg_type?: string,
41
+ * tz?: string,
41
42
  * fields?: string,
42
43
  * group_by?: string | null,
43
44
  * }} opts
@@ -53,6 +54,7 @@ export async function getBucketData({
53
54
  max_ts,
54
55
  agg,
55
56
  agg_type,
57
+ tz,
56
58
  fields,
57
59
  group_by,
58
60
  } = {}) {
@@ -67,6 +69,7 @@ export async function getBucketData({
67
69
  }
68
70
  if (agg) params.agg = agg;
69
71
  if (agg_type) params.agg_type = agg_type;
72
+ if (tz) params.tz = tz;
70
73
  if (group_by) params.group_by = group_by;
71
74
  if (fields) params.fields = fields;
72
75
 
package/lib/cli.js CHANGED
@@ -22,11 +22,16 @@ import { registerBucketCommands } from './commands/bucket.js';
22
22
  * onNoCommand?: (program: Command) => Promise<void> | void,
23
23
  * mcpTools?: Array<object>,
24
24
  * mcpInstructions?: string,
25
+ * resourceHooks?: Record<string, import('./mcp/resource-hooks.js').ResourceHooks>,
26
+ * docs?: Parameters<typeof import('./docs.js').defineCliDocs>[0],
25
27
  * }} opts
26
28
  * `register` adds the CLI's own commands, at the top level or inside the
27
29
  * generic groups. `onNoCommand` runs when the CLI is called without
28
30
  * arguments (default: print the help). `mcpTools` are the CLI's own MCP
29
- * tools (see buildTools).
31
+ * tools (see buildTools), and `resourceHooks` its hooks on the
32
+ * catalog types the generic `resource_*` tools serve. `docs` are guides
33
+ * the CLI ships, served by `doc_read` and as MCP resources next to the
34
+ * server's documentation.
30
35
  */
31
36
  export async function runCli({
32
37
  version,
@@ -35,6 +40,8 @@ export async function runCli({
35
40
  onNoCommand,
36
41
  mcpTools = [],
37
42
  mcpInstructions,
43
+ resourceHooks,
44
+ docs,
38
45
  }) {
39
46
  const id = identity();
40
47
 
@@ -79,7 +86,13 @@ export async function runCli({
79
86
  .description('Start the MCP server (stdio) for AI tool integration')
80
87
  .action(async () => {
81
88
  try {
82
- await startMCPServer({ version, tools: mcpTools, instructions: mcpInstructions });
89
+ await startMCPServer({
90
+ version,
91
+ tools: mcpTools,
92
+ instructions: mcpInstructions,
93
+ resourceHooks,
94
+ docs,
95
+ });
83
96
  } catch (error) {
84
97
  console.error(`Error: ${classifyError(error).message}`);
85
98
  process.exit(1);
package/lib/config.js CHANGED
@@ -50,8 +50,20 @@ function getConfigPath() {
50
50
  if (!fs.existsSync(configDir)) {
51
51
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
52
52
  }
53
+ ownerOnly(configDir, 0o700);
54
+ const configPath = path.join(configDir, 'config.json');
55
+ if (fs.existsSync(configPath)) ownerOnly(configPath, 0o600);
56
+ return configPath;
57
+ }
53
58
 
54
- return path.join(configDir, 'config.json');
59
+ /**
60
+ * Folder for data the CLI can always rebuild (server API descriptions…),
61
+ * next to the profiles. Created on first access.
62
+ */
63
+ export function cacheDir() {
64
+ const dir = path.join(os.homedir(), '.config', identity().configDir, 'cache');
65
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
66
+ return dir;
55
67
  }
56
68
 
57
69
  function emptyStore() {
@@ -79,10 +91,27 @@ function readStore() {
79
91
  return raw;
80
92
  }
81
93
 
94
+ /**
95
+ * Take group and other permissions away from a file or folder made by an
96
+ * older version (or by hand): `mode` on create does not fix existing ones.
97
+ */
98
+ function ownerOnly(file, mode) {
99
+ try {
100
+ if ((fs.statSync(file).mode & 0o077) !== 0) fs.chmodSync(file, mode);
101
+ } catch {
102
+ // Not ours to fix (another owner, a filesystem without modes).
103
+ }
104
+ }
105
+
82
106
  function writeStore(store) {
83
107
  const configPath = getConfigPath();
84
- // The file holds API tokens: keep it readable by its owner only.
85
- fs.writeFileSync(configPath, JSON.stringify(store, null, 2), { encoding: 'utf8', mode: 0o600 });
108
+ // The file holds API tokens: readable by its owner only. Written aside
109
+ // and renamed, so a crash or a concurrent reader never sees half a file,
110
+ // and the rename leaves the new file's mode on the path.
111
+ const tmp = `${configPath}.${process.pid}.tmp`;
112
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2), { encoding: 'utf8', mode: 0o600 });
113
+ ownerOnly(tmp, 0o600);
114
+ fs.renameSync(tmp, configPath);
86
115
  }
87
116
 
88
117
  /**
@@ -247,8 +276,7 @@ export function requireConfig() {
247
276
  */
248
277
  export function writeConfig(config) {
249
278
  const store = readStore();
250
- const name =
251
- activeProfileOverride || profileNameFor(config && config.server) || getActiveProfile();
279
+ const name = profileTarget(config && config.server);
252
280
  if (!name) {
253
281
  throw new Error('Cannot write config without a server / profile name');
254
282
  }
@@ -258,6 +286,28 @@ export function writeConfig(config) {
258
286
  return name;
259
287
  }
260
288
 
289
+ /** The profile writeConfig({ server }) writes to. */
290
+ export function profileTarget(server) {
291
+ return activeProfileOverride || profileNameFor(server) || getActiveProfile();
292
+ }
293
+
294
+ /**
295
+ * Put a profile back as it was (`undefined`: it did not exist), after a
296
+ * login that failed half-way.
297
+ *
298
+ * @param {string} name
299
+ * @param {Record<string, any> | undefined} profile
300
+ */
301
+ export function restoreProfile(name, profile) {
302
+ const store = readStore();
303
+ if (profile) {
304
+ store.profiles[name] = profile;
305
+ writeStore(store);
306
+ } else if (store.profiles[name]) {
307
+ deleteProfile(name);
308
+ }
309
+ }
310
+
261
311
  /**
262
312
  * Merge fields into the active profile.
263
313
  */
package/lib/constants.js CHANGED
@@ -12,6 +12,14 @@ export const TIMEOUTS = {
12
12
  /** Default axios request timeout for control-plane HTTP calls. */
13
13
  API_DEFAULT_MS: 10_000,
14
14
 
15
+ /**
16
+ * Requests that do work on the server or move files: resource actions
17
+ * (plugin install pulls a container, bucket import, product export),
18
+ * device and plugin calls, uploads. The server keeps working past a
19
+ * client timeout, so a short one only makes callers retry half-done jobs.
20
+ */
21
+ API_LONG_MS: 10 * 60 * 1000,
22
+
15
23
  /**
16
24
  * Refresh a JWT access token this close to (or past) its `exp`
17
25
  * before opening a WebSocket. WS handshakes lack the