@thinger-io/cli-core 0.0.0-stage → 0.1.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 +32 -0
- package/LICENSE +22 -0
- package/README.md +79 -2
- package/lib/alarms.js +223 -0
- package/lib/api.js +91 -0
- package/lib/auth.js +499 -0
- package/lib/bucket.js +97 -0
- package/lib/cli.js +95 -0
- package/lib/commands/_shared.js +150 -0
- package/lib/commands/auth.js +96 -0
- package/lib/commands/bucket.js +98 -0
- package/lib/commands/device.js +344 -0
- package/lib/commands/product.js +293 -0
- package/lib/commands/profile.js +193 -0
- package/lib/concurrency.js +52 -0
- package/lib/config.js +287 -0
- package/lib/constants.js +31 -0
- package/lib/devices.js +97 -0
- package/lib/errors.js +93 -0
- package/lib/format.js +114 -0
- package/lib/identity.js +101 -0
- package/lib/mcp/helpers.js +18 -0
- package/lib/mcp/naming.js +44 -0
- package/lib/mcp/registry.js +91 -0
- package/lib/mcp/server.js +74 -0
- package/lib/mcp/tools-alarms.js +507 -0
- package/lib/mcp/tools-buckets.js +131 -0
- package/lib/mcp/tools-devices.js +280 -0
- package/lib/mcp/tools-products.js +405 -0
- package/lib/mcp/tools-profile.js +635 -0
- package/lib/mcp/tools-profiles.js +30 -0
- package/lib/mcp/tools-properties.js +84 -0
- package/lib/mcp/tools-resources.js +83 -0
- package/lib/mcp/tools-tokens.js +370 -0
- package/lib/output.js +115 -0
- package/lib/paths.js +32 -0
- package/lib/product.js +169 -0
- package/lib/profile.js +633 -0
- package/lib/property.js +75 -0
- package/lib/resource.js +151 -0
- package/lib/storage.js +117 -0
- package/lib/tokens.js +364 -0
- package/package.json +57 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-10-10
|
|
4
|
+
|
|
5
|
+
First version, extracted from the core of `thinr` 1.3.5 and made
|
|
6
|
+
independent of the product.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- CLI identity (`defineIdentity`): name, config folder, environment prefix,
|
|
11
|
+
login token, OAuth client, MCP server name and tool prefix, product
|
|
12
|
+
template.
|
|
13
|
+
- `runCli` with the generic commands (login, logout, profile, device, product,
|
|
14
|
+
bucket, mcp) and extension points: `register` with the command groups,
|
|
15
|
+
`onNoCommand`, `mcpTools`, `mcpInstructions`.
|
|
16
|
+
- MCP tools declared with bare names and `{tool:x}` / `{family:x}`
|
|
17
|
+
references, expanded and validated when the server starts; CLI tools can
|
|
18
|
+
replace generic ones explicitly (`replaces: true`).
|
|
19
|
+
- `device create` / `device_create`, `bucket list` / `buckets`.
|
|
20
|
+
|
|
21
|
+
### Changed (compared with thinr 1.3.5)
|
|
22
|
+
|
|
23
|
+
- The API base URL and token come from the profile active for each request,
|
|
24
|
+
and the MCP `profile` argument is scoped per call (AsyncLocalStorage).
|
|
25
|
+
- Servers may carry a scheme (`http://localhost:8080`); profiles are named
|
|
26
|
+
after the host.
|
|
27
|
+
- `logout` forgets the current profile (or `--all`) and revokes the API token
|
|
28
|
+
the CLI minted, instead of deleting every profile.
|
|
29
|
+
- Login works without prompts (`--server --username --token`) and fails with
|
|
30
|
+
a clear message when a prompt has no terminal.
|
|
31
|
+
- The config file is written with mode 600.
|
|
32
|
+
- 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
|
-
#
|
|
1
|
+
# @thinger-io/cli-core
|
|
2
2
|
|
|
3
|
-
|
|
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;
|