@thinremote/thinr-cli 1.2.0-alpha.1 → 1.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +192 -16
  3. package/bin/thinr.js +23 -24
  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 +30 -17
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +199 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +12 -4
  51. package/lib/constants.js +76 -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 +109 -147
  57. package/lib/devices.js +18 -21
  58. package/lib/fleet-upgrade.js +106 -0
  59. package/lib/format.js +114 -0
  60. package/lib/mcp/registry.js +14 -0
  61. package/lib/mcp/tools-alarms.js +510 -0
  62. package/lib/mcp/tools-buckets.js +103 -0
  63. package/lib/mcp/tools-devices.js +33 -3
  64. package/lib/mcp/tools-exec.js +30 -17
  65. package/lib/mcp/tools-filesystem.js +102 -6
  66. package/lib/mcp/tools-install.js +153 -0
  67. package/lib/mcp/tools-logs.js +225 -0
  68. package/lib/mcp/tools-playbook.js +211 -0
  69. package/lib/mcp/tools-products.js +1226 -105
  70. package/lib/mcp/tools-profile.js +634 -0
  71. package/lib/mcp/tools-tokens.js +371 -0
  72. package/lib/monitoring.js +26 -1
  73. package/lib/playbook/actions.js +447 -0
  74. package/lib/playbook/expression.js +302 -0
  75. package/lib/playbook/fleet.js +175 -0
  76. package/lib/playbook/loader.js +247 -0
  77. package/lib/playbook/runner.js +300 -0
  78. package/lib/playbook/schema.js +209 -0
  79. package/lib/playbook/vars.js +209 -0
  80. package/lib/product/log-presets.js +196 -0
  81. package/lib/product/logs.js +485 -0
  82. package/lib/product-orchestrator.js +124 -0
  83. package/lib/product.js +523 -20
  84. package/lib/profile.js +665 -0
  85. package/lib/property.js +7 -40
  86. package/lib/proxy.js +9 -8
  87. package/lib/resource.js +10 -14
  88. package/lib/status.js +78 -37
  89. package/lib/tokens.js +374 -0
  90. package/package.json +14 -3
  91. 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,19 +1,15 @@
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
@@ -134,9 +130,26 @@ async function authenticateWithUserPass() {
134
130
  * when the OAuth server rejects the refresh — both cases mean the
135
131
  * caller has to re-authenticate.
136
132
  *
133
+ * Concurrent callers share a single in-flight refresh: refresh tokens
134
+ * are rotated on use, so issuing two refreshes in parallel makes the
135
+ * second one fail against an already-consumed token. The shared
136
+ * promise lets every awaiter see the same result and lets the next
137
+ * caller (after completion) start a new refresh against the freshly
138
+ * persisted token.
139
+ *
137
140
  * @returns {Promise<string>} The new access token.
138
141
  */
142
+ let inFlightRefresh = null;
143
+
139
144
  export async function refreshToken() {
145
+ if (inFlightRefresh) return inFlightRefresh;
146
+ inFlightRefresh = doRefresh().finally(() => {
147
+ inFlightRefresh = null;
148
+ });
149
+ return inFlightRefresh;
150
+ }
151
+
152
+ async function doRefresh() {
140
153
  const config = readConfig();
141
154
  if (!config.refresh_token) {
142
155
  const err = new Error('No refresh token available; re-authenticate with `thinr`.');
@@ -179,7 +192,7 @@ export async function deleteToken() {
179
192
  try {
180
193
  const config = readConfig();
181
194
  if (!config || !config.username || !config.server || !config.token) {
182
- console.log(chalk.yellow('No configuration found'));
195
+ console.log(warning('No configuration found'));
183
196
  return;
184
197
  }
185
198
 
@@ -193,11 +206,11 @@ export async function deleteToken() {
193
206
  spinner.succeed('Token was already deleted');
194
207
  } else {
195
208
  spinner.fail('Failed to delete token');
196
- console.error(chalk.red(`Error: ${error.message}`));
209
+ console.error(errorStyle(`Error: ${error.message}`));
197
210
  }
198
211
  }
199
212
  } catch (error) {
200
- console.error(chalk.red(`Error: ${error.message}`));
213
+ console.error(errorStyle(`Error: ${error.message}`));
201
214
  }
202
215
  }
203
216
 
@@ -258,9 +271,9 @@ async function authenticateWithOAuth() {
258
271
  throw error;
259
272
  }
260
273
 
261
- console.log('\nYour one time device code is: ' + chalk.bold(data.user_code));
274
+ console.log('\nYour one time device code is: ' + label(data.user_code));
262
275
  console.log(
263
- chalk.blue('Press ' + chalk.bold('ENTER')) +
276
+ info('Press ' + label('ENTER')) +
264
277
  ' to open your browser or submit your device code here: ' +
265
278
  data.verification_uri +
266
279
  '\n',
@@ -342,9 +355,9 @@ async function authenticateWithOAuth() {
342
355
  },
343
356
  ).catch((error) => {
344
357
  if (error.cause === 'timeout') {
345
- console.log(chalk.yellow('Polling aborted due to timeout'));
358
+ console.log(warning('Polling aborted due to timeout'));
346
359
  } else if (error.cause === 'validated') {
347
- console.log(chalk.green('Device authenticated successfully'));
360
+ console.log(success('Device authenticated successfully'));
348
361
  }
349
362
  // 'error' cause falls through; we surface pollError below.
350
363
  });
@@ -362,7 +375,7 @@ async function authenticateWithOAuth() {
362
375
  const username = decodedJWT.usr;
363
376
 
364
377
  mergeConfig({ username, token, refresh_token });
365
- console.log(chalk.green('OAuth token validation successful\n'));
378
+ console.log(success('OAuth token validation successful\n'));
366
379
  }
367
380
 
368
381
  /**
@@ -372,7 +385,7 @@ async function authenticateWithOAuth() {
372
385
  export function getAccessToken() {
373
386
  const config = readConfig();
374
387
  if (!config || !config.token) {
375
- console.log(chalk.yellow('No token found in configuration'));
388
+ console.log(warning('No token found in configuration'));
376
389
  return null;
377
390
  }
378
391
  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
+ }