@thinremote/thinr-cli 1.2.0 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +60 -31
- package/bin/thinr.js +20 -1
- package/lib/auth.js +38 -3
- package/lib/command-stream.js +23 -9
- package/lib/console.js +7 -1
- package/lib/constants.js +7 -0
- package/lib/device-api.js +36 -15
- package/lib/errors.js +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.0] - 2026-09-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `thinr login` — re-authenticate the current profile in place, without
|
|
8
|
+
deleting and recreating it. Renews an expired session in one step and
|
|
9
|
+
defaults the server prompt to the profile's existing server.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Session-expiry errors now point to the working command: "Session
|
|
14
|
+
expired. Run `thinr login` to sign in again." (previously suggested
|
|
15
|
+
`thinr`, which opens the dashboard instead of re-authenticating).
|
|
16
|
+
- More robust token refresh for WebSocket sessions: the interactive
|
|
17
|
+
console and streaming `exec` now refresh an expired access token
|
|
18
|
+
before the handshake and retry once on a 401, matching the
|
|
19
|
+
refresh-on-401 behaviour of HTTP calls.
|
|
20
|
+
|
|
3
21
|
## [1.2.0] - 2026-06-10
|
|
4
22
|
|
|
5
23
|
### Added
|
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@ Two operating modes:
|
|
|
12
12
|
|
|
13
13
|
- **CLI** — interactive or scripted device management from a shell.
|
|
14
14
|
- **MCP server** — a stdio [Model Context Protocol][mcp] server that
|
|
15
|
-
exposes the same operations as typed tools for AI clients like
|
|
16
|
-
Claude Code.
|
|
15
|
+
exposes the same operations as 80+ typed tools for AI clients like
|
|
16
|
+
Claude Code, Claude Desktop and Cursor.
|
|
17
17
|
|
|
18
18
|
Both modes share the same auth, profiles, and device API layer, so
|
|
19
19
|
you configure the tool once and use it in whichever mode fits the
|
|
@@ -39,6 +39,7 @@ task at hand.
|
|
|
39
39
|
- [Authoring playbooks from natural language](#authoring-playbooks-from-natural-language)
|
|
40
40
|
- [Per-call controls](#per-call-controls)
|
|
41
41
|
- [Integrating with Claude Code](#integrating-with-claude-code)
|
|
42
|
+
- [Integrating with Claude Desktop, Cursor and other MCP hosts](#integrating-with-claude-desktop-cursor-and-other-mcp-hosts)
|
|
42
43
|
- [Profiles and multi-account use](#profiles-and-multi-account-use)
|
|
43
44
|
- [Development](#development)
|
|
44
45
|
- [License](#license)
|
|
@@ -428,44 +429,53 @@ Other profiles are left untouched.
|
|
|
428
429
|
|
|
429
430
|
## Part 2 — MCP server
|
|
430
431
|
|
|
431
|
-
`thinr` ships an [MCP][mcp] server that exposes the CLI's device
|
|
432
|
-
operations as a set of typed tools. AI clients (Claude Code,
|
|
433
|
-
Desktop, and any other MCP-compatible host) can
|
|
434
|
-
run shell commands, read/write files, call resources,
|
|
435
|
-
products
|
|
436
|
-
|
|
432
|
+
`thinr` ships an [MCP][mcp] server that exposes the CLI's device and
|
|
433
|
+
fleet operations as a set of 80+ typed tools. AI clients (Claude Code,
|
|
434
|
+
Claude Desktop, Cursor, and any other MCP-compatible host) can list
|
|
435
|
+
devices, run shell commands, read/write files, call resources, manage
|
|
436
|
+
products, author and roll out playbooks, triage alarms and rotate
|
|
437
|
+
access tokens through the same API the CLI uses — with stable schemas
|
|
438
|
+
and a consistent error contract.
|
|
437
439
|
|
|
438
440
|
### Starting the server
|
|
439
441
|
|
|
440
442
|
```bash
|
|
441
|
-
thinr mcp
|
|
443
|
+
thinr mcp
|
|
442
444
|
```
|
|
443
445
|
|
|
444
|
-
The server speaks [MCP over stdio][mcp-stdio].
|
|
445
|
-
|
|
446
|
-
|
|
446
|
+
The server speaks [MCP over stdio][mcp-stdio]. Every tool call carries
|
|
447
|
+
its own `device`, `user` and `profile` arguments, so a single running
|
|
448
|
+
server handles any device of any configured environment without a
|
|
449
|
+
restart.
|
|
447
450
|
|
|
448
451
|
[mcp-stdio]: https://modelcontextprotocol.io/specification/server/transport#stdio
|
|
449
452
|
|
|
450
453
|
### Tool catalog
|
|
451
454
|
|
|
452
|
-
Grouped by capability. Every tool accepts optional `device
|
|
453
|
-
and `profile` arguments, so a single session can
|
|
454
|
-
device
|
|
455
|
-
|
|
456
|
-
| Area
|
|
457
|
-
|
|
|
458
|
-
| Discovery
|
|
459
|
-
|
|
|
460
|
-
|
|
|
461
|
-
|
|
|
462
|
-
|
|
|
463
|
-
|
|
|
464
|
-
|
|
|
465
|
-
|
|
|
466
|
-
|
|
|
467
|
-
|
|
|
468
|
-
|
|
|
455
|
+
Grouped by capability. Every tool accepts optional `device` (when
|
|
456
|
+
relevant), `user` and `profile` arguments, so a single session can
|
|
457
|
+
target any device, account or environment without restart.
|
|
458
|
+
|
|
459
|
+
| Area | Tools |
|
|
460
|
+
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
461
|
+
| Discovery | `thinr_devices` (with optional `query` for regex filtering), `thinr_device_info`, `thinr_profiles` |
|
|
462
|
+
| Provisioning and lifecycle | `thinr_agent_install_command`, `thinr_device_set_product`, `thinr_device_delete` |
|
|
463
|
+
| Shell | `thinr_exec` (buffered), with streaming stdout/stderr |
|
|
464
|
+
| Filesystem | `thinr_read`, `thinr_write`, `thinr_push`, `thinr_pull`, `thinr_ls`, `thinr_mkdir`, `thinr_rm`, `thinr_mv` |
|
|
465
|
+
| Resources | `thinr_resource_list` (with `in`/`out` schemas), `thinr_resource_call` |
|
|
466
|
+
| Properties (device) | `thinr_property_get`, `thinr_property_set` |
|
|
467
|
+
| Scripts (device) | `thinr_script_list`, `thinr_script_write`, `thinr_script_delete` |
|
|
468
|
+
| Monitoring and agent updates | `thinr_monitoring`, `thinr_bucket_read`, `thinr_update` |
|
|
469
|
+
| Alarms | `thinr_alarm_instances`, `thinr_alarm_instance_stats`, `thinr_alarm_instance_get`, `thinr_alarm_instance_update`, `thinr_alarm_instance_delete`, `thinr_alarm_rules`, `thinr_alarm_rule_read/write/delete` |
|
|
470
|
+
| Access tokens | User: `thinr_token_list`, `thinr_token_get`, `thinr_token_create`, `thinr_token_update`, `thinr_token_delete`. Device: `thinr_device_token_list`, `thinr_device_token_create`, `thinr_device_token_delete` |
|
|
471
|
+
| Products | `thinr_products`, `thinr_product_create`, `thinr_product_update`, `thinr_product_delete`, `thinr_product_exec`, `thinr_product_write` |
|
|
472
|
+
| Product scripts | `thinr_product_script_list`, `thinr_product_script_read`, `thinr_product_script_write`, `thinr_product_script_delete` |
|
|
473
|
+
| Product properties | `thinr_product_property_get`, `thinr_product_property_set`, `thinr_product_property_delete` |
|
|
474
|
+
| Product profile schema | `thinr_product_profile_api_{list,get,set,delete}`, `thinr_product_profile_bucket_{list,get,set,delete}`, `thinr_product_profile_property_{list,get,set,delete}` |
|
|
475
|
+
| Product log sources | `thinr_product_logs_list`, `thinr_product_logs_add`, `thinr_product_logs_remove`, `thinr_product_logs_presets`, `thinr_product_logs_set_default` |
|
|
476
|
+
| Product metrics | `thinr_product_metric_list`, `thinr_product_metric_set`, `thinr_product_metric_delete` |
|
|
477
|
+
| Playbooks (authoring) | `thinr_playbook_schema`, `thinr_playbook_validate`, `thinr_playbook_run` (ad-hoc) |
|
|
478
|
+
| Product playbooks | `thinr_product_playbook_list`, `thinr_product_playbook_read`, `thinr_product_playbook_write`, `thinr_product_playbook_run` (single device), `thinr_product_playbook_rollout` (fleet), `thinr_product_playbook_delete` |
|
|
469
479
|
|
|
470
480
|
Full input/output schemas are published via standard MCP
|
|
471
481
|
`list_tools`; the client will show them when you connect.
|
|
@@ -557,8 +567,27 @@ That's it — a single MCP server entry handles every device of the
|
|
|
557
567
|
active profile. Tool calls pass `device` (and optionally `user` /
|
|
558
568
|
`profile`) per call.
|
|
559
569
|
|
|
560
|
-
|
|
561
|
-
|
|
570
|
+
### Integrating with Claude Desktop, Cursor and other MCP hosts
|
|
571
|
+
|
|
572
|
+
Other MCP hosts use a JSON config file (`claude_desktop_config.json`,
|
|
573
|
+
`.cursor/mcp.json`, etc.). The entry shape is the same on every host:
|
|
574
|
+
|
|
575
|
+
```json
|
|
576
|
+
{
|
|
577
|
+
"mcpServers": {
|
|
578
|
+
"thinr": {
|
|
579
|
+
"command": "thinr",
|
|
580
|
+
"args": ["mcp"]
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
The `thinr` binary must be on `PATH` for the host to spawn it —
|
|
587
|
+
`npm install -g @thinremote/thinr-cli` puts it there. If the host
|
|
588
|
+
runs under a shell that doesn't load your global npm bin
|
|
589
|
+
(common on macOS GUI apps), point `command` at the absolute path
|
|
590
|
+
(`which thinr`) instead.
|
|
562
591
|
|
|
563
592
|
---
|
|
564
593
|
|
package/bin/thinr.js
CHANGED
|
@@ -6,7 +6,7 @@ import { dirname, resolve } from 'path';
|
|
|
6
6
|
import { Command } from 'commander';
|
|
7
7
|
import figlet from 'figlet';
|
|
8
8
|
import { accent, info } from '../lib/format.js';
|
|
9
|
-
import { configExists, readConfig, setActiveProfile } from '../lib/config.js';
|
|
9
|
+
import { configExists, getActiveProfile, readConfig, setActiveProfile } from '../lib/config.js';
|
|
10
10
|
import { detectJsonModeFromArgv } from '../lib/output.js';
|
|
11
11
|
|
|
12
12
|
// Single source of truth for the version: package.json. Avoids the drift
|
|
@@ -64,6 +64,25 @@ playbookCommand(program);
|
|
|
64
64
|
profileCommand(program);
|
|
65
65
|
logoutCommand(program);
|
|
66
66
|
|
|
67
|
+
program
|
|
68
|
+
.command('login')
|
|
69
|
+
.helpGroup('Profile:')
|
|
70
|
+
.description('Re-authenticate the current profile (renew an expired session)')
|
|
71
|
+
.action(async () => {
|
|
72
|
+
try {
|
|
73
|
+
// Pin the target to the active/default profile by name so the
|
|
74
|
+
// refreshed credentials overwrite it in place, even when the
|
|
75
|
+
// profile name differs from the server hostname.
|
|
76
|
+
const name = getActiveProfile();
|
|
77
|
+
if (name) setActiveProfile(name);
|
|
78
|
+
await authenticate();
|
|
79
|
+
console.log(info('Signed in.'));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(`Error: ${error.message}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
67
86
|
program
|
|
68
87
|
.command('mcp')
|
|
69
88
|
.helpGroup('Integration:')
|
package/lib/auth.js
CHANGED
|
@@ -16,6 +16,10 @@ const OAUTH_DEVICE_CODE_TIMEOUT_MS = TIMEOUTS.OAUTH_DEVICE_CODE_MS;
|
|
|
16
16
|
* @returns {Promise<void>}
|
|
17
17
|
*/
|
|
18
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
|
+
|
|
19
23
|
// Ask for authentication method
|
|
20
24
|
const method = await select({
|
|
21
25
|
message: 'How would you like to authenticate?',
|
|
@@ -29,7 +33,7 @@ export async function authenticate() {
|
|
|
29
33
|
// Ask for server URL
|
|
30
34
|
const server = await input({
|
|
31
35
|
message: 'Enter server URL:',
|
|
32
|
-
default: 'console.thinr.io',
|
|
36
|
+
default: existing?.server || 'console.thinr.io',
|
|
33
37
|
validate: (input) => {
|
|
34
38
|
return input ? true : 'Server URL is required';
|
|
35
39
|
},
|
|
@@ -152,7 +156,7 @@ export async function refreshToken() {
|
|
|
152
156
|
async function doRefresh() {
|
|
153
157
|
const config = readConfig();
|
|
154
158
|
if (!config.refresh_token) {
|
|
155
|
-
const err = new Error('
|
|
159
|
+
const err = new Error('Session expired. Run `thinr login` to sign in again.');
|
|
156
160
|
err.code = 'unauthorized';
|
|
157
161
|
throw err;
|
|
158
162
|
}
|
|
@@ -172,7 +176,7 @@ async function doRefresh() {
|
|
|
172
176
|
_isRefresh: true,
|
|
173
177
|
});
|
|
174
178
|
} catch (_error) {
|
|
175
|
-
const err = new Error('
|
|
179
|
+
const err = new Error('Session expired. Run `thinr login` to sign in again.');
|
|
176
180
|
err.code = 'unauthorized';
|
|
177
181
|
throw err;
|
|
178
182
|
}
|
|
@@ -184,6 +188,37 @@ async function doRefresh() {
|
|
|
184
188
|
return response.data.access_token;
|
|
185
189
|
}
|
|
186
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
|
+
|
|
187
222
|
/**
|
|
188
223
|
* Delete user token from server
|
|
189
224
|
* @returns {Promise<void>}
|
package/lib/command-stream.js
CHANGED
|
@@ -69,8 +69,7 @@ export class CommandStream {
|
|
|
69
69
|
this.cancelled = true;
|
|
70
70
|
if (
|
|
71
71
|
this.ws &&
|
|
72
|
-
(this.ws.readyState === WebSocket.OPEN ||
|
|
73
|
-
this.ws.readyState === WebSocket.CONNECTING)
|
|
72
|
+
(this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
|
|
74
73
|
) {
|
|
75
74
|
this.ws.close();
|
|
76
75
|
}
|
|
@@ -100,11 +99,14 @@ export class CommandStream {
|
|
|
100
99
|
_armSafety() {
|
|
101
100
|
const { timeout } = this.opts;
|
|
102
101
|
if (!timeout || timeout <= 0) return;
|
|
103
|
-
this.safetyTimer = setTimeout(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
this.safetyTimer = setTimeout(
|
|
103
|
+
() => {
|
|
104
|
+
if (this.resolved) return;
|
|
105
|
+
this.timedOut = true;
|
|
106
|
+
this._finish();
|
|
107
|
+
},
|
|
108
|
+
timeout * 1000 + TIMEOUTS.EXEC_SAFETY_GRACE_MS,
|
|
109
|
+
);
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
_disarmSafety() {
|
|
@@ -116,6 +118,19 @@ export class CommandStream {
|
|
|
116
118
|
|
|
117
119
|
_wireEvents(ws) {
|
|
118
120
|
ws.on('open', () => this._forwardStdin(ws));
|
|
121
|
+
ws.on('unexpected-response', (req, res) => {
|
|
122
|
+
// Handshake rejected before the upgrade (401 expired token,
|
|
123
|
+
// 404 unknown device, ...). Tag the HTTP status so callers
|
|
124
|
+
// can react to it; execStream() refreshes and retries on 401.
|
|
125
|
+
req.destroy();
|
|
126
|
+
if (this.cancelled || this.resolved) return;
|
|
127
|
+
this._disarmSafety();
|
|
128
|
+
const err = /** @type {Error & { statusCode: number }} */ (
|
|
129
|
+
new Error(`Unexpected server response: ${res.statusCode}`)
|
|
130
|
+
);
|
|
131
|
+
err.statusCode = res.statusCode;
|
|
132
|
+
this._reject?.(err);
|
|
133
|
+
});
|
|
119
134
|
ws.on('error', (err) => {
|
|
120
135
|
if (this.cancelled || this.resolved) return;
|
|
121
136
|
this._disarmSafety();
|
|
@@ -176,8 +191,7 @@ export class CommandStream {
|
|
|
176
191
|
});
|
|
177
192
|
if (
|
|
178
193
|
this.ws &&
|
|
179
|
-
(this.ws.readyState === WebSocket.OPEN ||
|
|
180
|
-
this.ws.readyState === WebSocket.CONNECTING)
|
|
194
|
+
(this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
|
|
181
195
|
) {
|
|
182
196
|
this.ws.terminate();
|
|
183
197
|
}
|
package/lib/console.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
2
|
import { requireConfig } from './config.js';
|
|
3
|
+
import { ensureFreshToken } from './auth.js';
|
|
3
4
|
import api from './api.js';
|
|
4
5
|
import { warning, error as errorStyle } from './format.js';
|
|
5
6
|
|
|
@@ -18,10 +19,15 @@ import { warning, error as errorStyle } from './format.js';
|
|
|
18
19
|
export async function connectToDeviceConsole(deviceId) {
|
|
19
20
|
const config = requireConfig();
|
|
20
21
|
|
|
22
|
+
// WS handshakes lack the refresh-on-401 retry that api.js gives
|
|
23
|
+
// HTTP requests: start from a valid token (no-op for permanent
|
|
24
|
+
// tokens, which carry no expiry).
|
|
25
|
+
const token = (await ensureFreshToken()) || config.token;
|
|
26
|
+
|
|
21
27
|
const sessionId = createUUID();
|
|
22
28
|
const wsUrl = `wss://${config.server}/v3/users/${config.username}/devices/${deviceId}/resources/$terminal/${sessionId}?raw=1`;
|
|
23
29
|
const ws = new WebSocket(wsUrl, {
|
|
24
|
-
headers: { Authorization: `Bearer ${
|
|
30
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
25
31
|
});
|
|
26
32
|
|
|
27
33
|
let terminalRestored = false;
|
package/lib/constants.js
CHANGED
|
@@ -12,6 +12,13 @@ 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
|
+
* Refresh a JWT access token this close to (or past) its `exp`
|
|
17
|
+
* before opening a WebSocket. WS handshakes lack the
|
|
18
|
+
* refresh-on-401 retry that api.js gives HTTP requests.
|
|
19
|
+
*/
|
|
20
|
+
TOKEN_EXPIRY_SLACK_MS: 30_000,
|
|
21
|
+
|
|
15
22
|
/** POSTs to device resources (cmd, update, etc.) — mid-weight calls. */
|
|
16
23
|
DEVICE_RESOURCE_CALL_MS: 30_000,
|
|
17
24
|
|
package/lib/device-api.js
CHANGED
|
@@ -2,6 +2,7 @@ import api from './api.js';
|
|
|
2
2
|
import { requireConfig } from './config.js';
|
|
3
3
|
import { BYTES, TIMEOUTS } from './constants.js';
|
|
4
4
|
import { CommandStream } from './command-stream.js';
|
|
5
|
+
import { ensureFreshToken, refreshToken } from './auth.js';
|
|
5
6
|
import { Readable } from 'node:stream';
|
|
6
7
|
|
|
7
8
|
// Directory endpoints on the agent expect a trailing slash so it can
|
|
@@ -166,7 +167,11 @@ export function createDeviceAPI(deviceId, options = {}) {
|
|
|
166
167
|
},
|
|
167
168
|
|
|
168
169
|
// POST /v3/users/{user}/devices/{device}/resources/{resource}
|
|
169
|
-
async callResource(
|
|
170
|
+
async callResource(
|
|
171
|
+
resource,
|
|
172
|
+
payload = null,
|
|
173
|
+
{ timeout = TIMEOUTS.DEVICE_RESOURCE_CALL_MS } = {},
|
|
174
|
+
) {
|
|
170
175
|
const url = `${resourcePath}/${resource}`;
|
|
171
176
|
const response = await api.post(url, payload, { timeout });
|
|
172
177
|
return response.data;
|
|
@@ -197,23 +202,39 @@ export function createDeviceAPI(deviceId, options = {}) {
|
|
|
197
202
|
* Preferred path for the CLI: the server detects WS close and stops
|
|
198
203
|
* the device-side stream automatically. Framing, timeout and
|
|
199
204
|
* cancellation live in `CommandStream` (lib/command-stream.js).
|
|
205
|
+
*
|
|
206
|
+
* Auth: the WS handshake has no refresh-on-401 interceptor like
|
|
207
|
+
* HTTP calls do, so the token is refreshed up front when its JWT
|
|
208
|
+
* is already expired, and a handshake 401 gets one explicit
|
|
209
|
+
* refresh-and-retry.
|
|
200
210
|
*/
|
|
201
|
-
execStream(cmd, { timeout = 0, onStdout, onStderr, onCancel, stdin } = {}) {
|
|
211
|
+
async execStream(cmd, { timeout = 0, onStdout, onStderr, onCancel, stdin } = {}) {
|
|
202
212
|
const isLocal = config.server === 'localhost' || config.server === '127.0.0.1';
|
|
203
213
|
const insecure = isLocal || process.env.THINR_INSECURE === '1';
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
214
|
+
const run = (token) =>
|
|
215
|
+
new CommandStream({
|
|
216
|
+
server: config.server,
|
|
217
|
+
token,
|
|
218
|
+
apiUser,
|
|
219
|
+
deviceId,
|
|
220
|
+
insecure,
|
|
221
|
+
cmd,
|
|
222
|
+
timeout,
|
|
223
|
+
onStdout,
|
|
224
|
+
onStderr,
|
|
225
|
+
onCancel,
|
|
226
|
+
stdin,
|
|
227
|
+
}).run();
|
|
228
|
+
try {
|
|
229
|
+
return await run((await ensureFreshToken()) || config.token);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// A token that decodes as still-valid can be rejected
|
|
232
|
+
// anyway (revoked, clock skew): mirror the api.js
|
|
233
|
+
// interceptor and retry the handshake once after an
|
|
234
|
+
// explicit refresh.
|
|
235
|
+
if (error?.statusCode !== 401) throw error;
|
|
236
|
+
return run(await refreshToken());
|
|
237
|
+
}
|
|
217
238
|
},
|
|
218
239
|
};
|
|
219
240
|
}
|
package/lib/errors.js
CHANGED
|
@@ -7,6 +7,18 @@
|
|
|
7
7
|
* that names the missing entity (e.g. "Device not found: abc").
|
|
8
8
|
*/
|
|
9
9
|
export function apiError(error, messages = {}) {
|
|
10
|
+
// Errors normalized upstream (the tagged 'unauthorized' from a failed
|
|
11
|
+
// token refresh, inputError, ...) carry a stable string code and no
|
|
12
|
+
// axios envelope: pass them through so the code survives.
|
|
13
|
+
if (
|
|
14
|
+
error &&
|
|
15
|
+
typeof error.code === 'string' &&
|
|
16
|
+
!error.isAxiosError &&
|
|
17
|
+
!error.response &&
|
|
18
|
+
!error.request
|
|
19
|
+
) {
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
10
22
|
if (error && error.response) {
|
|
11
23
|
const status = error.response.status;
|
|
12
24
|
if (status === 404) {
|