@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +235 -30
  3. package/bin/thinr.js +43 -25
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +68 -20
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +213 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +19 -5
  51. package/lib/constants.js +83 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +131 -148
  57. package/lib/devices.js +18 -21
  58. package/lib/errors.js +12 -0
  59. package/lib/fleet-upgrade.js +106 -0
  60. package/lib/format.js +114 -0
  61. package/lib/mcp/registry.js +14 -0
  62. package/lib/mcp/tools-alarms.js +510 -0
  63. package/lib/mcp/tools-buckets.js +103 -0
  64. package/lib/mcp/tools-devices.js +33 -3
  65. package/lib/mcp/tools-exec.js +30 -17
  66. package/lib/mcp/tools-filesystem.js +102 -6
  67. package/lib/mcp/tools-install.js +153 -0
  68. package/lib/mcp/tools-logs.js +225 -0
  69. package/lib/mcp/tools-playbook.js +211 -0
  70. package/lib/mcp/tools-products.js +1226 -105
  71. package/lib/mcp/tools-profile.js +634 -0
  72. package/lib/mcp/tools-tokens.js +371 -0
  73. package/lib/monitoring.js +26 -1
  74. package/lib/playbook/actions.js +447 -0
  75. package/lib/playbook/expression.js +302 -0
  76. package/lib/playbook/fleet.js +175 -0
  77. package/lib/playbook/loader.js +247 -0
  78. package/lib/playbook/runner.js +300 -0
  79. package/lib/playbook/schema.js +209 -0
  80. package/lib/playbook/vars.js +209 -0
  81. package/lib/product/log-presets.js +196 -0
  82. package/lib/product/logs.js +485 -0
  83. package/lib/product-orchestrator.js +124 -0
  84. package/lib/product.js +523 -20
  85. package/lib/profile.js +665 -0
  86. package/lib/property.js +7 -40
  87. package/lib/proxy.js +9 -8
  88. package/lib/resource.js +10 -14
  89. package/lib/status.js +78 -37
  90. package/lib/tokens.js +374 -0
  91. package/package.json +14 -3
  92. package/commands/product.js +0 -407
