@thinger-io/cli-core 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1] - 2026-10-10
4
+
5
+ ### Fixed
6
+
7
+ - `repository.url` in the canonical `git+https` form npm expects; first release
8
+ published from GitHub Actions through Trusted Publishing.
9
+
10
+ ## [0.1.0] - 2026-10-10
11
+
12
+ First version, extracted from the core of `thinr` 1.3.5 and made
13
+ independent of the product.
14
+
15
+ ### Added
16
+
17
+ - CLI identity (`defineIdentity`): name, config folder, environment prefix,
18
+ login token, OAuth client, MCP server name and tool prefix, product
19
+ template.
20
+ - `runCli` with the generic commands (login, logout, profile, device, product,
21
+ bucket, mcp) and extension points: `register` with the command groups,
22
+ `onNoCommand`, `mcpTools`, `mcpInstructions`.
23
+ - MCP tools declared with bare names and `{tool:x}` / `{family:x}`
24
+ references, expanded and validated when the server starts; CLI tools can
25
+ replace generic ones explicitly (`replaces: true`).
26
+ - `device create` / `device_create`, `bucket list` / `buckets`.
27
+
28
+ ### Changed (compared with thinr 1.3.5)
29
+
30
+ - The API base URL and token come from the profile active for each request,
31
+ and the MCP `profile` argument is scoped per call (AsyncLocalStorage).
32
+ - Servers may carry a scheme (`http://localhost:8080`); profiles are named
33
+ after the host.
34
+ - `logout` forgets the current profile (or `--all`) and revokes the API token
35
+ the CLI minted, instead of deleting every profile.
36
+ - Login works without prompts (`--server --username --token`) and fails with
37
+ a clear message when a prompt has no terminal.
38
+ - The config file is written with mode 600.
39
+ - Server error messages are kept in the errors shown to the user.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+
2
+ MIT License
3
+
4
+ Copyright (c) 2025 Thinger.io
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # @thinger-io/cli-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The shared core of the CLIs built on Thinger.io servers — `thinger`
4
+ ([@thinger-io/thinger-cli](https://github.com/thinger-io/thinger-cli)) and
5
+ ThinRemote's `thinr` — and of the MCP servers they expose.
6
+
7
+ It holds everything that is not specific to a product:
8
+
9
+ - **API client** (`api`) — axios with the active profile's server and token on
10
+ every request, refresh on 401, self-signed TLS for localhost.
11
+ - **Auth** (`auth`) — username/password (mints a permanent API token),
12
+ pasted token, OAuth device flow; interactive or fully non-interactive.
13
+ - **Profiles** (`config`) — several servers per CLI, selected by `--profile`,
14
+ `<PREFIX>_PROFILE` or the saved default, and scoped per call
15
+ (`withActiveProfile`) so concurrent MCP calls don't mix servers.
16
+ - **REST helpers** — `devices`, `property`, `resource`, `bucket`, `product`,
17
+ `profile` (product profiles), `tokens`, `alarms`, `storage`, with every
18
+ account path built in `paths`.
19
+ - **Generic commands** (`cli`, `commands/*`) — login, logout, profile, device,
20
+ product, bucket, mcp.
21
+ - **MCP** (`mcp/*`) — the stdio server and ~50 generic tools.
22
+
23
+ ## Identity
24
+
25
+ The core knows nothing about the product it runs for. A CLI defines its
26
+ identity once, before anything else, and runs:
27
+
28
+ ```js
29
+ import { defineIdentity } from '@thinger-io/cli-core/identity';
30
+ import { runCli } from '@thinger-io/cli-core/cli';
31
+
32
+ defineIdentity({
33
+ name: 'acme', // binary name, used in hints
34
+ displayName: 'Acme IoT',
35
+ description: 'CLI for Acme IoT servers',
36
+ configDir: 'acme-cli', // ~/.config/acme-cli/config.json
37
+ envPrefix: 'ACME', // ACME_PROFILE, ACME_INSECURE
38
+ token: { id: 'acme_cli', name: 'Acme CLI' }, // minted by the password login
39
+ oauth: { clientId: null }, // OAuth device-flow client, if the platform ships one
40
+ defaultServer: null, // suggested by the login prompt
41
+ mcp: { serverName: 'acme', toolPrefix: 'acme' }, // tools become acme_devices, …
42
+ productTemplate: {}, // optional defaults for new products
43
+ });
44
+
45
+ await runCli({ version: '1.0.0' });
46
+ ```
47
+
48
+ Each CLI keeps its own profiles, environment variables and MCP tool names,
49
+ so two of them can run side by side without one operating on the other's
50
+ servers.
51
+
52
+ ## Extending
53
+
54
+ `runCli` takes:
55
+
56
+ - `register(program, { device, product, bucket })` — add top-level commands,
57
+ or subcommands inside the generic groups.
58
+ - `onNoCommand(program)` — what to do when called without arguments
59
+ (default: help).
60
+ - `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.
64
+ - `mcpInstructions` — server-level guidance sent to MCP clients.
65
+
66
+ Modules are imported by path: `@thinger-io/cli-core/api`,
67
+ `@thinger-io/cli-core/mcp/registry`, …
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ npm install
73
+ npm test # under a made-up "acme" identity
74
+ npm run lint
75
+ npm run typecheck
76
+ ```
77
+
78
+ ## License
79
+
80
+ MIT
package/lib/alarms.js ADDED
@@ -0,0 +1,223 @@
1
+ import api from './api.js';
2
+ import { apiError } from './errors.js';
3
+ import { v1 } from './paths.js';
4
+
5
+ // Backend enums (see backend/src/thinger/features/alarms/alarm_rule.hpp).
6
+ // Exported so the dashboard can render labels/colors without re-deriving
7
+ // them from string heuristics.
8
+ export const ALARM_STATE = {
9
+ NONE: 0,
10
+ ACTIVATED: 1,
11
+ ACKNOWLEDGED: 2,
12
+ LATCHED: 3,
13
+ SHELVED: 4,
14
+ CLEARED: 5,
15
+ };
16
+
17
+ export const ALARM_SEVERITY = {
18
+ NONE: 0,
19
+ LOW: 1,
20
+ MEDIUM: 2,
21
+ HIGH: 3,
22
+ CRITICAL: 4,
23
+ };
24
+
25
+ export const ALARM_STATE_LABEL = {
26
+ 0: 'none',
27
+ 1: 'active',
28
+ 2: 'ack',
29
+ 3: 'latched',
30
+ 4: 'shelved',
31
+ 5: 'cleared',
32
+ };
33
+
34
+ export const ALARM_SEVERITY_LABEL = {
35
+ 0: 'none',
36
+ 1: 'low',
37
+ 2: 'med',
38
+ 3: 'high',
39
+ 4: 'crit',
40
+ };
41
+
42
+ // Reverse maps so callers can pass human-readable strings ("ack", "shelved",
43
+ // "high", …) instead of remembering the numeric enum values.
44
+ const STATE_FROM_LABEL = {
45
+ none: ALARM_STATE.NONE,
46
+ active: ALARM_STATE.ACTIVATED,
47
+ activated: ALARM_STATE.ACTIVATED,
48
+ ack: ALARM_STATE.ACKNOWLEDGED,
49
+ acknowledged: ALARM_STATE.ACKNOWLEDGED,
50
+ latched: ALARM_STATE.LATCHED,
51
+ shelved: ALARM_STATE.SHELVED,
52
+ cleared: ALARM_STATE.CLEARED,
53
+ };
54
+
55
+ const SEVERITY_FROM_LABEL = {
56
+ none: ALARM_SEVERITY.NONE,
57
+ low: ALARM_SEVERITY.LOW,
58
+ med: ALARM_SEVERITY.MEDIUM,
59
+ medium: ALARM_SEVERITY.MEDIUM,
60
+ high: ALARM_SEVERITY.HIGH,
61
+ crit: ALARM_SEVERITY.CRITICAL,
62
+ critical: ALARM_SEVERITY.CRITICAL,
63
+ };
64
+
65
+ export function parseAlarmState(value) {
66
+ if (value == null) return undefined;
67
+ if (typeof value === 'number') return value;
68
+ const key = String(value).trim().toLowerCase();
69
+ return STATE_FROM_LABEL[key];
70
+ }
71
+
72
+ export function parseAlarmSeverity(value) {
73
+ if (value == null) return undefined;
74
+ if (typeof value === 'number') return value;
75
+ const key = String(value).trim().toLowerCase();
76
+ return SEVERITY_FROM_LABEL[key];
77
+ }
78
+
79
+ // Repeats a query param as the server expects (`?state=1&state=2`).
80
+ function appendMulti(params, key, values) {
81
+ if (values == null) return;
82
+ const arr = Array.isArray(values) ? values : [values];
83
+ for (const v of arr) params.append(key, String(v));
84
+ }
85
+
86
+ /**
87
+ * Query alarm instances from /v1/users/{user}/alarms/instances.
88
+ *
89
+ * `state` and `severity` are repeated query params — pass numbers or
90
+ * arrays of numbers (matching the ALARM_STATE / ALARM_SEVERITY enums).
91
+ *
92
+ * @param {{
93
+ * user?: string | null,
94
+ * count?: number,
95
+ * index?: number,
96
+ * name?: string,
97
+ * state?: number | number[],
98
+ * severity?: number | number[],
99
+ * sort?: string,
100
+ * order?: string,
101
+ * }} [opts]
102
+ */
103
+ export async function getAlarmInstances({
104
+ user = null,
105
+ count = 100,
106
+ index = 0,
107
+ name,
108
+ state,
109
+ severity,
110
+ sort,
111
+ order,
112
+ } = {}) {
113
+ const params = new URLSearchParams();
114
+ params.set('count', String(count));
115
+ params.set('index', String(index));
116
+ if (name) params.set('name', name);
117
+ if (sort) params.set('sort', sort);
118
+ if (order) params.set('order', order);
119
+ appendMulti(params, 'state', state);
120
+ appendMulti(params, 'severity', severity);
121
+ try {
122
+ const res = await api.get(`${v1(user)}/alarms/instances?${params.toString()}`);
123
+ return Array.isArray(res.data) ? res.data : [];
124
+ } catch (e) {
125
+ throw apiError(e);
126
+ }
127
+ }
128
+
129
+ // Counter per state, e.g. { activated: 3, acknowledged: 1, ... }. The
130
+ // server seeds zeros for every known state so consumers can render a
131
+ // stable layout without null-checks.
132
+ export async function getAlarmInstanceStats({ user = null } = {}) {
133
+ try {
134
+ const res = await api.get(`${v1(user)}/alarms/instances/stats`);
135
+ return res.data || {};
136
+ } catch (e) {
137
+ throw apiError(e);
138
+ }
139
+ }
140
+
141
+ export async function getAlarmInstance(instance, { user = null } = {}) {
142
+ try {
143
+ const res = await api.get(`${v1(user)}/alarms/instances/${encodeURIComponent(instance)}`);
144
+ return res.data;
145
+ } catch (e) {
146
+ throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
147
+ }
148
+ }
149
+
150
+ // PUT-style update. The server accepts only a small whitelist of fields
151
+ // (see UPDATE_SCHEMA in alarms_instances.cpp): name, description, state,
152
+ // annotation, reactivation. Anything else is rejected.
153
+ export async function updateAlarmInstance(instance, body, { user = null } = {}) {
154
+ try {
155
+ const res = await api.put(
156
+ `${v1(user)}/alarms/instances/${encodeURIComponent(instance)}`,
157
+ body,
158
+ );
159
+ return res.data;
160
+ } catch (e) {
161
+ throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
162
+ }
163
+ }
164
+
165
+ export async function deleteAlarmInstance(instance, { user = null } = {}) {
166
+ try {
167
+ await api.delete(`${v1(user)}/alarms/instances/${encodeURIComponent(instance)}`);
168
+ return true;
169
+ } catch (e) {
170
+ throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
171
+ }
172
+ }
173
+
174
+ export async function getAlarmRules({ user = null, count = 200, index = 0 } = {}) {
175
+ const params = new URLSearchParams();
176
+ params.set('count', String(count));
177
+ params.set('index', String(index));
178
+ try {
179
+ const res = await api.get(`${v1(user)}/alarms/rules?${params.toString()}`);
180
+ return Array.isArray(res.data) ? res.data : [];
181
+ } catch (e) {
182
+ throw apiError(e);
183
+ }
184
+ }
185
+
186
+ export async function getAlarmRule(rule, { user = null } = {}) {
187
+ try {
188
+ const res = await api.get(`${v1(user)}/alarms/rules/${encodeURIComponent(rule)}`);
189
+ return res.data;
190
+ } catch (e) {
191
+ throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
192
+ }
193
+ }
194
+
195
+ // POST /v1/users/{user}/alarms/rules. The body must include `rule`,
196
+ // `name`, `enabled` and an optional `config` object — see CREATE_SCHEMA
197
+ // in alarms_rules.cpp for the full shape.
198
+ export async function createAlarmRule(body, { user = null } = {}) {
199
+ try {
200
+ const res = await api.post(`${v1(user)}/alarms/rules`, body);
201
+ return res.data;
202
+ } catch (e) {
203
+ throw apiError(e);
204
+ }
205
+ }
206
+
207
+ export async function updateAlarmRule(rule, body, { user = null } = {}) {
208
+ try {
209
+ const res = await api.put(`${v1(user)}/alarms/rules/${encodeURIComponent(rule)}`, body);
210
+ return res.data;
211
+ } catch (e) {
212
+ throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
213
+ }
214
+ }
215
+
216
+ export async function deleteAlarmRule(rule, { user = null } = {}) {
217
+ try {
218
+ await api.delete(`${v1(user)}/alarms/rules/${encodeURIComponent(rule)}`);
219
+ return true;
220
+ } catch (e) {
221
+ throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
222
+ }
223
+ }
package/lib/api.js ADDED
@@ -0,0 +1,91 @@
1
+ import axios from 'axios';
2
+ import { refreshToken, getAccessToken } from './auth.js';
3
+ import { readConfig, serverURL } from './config.js';
4
+ import { TIMEOUTS } from './constants.js';
5
+ import { env } from './identity.js';
6
+ import http from 'http';
7
+ import https from 'https';
8
+
9
+ const httpAgent = new http.Agent({ keepAlive: true });
10
+ // Accept self-signed certificates when talking to local dev servers
11
+ // (localhost / 127.0.0.1). Any other host still enforces full TLS
12
+ // validation. Toggleable via <PREFIX>_INSECURE=1 for other local setups.
13
+ const httpsAgent = new https.Agent({ keepAlive: true });
14
+ const insecureAgent = new https.Agent({ keepAlive: true, rejectUnauthorized: false });
15
+
16
+ const api = axios.create({
17
+ timeout: TIMEOUTS.API_DEFAULT_MS,
18
+ httpAgent,
19
+ httpsAgent,
20
+ });
21
+
22
+ api.interceptors.request.use(
23
+ (request) => {
24
+ // The server comes from the active profile on every request rather
25
+ // than from a process-wide default, so concurrent MCP tool calls
26
+ // scoped to different profiles each reach their own server.
27
+ if (!request.baseURL) request.baseURL = serverURL(readConfig().server);
28
+
29
+ // A caller probing a token it hasn't persisted yet sets the header
30
+ // itself; don't overwrite it with the stored one.
31
+ if (!request.headers['Authorization']) {
32
+ const token = getAccessToken();
33
+ if (token) request.headers['Authorization'] = `Bearer ${token}`;
34
+ }
35
+
36
+ // Allow self-signed TLS against localhost / 127.0.0.1 or when the user
37
+ // opts in explicitly. Anything else keeps default certificate validation.
38
+ // A malformed URL here is not worth aborting the request over — the
39
+ // HTTPS layer will surface the real error downstream; we only care
40
+ // about detecting localhost cheaply.
41
+ try {
42
+ const url = new URL(request.url || '', request.baseURL || 'https://placeholder/');
43
+ const isLocal = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
44
+ if (url.protocol === 'https:' && (isLocal || env('INSECURE') === '1')) {
45
+ request.httpsAgent = insecureAgent;
46
+ }
47
+ } catch {
48
+ // Intentionally swallowed — see note above.
49
+ }
50
+
51
+ return request;
52
+ },
53
+ (error) => {
54
+ return Promise.reject(error);
55
+ },
56
+ );
57
+
58
+ api.interceptors.response.use(
59
+ (response) => response,
60
+ async (error) => {
61
+ const originalRequest = error.config;
62
+
63
+ // Only intervene on 401s; everything else surfaces unchanged.
64
+ if (error.response?.status !== 401) return Promise.reject(error);
65
+
66
+ // Don't recurse: refresh requests themselves and already-retried
67
+ // requests must not trigger another refresh.
68
+ if (originalRequest?._isRefresh || originalRequest?._retry) {
69
+ return Promise.reject(error);
70
+ }
71
+
72
+ // Tokens issued via the username/password flow have no
73
+ // refresh_token. Skip the refresh attempt and let the 401 reach
74
+ // the caller — they can log in again.
75
+ if (!readConfig().refresh_token) return Promise.reject(error);
76
+
77
+ originalRequest._retry = true;
78
+
79
+ try {
80
+ const newToken = await refreshToken();
81
+ // Patch only this request; subsequent requests pick up the new
82
+ // token from config via the request interceptor.
83
+ originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
84
+ return api(originalRequest);
85
+ } catch (refreshError) {
86
+ return Promise.reject(refreshError);
87
+ }
88
+ },
89
+ );
90
+
91
+ export default api;