@@ -0,0 +1,59 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Small helper that talks to the public release CDN at
5
+ * `https://get.thinremote.io/<channel>.json`. Same file the agent itself
6
+ * polls to decide whether a self-update is available — keeping a single
7
+ * source of truth between what the CLI offers and what a device would
8
+ * actually download if the user ran `thinr device update apply`.
9
+ *
10
+ * Why a dedicated module: the TUI hook (`useLatestAgentVersion`) needed
11
+ * React-aware polling, the CLI just wants a one-shot fetch. Sharing the
12
+ * URL and the response shape here avoids drift if we ever add fields
13
+ * (signing keys, min-supported-version, etc.) to the JSON.
14
+ */
15
+
16
+ const CDN_BASE = 'https://get.thinremote.io';
17
+ const DEFAULT_TIMEOUT_MS = 5_000;
18
+
19
+ /**
20
+ * Fetch the release descriptor for a channel.
21
+ * @param {object} [opts]
22
+ * @param {string} [opts.channel='latest'] Release channel — matches the
23
+ * filenames under get.thinremote.io (`latest.json`, `main.json`,
24
+ * `develop.json`). Per-version archives are not exposed here; devices
25
+ * resolve those through the channel metadata themselves.
26
+ * @param {number} [opts.timeoutMs] Hard cap on the network call. 5s is
27
+ * plenty for a 1 KB JSON off a global CDN; anything slower is a DNS or
28
+ * connectivity issue that should fail fast rather than stall the CLI.
29
+ * @returns {Promise<{version: string, checksums?: Record<string,string>}>}
30
+ */
31
+ export async function fetchReleaseDescriptor({
32
+ channel = 'latest',
33
+ timeoutMs = DEFAULT_TIMEOUT_MS,
34
+ } = {}) {
35
+ const url = `${CDN_BASE}/${encodeURIComponent(channel)}.json`;
36
+ const controller = new AbortController();
37
+ const to = setTimeout(() => controller.abort(), timeoutMs);
38
+ try {
39
+ const res = await fetch(url, { signal: controller.signal });
40
+ if (!res.ok) {
41
+ throw new Error(
42
+ `release descriptor fetch failed: ${res.status} ${res.statusText}`,
43
+ );
44
+ }
45
+ const data = await res.json();
46
+ if (!data?.version) {
47
+ throw new Error('release descriptor missing `version` field');
48
+ }
49
+ return data;
50
+ } finally {
51
+ clearTimeout(to);
52
+ }
53
+ }
54
+
55
+ /** Convenience: just the version string. */
56
+ export async function fetchLatestAgentVersion(opts) {
57
+ const { version } = await fetchReleaseDescriptor(opts);
58
+ return version;
59
+ }
@@ -0,0 +1,73 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Agent-version helpers shared between the TUI (`src/dashboard/`) and the
5
+ * CLI commands. Kept in plain Node-compatible JS so both the bundled
6
+ * dashboard and the un-bundled CLI can import directly.
7
+ *
8
+ * The agent reports its version as a git-describe string (e.g.
9
+ * `v1.6.5-2-g7c3d192` for a dev build off `v1.6.5`). Grouping those
10
+ * together by release tag gives a readable rollout picture without an
11
+ * explosion of near-identical rows.
12
+ */
13
+
14
+ /** Drop the git-describe suffix so dev builds group with their release. */
15
+ export function normalizeAgentVersion(raw) {
16
+ if (!raw) return null;
17
+ const m = String(raw).match(/^(v?\d+\.\d+\.\d+)/);
18
+ return m ? m[1] : raw;
19
+ }
20
+
21
+ function semverTriple(v) {
22
+ if (!v) return null;
23
+ const m = String(v).match(/^v?(\d+)\.(\d+)\.(\d+)/);
24
+ if (!m) return null;
25
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
26
+ }
27
+
28
+ /**
29
+ * Descending comparator — newer first. Non-semver values sink to the
30
+ * bottom alphabetically, so rogue strings never claim "newest".
31
+ */
32
+ export function compareAgentVersions(a, b) {
33
+ const ta = semverTriple(a);
34
+ const tb = semverTriple(b);
35
+ if (ta && tb) {
36
+ for (let i = 0; i < 3; i++) {
37
+ if (ta[i] !== tb[i]) return tb[i] - ta[i];
38
+ }
39
+ return 0;
40
+ }
41
+ if (ta) return -1;
42
+ if (tb) return 1;
43
+ return String(b).localeCompare(String(a));
44
+ }
45
+
46
+ /**
47
+ * Partition a map of `deviceId -> reported-version-string` against a
48
+ * target version.
49
+ *
50
+ * Returns:
51
+ * - `outdated`: device IDs strictly older than `target`
52
+ * - `current`: device IDs already at `target`
53
+ * - `unknown`: device IDs we have no version for (never reported a
54
+ * sample, or reported a non-parseable string). These are *not*
55
+ * upgraded by default — we don't want to "fix" something we can't
56
+ * identify.
57
+ */
58
+ export function classifyAgainst(target, deviceVersions) {
59
+ const outdated = [];
60
+ const current = [];
61
+ const unknown = [];
62
+ for (const [id, raw] of Object.entries(deviceVersions)) {
63
+ const v = normalizeAgentVersion(raw);
64
+ if (!v) {
65
+ unknown.push(id);
66
+ continue;
67
+ }
68
+ const d = compareAgentVersions(v, target);
69
+ if (d > 0) outdated.push(id);
70
+ else current.push(id);
71
+ }
72
+ return { outdated, current, unknown };
73
+ }
package/lib/alarms.js ADDED
@@ -0,0 +1,252 @@
1
+ import api from './api.js';
2
+ import { apiError } from './errors.js';
3
+ import { requireConfig } from './config.js';
4
+
5
+ function resolveUser(user) {
6
+ return user || requireConfig().username;
7
+ }
8
+
9
+ // Backend enums (see backend/src/thinger/features/alarms/alarm_rule.hpp).
10
+ // Exported so the dashboard can render labels/colors without re-deriving
11
+ // them from string heuristics.
12
+ export const ALARM_STATE = {
13
+ NONE: 0,
14
+ ACTIVATED: 1,
15
+ ACKNOWLEDGED: 2,
16
+ LATCHED: 3,
17
+ SHELVED: 4,
18
+ CLEARED: 5,
19
+ };
20
+
21
+ export const ALARM_SEVERITY = {
22
+ NONE: 0,
23
+ LOW: 1,
24
+ MEDIUM: 2,
25
+ HIGH: 3,
26
+ CRITICAL: 4,
27
+ };
28
+
29
+ export const ALARM_STATE_LABEL = {
30
+ 0: 'none',
31
+ 1: 'active',
32
+ 2: 'ack',
33
+ 3: 'latched',
34
+ 4: 'shelved',
35
+ 5: 'cleared',
36
+ };
37
+
38
+ export const ALARM_SEVERITY_LABEL = {
39
+ 0: 'none',
40
+ 1: 'low',
41
+ 2: 'med',
42
+ 3: 'high',
43
+ 4: 'crit',
44
+ };
45
+
46
+ // Reverse maps so callers can pass human-readable strings ("ack", "shelved",
47
+ // "high", …) instead of remembering the numeric enum values.
48
+ const STATE_FROM_LABEL = {
49
+ none: ALARM_STATE.NONE,
50
+ active: ALARM_STATE.ACTIVATED,
51
+ activated: ALARM_STATE.ACTIVATED,
52
+ ack: ALARM_STATE.ACKNOWLEDGED,
53
+ acknowledged: ALARM_STATE.ACKNOWLEDGED,
54
+ latched: ALARM_STATE.LATCHED,
55
+ shelved: ALARM_STATE.SHELVED,
56
+ cleared: ALARM_STATE.CLEARED,
57
+ };
58
+
59
+ const SEVERITY_FROM_LABEL = {
60
+ none: ALARM_SEVERITY.NONE,
61
+ low: ALARM_SEVERITY.LOW,
62
+ med: ALARM_SEVERITY.MEDIUM,
63
+ medium: ALARM_SEVERITY.MEDIUM,
64
+ high: ALARM_SEVERITY.HIGH,
65
+ crit: ALARM_SEVERITY.CRITICAL,
66
+ critical: ALARM_SEVERITY.CRITICAL,
67
+ };
68
+
69
+ export function parseAlarmState(value) {
70
+ if (value == null) return undefined;
71
+ if (typeof value === 'number') return value;
72
+ const key = String(value).trim().toLowerCase();
73
+ return STATE_FROM_LABEL[key];
74
+ }
75
+
76
+ export function parseAlarmSeverity(value) {
77
+ if (value == null) return undefined;
78
+ if (typeof value === 'number') return value;
79
+ const key = String(value).trim().toLowerCase();
80
+ return SEVERITY_FROM_LABEL[key];
81
+ }
82
+
83
+ // Repeats a query param as the server expects (`?state=1&state=2`).
84
+ function appendMulti(params, key, values) {
85
+ if (values == null) return;
86
+ const arr = Array.isArray(values) ? values : [values];
87
+ for (const v of arr) params.append(key, String(v));
88
+ }
89
+
90
+ /**
91
+ * Query alarm instances from /v1/users/{user}/alarms/instances.
92
+ *
93
+ * `state` and `severity` are repeated query params — pass numbers or
94
+ * arrays of numbers (matching the ALARM_STATE / ALARM_SEVERITY enums).
95
+ *
96
+ * @param {{
97
+ * user?: string | null,
98
+ * count?: number,
99
+ * index?: number,
100
+ * name?: string,
101
+ * state?: number | number[],
102
+ * severity?: number | number[],
103
+ * sort?: string,
104
+ * order?: string,
105
+ * }} [opts]
106
+ */
107
+ export async function getAlarmInstances({
108
+ user = null,
109
+ count = 100,
110
+ index = 0,
111
+ name,
112
+ state,
113
+ severity,
114
+ sort,
115
+ order,
116
+ } = {}) {
117
+ const apiUser = resolveUser(user);
118
+ const params = new URLSearchParams();
119
+ params.set('count', String(count));
120
+ params.set('index', String(index));
121
+ if (name) params.set('name', name);
122
+ if (sort) params.set('sort', sort);
123
+ if (order) params.set('order', order);
124
+ appendMulti(params, 'state', state);
125
+ appendMulti(params, 'severity', severity);
126
+ try {
127
+ const res = await api.get(
128
+ `/v1/users/${apiUser}/alarms/instances?${params.toString()}`,
129
+ );
130
+ return Array.isArray(res.data) ? res.data : [];
131
+ } catch (e) {
132
+ throw apiError(e);
133
+ }
134
+ }
135
+
136
+ // Counter per state, e.g. { activated: 3, acknowledged: 1, ... }. The
137
+ // server seeds zeros for every known state so consumers can render a
138
+ // stable layout without null-checks.
139
+ export async function getAlarmInstanceStats({ user = null } = {}) {
140
+ const apiUser = resolveUser(user);
141
+ try {
142
+ const res = await api.get(`/v1/users/${apiUser}/alarms/instances/stats`);
143
+ return res.data || {};
144
+ } catch (e) {
145
+ throw apiError(e);
146
+ }
147
+ }
148
+
149
+ export async function getAlarmInstance(instance, { user = null } = {}) {
150
+ const apiUser = resolveUser(user);
151
+ try {
152
+ const res = await api.get(
153
+ `/v1/users/${apiUser}/alarms/instances/${encodeURIComponent(instance)}`,
154
+ );
155
+ return res.data;
156
+ } catch (e) {
157
+ throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
158
+ }
159
+ }
160
+
161
+ // PUT-style update. The server accepts only a small whitelist of fields
162
+ // (see UPDATE_SCHEMA in alarms_instances.cpp): name, description, state,
163
+ // annotation, reactivation. Anything else is rejected.
164
+ export async function updateAlarmInstance(instance, body, { user = null } = {}) {
165
+ const apiUser = resolveUser(user);
166
+ try {
167
+ const res = await api.put(
168
+ `/v1/users/${apiUser}/alarms/instances/${encodeURIComponent(instance)}`,
169
+ body,
170
+ );
171
+ return res.data;
172
+ } catch (e) {
173
+ throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
174
+ }
175
+ }
176
+
177
+ export async function deleteAlarmInstance(instance, { user = null } = {}) {
178
+ const apiUser = resolveUser(user);
179
+ try {
180
+ await api.delete(
181
+ `/v1/users/${apiUser}/alarms/instances/${encodeURIComponent(instance)}`,
182
+ );
183
+ return true;
184
+ } catch (e) {
185
+ throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
186
+ }
187
+ }
188
+
189
+ export async function getAlarmRules({ user = null, count = 200, index = 0 } = {}) {
190
+ const apiUser = resolveUser(user);
191
+ const params = new URLSearchParams();
192
+ params.set('count', String(count));
193
+ params.set('index', String(index));
194
+ try {
195
+ const res = await api.get(
196
+ `/v1/users/${apiUser}/alarms/rules?${params.toString()}`,
197
+ );
198
+ return Array.isArray(res.data) ? res.data : [];
199
+ } catch (e) {
200
+ throw apiError(e);
201
+ }
202
+ }
203
+
204
+ export async function getAlarmRule(rule, { user = null } = {}) {
205
+ const apiUser = resolveUser(user);
206
+ try {
207
+ const res = await api.get(
208
+ `/v1/users/${apiUser}/alarms/rules/${encodeURIComponent(rule)}`,
209
+ );
210
+ return res.data;
211
+ } catch (e) {
212
+ throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
213
+ }
214
+ }
215
+
216
+ // POST /v1/users/{user}/alarms/rules. The body must include `rule`,
217
+ // `name`, `enabled` and an optional `config` object — see CREATE_SCHEMA
218
+ // in alarms_rules.cpp for the full shape.
219
+ export async function createAlarmRule(body, { user = null } = {}) {
220
+ const apiUser = resolveUser(user);
221
+ try {
222
+ const res = await api.post(`/v1/users/${apiUser}/alarms/rules`, body);
223
+ return res.data;
224
+ } catch (e) {
225
+ throw apiError(e);
226
+ }
227
+ }
228
+
229
+ export async function updateAlarmRule(rule, body, { user = null } = {}) {
230
+ const apiUser = resolveUser(user);
231
+ try {
232
+ const res = await api.put(
233
+ `/v1/users/${apiUser}/alarms/rules/${encodeURIComponent(rule)}`,
234
+ body,
235
+ );
236
+ return res.data;
237
+ } catch (e) {
238
+ throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
239
+ }
240
+ }
241
+
242
+ export async function deleteAlarmRule(rule, { user = null } = {}) {
243
+ const apiUser = resolveUser(user);
244
+ try {
245
+ await api.delete(
246
+ `/v1/users/${apiUser}/alarms/rules/${encodeURIComponent(rule)}`,
247
+ );
248
+ return true;
249
+ } catch (e) {
250
+ throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
251
+ }
252
+ }
package/lib/api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import axios from 'axios';
2
2
  import { refreshToken, getAccessToken } from './auth.js';
3
3
  import { readConfig } from './config.js';
4
+ import { TIMEOUTS } from './constants.js';
4
5
  import http from 'http';
5
6
  import https from 'https';
6
7
 
@@ -13,7 +14,7 @@ const insecureAgent = new https.Agent({ keepAlive: true, rejectUnauthorized: fal
13
14
 
14
15
  const api = axios.create({
15
16
  // You can leave baseURL undefined initially
16
- timeout: 10000,
17
+ timeout: TIMEOUTS.API_DEFAULT_MS,
17
18
  httpAgent,
18
19
  httpsAgent,
19
20
  });
@@ -31,6 +32,9 @@ api.interceptors.request.use(
31
32
 
32
33
  // Allow self-signed TLS against localhost / 127.0.0.1 or when the user
33
34
  // opts in explicitly. Anything else keeps default certificate validation.
35
+ // A malformed URL here is not worth aborting the request over — the
36
+ // HTTPS layer will surface the real error downstream; we only care
37
+ // about detecting localhost cheaply.
34
38
  try {
35
39
  const base = request.baseURL || api.defaults.baseURL || '';
36
40
  const url = new URL(request.url || '', base || 'https://placeholder/');
@@ -38,7 +42,9 @@ api.interceptors.request.use(
38
42
  if (url.protocol === 'https:' && (isLocal || process.env.THINR_INSECURE === '1')) {
39
43
  request.httpsAgent = insecureAgent;
40
44
  }
41
- } catch {}
45
+ } catch {
46
+ // Intentionally swallowed — see note above.
47
+ }
42
48
 
43
49
  return request;
44
50
  },
package/lib/auth.js CHANGED
@@ -1,25 +1,25 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { input, select } from '@inquirer/prompts';
3
- import chalk from 'chalk';
4
3
  import ora from 'ora';
5
4
  import open, { apps } from 'open';
6
5
  import { writeConfig, readConfig, mergeConfig } from './config.js';
6
+ import { label, success, warning, error as errorStyle, info } from './format.js';
7
7
 
8
8
  import api, { setBaseURL } from './api.js';
9
+ import { OAUTH, TIMEOUTS } from './constants.js';
9
10
 
10
- // OAuth device-flow client identifier registered for this CLI on the
11
- // ThinRemote OAuth provider. Stable across versions.
12
- const OAUTH_CLIENT_ID = '3b40164d28730e416cbd';
13
-
14
- // Hard ceiling on how long to keep polling for the user to finish the
15
- // device-code flow in the browser before we give up (10 minutes).
16
- const OAUTH_DEVICE_CODE_TIMEOUT_MS = 10 * 60 * 1000;
11
+ const OAUTH_CLIENT_ID = OAUTH.CLIENT_ID;
12
+ const OAUTH_DEVICE_CODE_TIMEOUT_MS = TIMEOUTS.OAUTH_DEVICE_CODE_MS;
17
13
 
18
14
  /**
19
15
  * Authenticate user
20
16
  * @returns {Promise<void>}
21
17
  */
22
18
  export async function authenticate() {
19
+ // When re-authenticating an existing profile, default the server prompt
20
+ // to the one already stored so `thinr login` is a couple of keystrokes.
21
+ const existing = readConfig();
22
+
23
23
  // Ask for authentication method
24
24
  const method = await select({
25
25
  message: 'How would you like to authenticate?',
@@ -33,7 +33,7 @@ export async function authenticate() {
33
33
  // Ask for server URL
34
34
  const server = await input({
35
35
  message: 'Enter server URL:',
36
- default: 'console.thinr.io',
36
+ default: existing?.server || 'console.thinr.io',
37
37
  validate: (input) => {
38
38
  return input ? true : 'Server URL is required';
39
39
  },
@@ -134,12 +134,29 @@ async function authenticateWithUserPass() {
134
134
  * when the OAuth server rejects the refresh — both cases mean the
135
135
  * caller has to re-authenticate.
136
136
  *
137
+ * Concurrent callers share a single in-flight refresh: refresh tokens
138
+ * are rotated on use, so issuing two refreshes in parallel makes the
139
+ * second one fail against an already-consumed token. The shared
140
+ * promise lets every awaiter see the same result and lets the next
141
+ * caller (after completion) start a new refresh against the freshly
142
+ * persisted token.
143
+ *
137
144
  * @returns {Promise<string>} The new access token.
138
145
  */
146
+ let inFlightRefresh = null;
147
+
139
148
  export async function refreshToken() {
149
+ if (inFlightRefresh) return inFlightRefresh;
150
+ inFlightRefresh = doRefresh().finally(() => {
151
+ inFlightRefresh = null;
152
+ });
153
+ return inFlightRefresh;
154
+ }
155
+
156
+ async function doRefresh() {
140
157
  const config = readConfig();
141
158
  if (!config.refresh_token) {
142
- const err = new Error('No refresh token available; re-authenticate with `thinr`.');
159
+ const err = new Error('Session expired. Run `thinr login` to sign in again.');
143
160
  err.code = 'unauthorized';
144
161
  throw err;
145
162
  }
@@ -159,7 +176,7 @@ export async function refreshToken() {
159
176
  _isRefresh: true,
160
177
  });
161
178
  } catch (_error) {
162
- const err = new Error('Token refresh failed; re-authenticate with `thinr`.');
179
+ const err = new Error('Session expired. Run `thinr login` to sign in again.');
163
180
  err.code = 'unauthorized';
164
181
  throw err;
165
182
  }
@@ -171,6 +188,37 @@ export async function refreshToken() {
171
188
  return response.data.access_token;
172
189
  }
173
190
 
191
+ /**
192
+ * Milliseconds-since-epoch expiry of a JWT access token, or null when
193
+ * the token is not a decodable JWT (permanent API tokens have no exp).
194
+ */
195
+ function jwtExpiryMs(token) {
196
+ try {
197
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
198
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Return a currently-valid access token, refreshing first when the
206
+ * stored one is an expired (or about-to-expire) JWT. HTTP requests
207
+ * recover from expiry through the api.js response interceptor, but
208
+ * WebSocket handshakes have no retry layer: callers opening a WS use
209
+ * this to start from a valid token. Tokens without a decodable `exp`
210
+ * and profiles without a refresh_token pass through untouched.
211
+ */
212
+ export async function ensureFreshToken() {
213
+ const config = readConfig();
214
+ if (!config?.token || !config.refresh_token) return config?.token ?? null;
215
+ const expiresAt = jwtExpiryMs(config.token);
216
+ if (expiresAt === null || expiresAt - Date.now() > TIMEOUTS.TOKEN_EXPIRY_SLACK_MS) {
217
+ return config.token;
218
+ }
219
+ return refreshToken();
220
+ }
221
+
174
222
  /**
175
223
  * Delete user token from server
176
224
  * @returns {Promise<void>}
@@ -179,7 +227,7 @@ export async function deleteToken() {
179
227
  try {
180
228
  const config = readConfig();
181
229
  if (!config || !config.username || !config.server || !config.token) {
182
- console.log(chalk.yellow('No configuration found'));
230
+ console.log(warning('No configuration found'));
183
231
  return;
184
232
  }
185
233
 
@@ -193,11 +241,11 @@ export async function deleteToken() {
193
241
  spinner.succeed('Token was already deleted');
194
242
  } else {
195
243
  spinner.fail('Failed to delete token');
196
- console.error(chalk.red(`Error: ${error.message}`));
244
+ console.error(errorStyle(`Error: ${error.message}`));
197
245
  }
198
246
  }
199
247
  } catch (error) {
200
- console.error(chalk.red(`Error: ${error.message}`));
248
+ console.error(errorStyle(`Error: ${error.message}`));
201
249
  }
202
250
  }
203
251
 
@@ -258,9 +306,9 @@ async function authenticateWithOAuth() {
258
306
  throw error;
259
307
  }
260
308
 
261
- console.log('\nYour one time device code is: ' + chalk.bold(data.user_code));
309
+ console.log('\nYour one time device code is: ' + label(data.user_code));
262
310
  console.log(
263
- chalk.blue('Press ' + chalk.bold('ENTER')) +
311
+ info('Press ' + label('ENTER')) +
264
312
  ' to open your browser or submit your device code here: ' +
265
313
  data.verification_uri +
266
314
  '\n',
@@ -342,9 +390,9 @@ async function authenticateWithOAuth() {
342
390
  },
343
391
  ).catch((error) => {
344
392
  if (error.cause === 'timeout') {
345
- console.log(chalk.yellow('Polling aborted due to timeout'));
393
+ console.log(warning('Polling aborted due to timeout'));
346
394
  } else if (error.cause === 'validated') {
347
- console.log(chalk.green('Device authenticated successfully'));
395
+ console.log(success('Device authenticated successfully'));
348
396
  }
349
397
  // 'error' cause falls through; we surface pollError below.
350
398
  });
@@ -362,7 +410,7 @@ async function authenticateWithOAuth() {
362
410
  const username = decodedJWT.usr;
363
411
 
364
412
  mergeConfig({ username, token, refresh_token });
365
- console.log(chalk.green('OAuth token validation successful\n'));
413
+ console.log(success('OAuth token validation successful\n'));
366
414
  }
367
415
 
368
416
  /**
@@ -372,7 +420,7 @@ async function authenticateWithOAuth() {
372
420
  export function getAccessToken() {
373
421
  const config = readConfig();
374
422
  if (!config || !config.token) {
375
- console.log(chalk.yellow('No token found in configuration'));
423
+ console.log(warning('No token found in configuration'));
376
424
  return null;
377
425
  }
378
426
  return config.token;
package/lib/bucket.js ADDED
@@ -0,0 +1,87 @@
1
+ import api from './api.js';
2
+ import { apiError } from './errors.js';
3
+ import { requireConfig } from './config.js';
4
+
5
+ function resolveUser(user) {
6
+ return user || requireConfig().username;
7
+ }
8
+
9
+ /**
10
+ * Query an arbitrary time-series bucket declared in a product profile.
11
+ * When `device` is provided, scopes to that device's bucket; otherwise
12
+ * queries the user-level bucket across every device of the account.
13
+ *
14
+ * Buckets are the mechanism products use to persist structured samples
15
+ * over time — the platform ships a `monitoring` bucket by default, and
16
+ * users can declare custom ones for their own metrics.
17
+ *
18
+ * Time range:
19
+ * - `minutes`: shortcut for "last N minutes" (sets min_ts to
20
+ * `Date.now() - minutes * 60_000`, max_ts to 0).
21
+ * - `min_ts` / `max_ts`: explicit unix-millis range when `minutes`
22
+ * is not supplied; either may be omitted for an open bound.
23
+ *
24
+ * Aggregation: `agg` is the time-window size (e.g. "5m"), `agg_type`
25
+ * the reducer (e.g. "mean"), and `fields` a comma-separated allowlist
26
+ * to trim the response down.
27
+ *
28
+ * `group_by` is passed through verbatim — no default. Custom buckets
29
+ * may not even expose a `device` tag, so the caller decides whether
30
+ * to group (e.g. `group_by=device` for a fleet view) or read raw
31
+ * interleaved samples.
32
+ *
33
+ * @param {{
34
+ * bucket: string,
35
+ * device?: string | null,
36
+ * user?: string | null,
37
+ * items?: number,
38
+ * sort?: string,
39
+ * minutes?: number,
40
+ * min_ts?: number,
41
+ * max_ts?: number,
42
+ * agg?: string,
43
+ * agg_type?: string,
44
+ * fields?: string,
45
+ * group_by?: string | null,
46
+ * }} opts
47
+ */
48
+ export async function getBucketData({
49
+ bucket,
50
+ device = null,
51
+ user = null,
52
+ items = 10,
53
+ sort = 'desc',
54
+ minutes,
55
+ min_ts,
56
+ max_ts,
57
+ agg,
58
+ agg_type,
59
+ fields,
60
+ group_by,
61
+ } = {}) {
62
+ if (!bucket) throw new Error('bucket is required');
63
+ const apiUser = resolveUser(user);
64
+ const params = { items, sort };
65
+ if (minutes) {
66
+ params.min_ts = Date.now() - minutes * 60 * 1000;
67
+ params.max_ts = 0;
68
+ } else {
69
+ if (min_ts) params.min_ts = min_ts;
70
+ if (max_ts !== undefined) params.max_ts = max_ts;
71
+ }
72
+ if (agg) params.agg = agg;
73
+ if (agg_type) params.agg_type = agg_type;
74
+ if (group_by) params.group_by = group_by;
75
+ if (fields) params.fields = fields;
76
+
77
+ const url = device
78
+ ? `/v3/users/${apiUser}/devices/${device}/buckets/${bucket}/data`
79
+ : `/v2/users/${apiUser}/buckets/${bucket}/data`;
80
+
81
+ try {
82
+ const res = await api.get(url, { params });
83
+ return res.data;
84
+ } catch (e) {
85
+ throw apiError(e);
86
+ }
87
+ }