@thinremote/thinr-cli 1.0.2-alpha.2 → 1.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.md +362 -87
- package/bin/thinr.js +51 -11
- package/commands/device/_shared.js +48 -0
- package/commands/device/console.js +15 -0
- package/commands/device/exec.js +76 -0
- package/commands/device/index.js +31 -0
- package/commands/device/list.js +43 -0
- package/commands/device/property.js +50 -0
- package/commands/device/proxy.js +42 -0
- package/commands/device/resource.js +51 -0
- package/commands/device/status.js +26 -0
- package/commands/device/update.js +53 -0
- package/commands/logout.js +12 -5
- package/commands/product.js +146 -177
- package/commands/profile.js +105 -0
- package/lib/api.js +54 -31
- package/lib/auth.js +225 -309
- package/lib/config.js +176 -45
- package/lib/console.js +87 -108
- package/lib/device-api.js +257 -0
- package/lib/devices.js +69 -0
- package/lib/errors.js +53 -0
- package/lib/mcp/helpers.js +34 -0
- package/lib/mcp/registry.js +32 -0
- package/lib/mcp/server.js +63 -0
- package/lib/mcp/tools-devices.js +194 -0
- package/lib/mcp/tools-exec.js +213 -0
- package/lib/mcp/tools-filesystem.js +163 -0
- package/lib/mcp/tools-products.js +232 -0
- package/lib/mcp/tools-profiles.js +30 -0
- package/lib/mcp/tools-properties.js +79 -0
- package/lib/mcp/tools-resources.js +76 -0
- package/lib/mcp/tools-scripts.js +172 -0
- package/lib/monitoring.js +72 -0
- package/lib/output.js +114 -0
- package/lib/product.js +373 -0
- package/lib/property.js +41 -57
- package/lib/proxy.js +58 -90
- package/lib/resource.js +106 -72
- package/lib/status.js +7 -33
- package/lib/storage.js +128 -0
- package/package.json +68 -37
- package/.github/workflows/publish.yml +0 -79
- package/commands/device.js +0 -268
- package/lib/device.js +0 -76
package/commands/product.js
CHANGED
|
@@ -1,198 +1,167 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import { formatDeviceProperty, getDeviceProperty } from
|
|
5
|
-
import {
|
|
6
|
-
import { filterActiveDevices, getDevices } from
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { InvalidArgumentError } from 'commander';
|
|
3
|
+
import { configExists } from '../lib/config.js';
|
|
4
|
+
import { formatDeviceProperty, getDeviceProperty } from '../lib/property.js';
|
|
5
|
+
import { callDeviceResource } from '../lib/resource.js';
|
|
6
|
+
import { filterActiveDevices, getDevices } from '../lib/devices.js';
|
|
7
|
+
import {
|
|
8
|
+
setJsonMode,
|
|
9
|
+
isJsonMode,
|
|
10
|
+
printOk,
|
|
11
|
+
printErr,
|
|
12
|
+
createSpinner,
|
|
13
|
+
classifyError,
|
|
14
|
+
} from '../lib/output.js';
|
|
15
|
+
|
|
16
|
+
function ensureConfigured() {
|
|
17
|
+
if (!configExists()) {
|
|
18
|
+
printErr('Not configured. Run thinr without parameters to set up.', {
|
|
19
|
+
code: 'not_configured',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
7
23
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*/
|
|
12
|
-
export function productCommand(program) {
|
|
24
|
+
function applyJsonFlag(opts) {
|
|
25
|
+
if (opts.json) setJsonMode(true);
|
|
26
|
+
}
|
|
13
27
|
|
|
14
|
-
|
|
28
|
+
function getGlobalUser(cmd) {
|
|
29
|
+
let root = cmd;
|
|
30
|
+
while (root.parent) root = root.parent;
|
|
31
|
+
return root.opts().user || null;
|
|
32
|
+
}
|
|
15
33
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
.action( async (productId, options, command) => {
|
|
34
|
+
function collectInput(value, previous = {}) {
|
|
35
|
+
const idx = value.indexOf('=');
|
|
36
|
+
if (idx === -1) throw new InvalidArgumentError('must be key=value');
|
|
37
|
+
return { ...previous, [value.slice(0, idx)]: value.slice(idx + 1) };
|
|
38
|
+
}
|
|
22
39
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
async function fetchProductDevices(productId, group, user) {
|
|
41
|
+
const filter = { productId };
|
|
42
|
+
if (group) filter.asset_group = group;
|
|
43
|
+
return getDevices(filter, user);
|
|
44
|
+
}
|
|
27
45
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
/**
|
|
47
|
+
* `thinr product <subcommand> <productId> …` — fan-out commands across
|
|
48
|
+
* every device that belongs to a product.
|
|
49
|
+
*/
|
|
50
|
+
export function productCommand(program) {
|
|
51
|
+
const product = program
|
|
52
|
+
.command('product')
|
|
53
|
+
.description('Product commands (subcommand-first: thinr product <action> <productId>)');
|
|
33
54
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
55
|
+
product
|
|
56
|
+
.command('property <productId> <propertyId>')
|
|
57
|
+
.description('Read a property on every device of the product')
|
|
58
|
+
.option('-j, --json', 'Output as JSON')
|
|
59
|
+
.option('-f, --field <field>', 'Extract a sub-field from each property (dot path)')
|
|
60
|
+
.option('-a, --all', 'Include offline devices (default: only active)')
|
|
61
|
+
.option('-g, --group <group>', 'Filter devices by asset group')
|
|
62
|
+
.action(async (productId, propertyId, opts, cmd) => {
|
|
63
|
+
applyJsonFlag(opts);
|
|
64
|
+
ensureConfigured();
|
|
65
|
+
const user = getGlobalUser(cmd);
|
|
66
|
+
const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
|
|
67
|
+
let devices;
|
|
68
|
+
try {
|
|
69
|
+
devices = await fetchProductDevices(productId, opts.group, user);
|
|
70
|
+
spinner.succeed(`Devices for product ${productId} retrieved successfully`);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
spinner.fail(`Failed to retrieve devices for product ${productId}`);
|
|
73
|
+
const { message, code } = classifyError(error);
|
|
74
|
+
printErr(message, { code });
|
|
37
75
|
return;
|
|
38
76
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
if (!opts.all) devices = filterActiveDevices(devices);
|
|
78
|
+
|
|
79
|
+
const results = [];
|
|
80
|
+
for (const device of devices) {
|
|
81
|
+
try {
|
|
82
|
+
const property = await getDeviceProperty(device.device, propertyId);
|
|
83
|
+
const value = opts.field
|
|
84
|
+
? opts.field.split('.').reduce((obj, key) => obj && obj[key], property)
|
|
85
|
+
: property;
|
|
86
|
+
results.push({ device: device.device, ok: true, data: value });
|
|
87
|
+
if (!isJsonMode()) {
|
|
88
|
+
console.log(
|
|
89
|
+
'Device',
|
|
90
|
+
chalk.blue(device.device),
|
|
91
|
+
'property',
|
|
92
|
+
chalk.blue(propertyId),
|
|
93
|
+
);
|
|
94
|
+
if (opts.field) console.log(value);
|
|
95
|
+
else console.log(formatDeviceProperty(device.device, propertyId, property));
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
const { message, code } = classifyError(error);
|
|
99
|
+
results.push({ device: device.device, ok: false, error: { message, code } });
|
|
100
|
+
if (!isJsonMode()) {
|
|
101
|
+
console.error(
|
|
102
|
+
chalk.red(
|
|
103
|
+
`Error retrieving property ${propertyId} for device ${device.device}: ${message}`,
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
65
107
|
}
|
|
66
|
-
|
|
67
108
|
}
|
|
109
|
+
if (isJsonMode()) printOk({ product: productId, property: propertyId, results });
|
|
110
|
+
});
|
|
68
111
|
|
|
69
|
-
|
|
70
|
-
|
|
112
|
+
product
|
|
113
|
+
.command('resource <productId> <resource>')
|
|
114
|
+
.description('Call a resource on every active device of the product')
|
|
115
|
+
.option('-j, --json', 'Output as JSON')
|
|
116
|
+
.option('-f, --field <field>', 'Extract a sub-field from each result (dot path)')
|
|
117
|
+
.option('-g, --group <group>', 'Filter devices by asset group')
|
|
118
|
+
.option('-i, --input <key=value>', 'Resource input (repeatable)', collectInput, {})
|
|
119
|
+
.action(async (productId, resource, opts, cmd) => {
|
|
120
|
+
applyJsonFlag(opts);
|
|
121
|
+
ensureConfigured();
|
|
122
|
+
const user = getGlobalUser(cmd);
|
|
123
|
+
const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
|
|
124
|
+
let devices;
|
|
71
125
|
try {
|
|
72
|
-
|
|
73
|
-
productId: productId,
|
|
74
|
-
};
|
|
75
|
-
if (parsedOptions.group) {
|
|
76
|
-
filter.asset_group = parsedOptions.group;
|
|
77
|
-
}
|
|
78
|
-
devices = await getDevices(filter);
|
|
126
|
+
devices = await fetchProductDevices(productId, opts.group, user);
|
|
79
127
|
spinner.succeed(`Devices for product ${productId} retrieved successfully`);
|
|
80
|
-
} catch (
|
|
128
|
+
} catch (error) {
|
|
81
129
|
spinner.fail(`Failed to retrieve devices for product ${productId}`);
|
|
82
|
-
|
|
130
|
+
const { message, code } = classifyError(error);
|
|
131
|
+
printErr(message, { code });
|
|
132
|
+
return;
|
|
83
133
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
134
|
+
devices = filterActiveDevices(devices);
|
|
135
|
+
|
|
136
|
+
const results = [];
|
|
137
|
+
for (const device of devices) {
|
|
138
|
+
try {
|
|
139
|
+
const result = await callDeviceResource(device.device, resource, opts.input);
|
|
140
|
+
const value = opts.field
|
|
141
|
+
? opts.field.split('.').reduce((obj, key) => obj && obj[key], result)
|
|
142
|
+
: result;
|
|
143
|
+
results.push({ device: device.device, ok: true, data: value });
|
|
144
|
+
if (!isJsonMode()) {
|
|
145
|
+
console.log(
|
|
146
|
+
'Device',
|
|
147
|
+
chalk.blue(device.device),
|
|
148
|
+
'resource',
|
|
149
|
+
chalk.blue(resource),
|
|
150
|
+
);
|
|
151
|
+
console.log(value);
|
|
93
152
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
console.log(parsedOptions.field.split('.').reduce((obj, key) => obj && obj[key], property));
|
|
104
|
-
} else {
|
|
105
|
-
console.log(JSON.stringify(property, null, 2));
|
|
106
|
-
}
|
|
107
|
-
} else {
|
|
108
|
-
console.log(formatDeviceProperty(device.device, target, property));
|
|
109
|
-
}
|
|
110
|
-
} catch (e) {
|
|
111
|
-
console.error(chalk.red(`Error retrieving property ${target} for device ${device.device}: ${e.message}`));
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
//spinner.succeed(`Property ${target} for devices belonging to product ${productId} found`);
|
|
115
|
-
} catch (error) {
|
|
116
|
-
spinner.fail(`Property retrieval failed`);
|
|
117
|
-
console.error(chalk.red(`Error: ${error.message}`));
|
|
118
|
-
process.exit(1);
|
|
119
|
-
}
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
case 'resource': {
|
|
124
|
-
|
|
125
|
-
//const spinner = ora(`Executing resource ${target} for devices belonging to the product ${productId}...`).start();
|
|
126
|
-
|
|
127
|
-
// Obviously, we can only execute resources on active devices
|
|
128
|
-
devices = filterActiveDevices(devices);
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
for (const device of devices) {
|
|
132
|
-
try {
|
|
133
|
-
const result = await executeDeviceResource(device.device, target, parsedOptions.inputs);
|
|
134
|
-
console.log('Device', chalk.blue(device.device), 'resource', chalk.blue(target));
|
|
135
|
-
if ( parsedOptions.field ) {
|
|
136
|
-
console.log(parsedOptions.field.split('.').reduce((obj, key) => obj && obj[key], result));
|
|
137
|
-
} else {
|
|
138
|
-
console.log(result);
|
|
139
|
-
}
|
|
140
|
-
} catch (e) {
|
|
141
|
-
console.error(chalk.red(`Error retrieving property ${target} for device ${device.device}: ${e.message}`));
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
//spinner.succeed(`Resource ${target} executed successfully for devices belonging to product ${productId}`);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
} catch (error) {
|
|
148
|
-
spinner.fail(`Resource execution failed`);
|
|
149
|
-
console.error(chalk.red(`Error: ${error.message}`));
|
|
150
|
-
process.exit(1);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const { message, code } = classifyError(error);
|
|
155
|
+
results.push({ device: device.device, ok: false, error: { message, code } });
|
|
156
|
+
if (!isJsonMode()) {
|
|
157
|
+
console.error(
|
|
158
|
+
chalk.red(
|
|
159
|
+
`Error executing resource ${resource} for device ${device.device}: ${message}`,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
151
162
|
}
|
|
152
|
-
|
|
153
|
-
break;
|
|
154
|
-
|
|
155
163
|
}
|
|
156
|
-
|
|
157
|
-
default:
|
|
158
|
-
console.error(chalk.red(`Error: Unknown action '${action}'. Available actions: property, resource.\n`));
|
|
159
|
-
printHelp(productId);
|
|
160
|
-
process.exit(1);
|
|
161
|
-
|
|
162
164
|
}
|
|
165
|
+
if (isJsonMode()) printOk({ product: productId, resource, results });
|
|
163
166
|
});
|
|
164
167
|
}
|
|
165
|
-
|
|
166
|
-
function printHelp(productId) {
|
|
167
|
-
console.log(`Usage: thinr device product ${productId} <command> [options]
|
|
168
|
-
|
|
169
|
-
Available commands for device product "${productId}":
|
|
170
|
-
|
|
171
|
-
property [propertyId] Get a specific property of all devices belonging to the product
|
|
172
|
-
Example: thinr device product ${productId} property uptime
|
|
173
|
-
|
|
174
|
-
resource [resource] Execute a specific resource of all devices belonging to the product
|
|
175
|
-
Example: thinr device product ${productId} resource reboot
|
|
176
|
-
|
|
177
|
-
Arguments:
|
|
178
|
-
|
|
179
|
-
propertyId ID of the property to retrieve (for property command)
|
|
180
|
-
|
|
181
|
-
resource Name of the resource to execute (for resource command)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
Options (for all product subcommands):
|
|
185
|
-
-g, --group <group> Group of devices to filter (e.g., -g group1)
|
|
186
|
-
|
|
187
|
-
Options (for property command):
|
|
188
|
-
-j, --json Output as JSON
|
|
189
|
-
-f, --field <field> Field to extract from the property (e.g., -f data.value)
|
|
190
|
-
-a, --all Include non active devices (default: false)
|
|
191
|
-
|
|
192
|
-
Options (for resource command):
|
|
193
|
-
-j, --json Output as JSON
|
|
194
|
-
-f, --field <field> Field to extract from the property (e.g., -f data.value)
|
|
195
|
-
|
|
196
|
-
Use "thinr product device ${productId} <command> --help" for more details on each command.`);
|
|
197
|
-
|
|
198
|
-
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import {
|
|
3
|
+
listProfiles,
|
|
4
|
+
getActiveProfile,
|
|
5
|
+
getProfile,
|
|
6
|
+
setDefaultProfile,
|
|
7
|
+
deleteProfile,
|
|
8
|
+
} from '../lib/config.js';
|
|
9
|
+
import { setJsonMode, isJsonMode, printOk, printErr } from '../lib/output.js';
|
|
10
|
+
|
|
11
|
+
function applyJsonFlag(opts) {
|
|
12
|
+
if (opts.json) setJsonMode(true);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function profileCommand(program) {
|
|
16
|
+
const profile = program.command('profile').description('Manage configuration profiles');
|
|
17
|
+
|
|
18
|
+
profile
|
|
19
|
+
.command('list')
|
|
20
|
+
.description('List configured profiles')
|
|
21
|
+
.option('-j, --json', 'Output as JSON')
|
|
22
|
+
.action((opts) => {
|
|
23
|
+
applyJsonFlag(opts);
|
|
24
|
+
const names = listProfiles();
|
|
25
|
+
const active = getActiveProfile();
|
|
26
|
+
if (isJsonMode()) {
|
|
27
|
+
const entries = names.map((name) => {
|
|
28
|
+
const data = getProfile(name) || {};
|
|
29
|
+
return {
|
|
30
|
+
name,
|
|
31
|
+
active: name === active,
|
|
32
|
+
username: data.username || null,
|
|
33
|
+
server: data.server || null,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
printOk({ active, profiles: entries });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!names.length) {
|
|
40
|
+
console.log(chalk.gray('No profiles configured. Run thinr to set one up.'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
const data = getProfile(name) || {};
|
|
45
|
+
const marker = name === active ? chalk.green('* ') : ' ';
|
|
46
|
+
const detail = chalk.gray(`${data.username || ''}@${data.server || name}`);
|
|
47
|
+
console.log(`${marker}${chalk.bold(name)} ${detail}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
profile
|
|
52
|
+
.command('current')
|
|
53
|
+
.description('Print the active profile')
|
|
54
|
+
.option('-j, --json', 'Output as JSON')
|
|
55
|
+
.action((opts) => {
|
|
56
|
+
applyJsonFlag(opts);
|
|
57
|
+
const name = getActiveProfile();
|
|
58
|
+
if (isJsonMode()) {
|
|
59
|
+
printOk({ active: name });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!name) {
|
|
63
|
+
console.log(chalk.gray('No active profile.'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
console.log(name);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
profile
|
|
70
|
+
.command('use <name>')
|
|
71
|
+
.description('Set the default profile')
|
|
72
|
+
.option('-j, --json', 'Output as JSON')
|
|
73
|
+
.action((name, opts) => {
|
|
74
|
+
applyJsonFlag(opts);
|
|
75
|
+
try {
|
|
76
|
+
setDefaultProfile(name);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
printErr(error.message, { code: 'not_found' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (isJsonMode()) {
|
|
82
|
+
printOk({ active: name });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
console.log(chalk.green(`Default profile set to ${name}`));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
profile
|
|
89
|
+
.command('delete <name>')
|
|
90
|
+
.description('Remove a profile')
|
|
91
|
+
.option('-j, --json', 'Output as JSON')
|
|
92
|
+
.action((name, opts) => {
|
|
93
|
+
applyJsonFlag(opts);
|
|
94
|
+
const ok = deleteProfile(name);
|
|
95
|
+
if (!ok) {
|
|
96
|
+
printErr(`Profile not found: ${name}`, { code: 'not_found' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (isJsonMode()) {
|
|
100
|
+
printOk({ removed: name });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
console.log(chalk.green(`Removed profile ${name}`));
|
|
104
|
+
});
|
|
105
|
+
}
|
package/lib/api.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
-
import { refreshToken, getAccessToken } from './auth.js';
|
|
2
|
+
import { refreshToken, getAccessToken } from './auth.js';
|
|
3
|
+
import { readConfig } from './config.js';
|
|
3
4
|
import http from 'http';
|
|
4
5
|
import https from 'https';
|
|
5
6
|
|
|
6
7
|
const httpAgent = new http.Agent({ keepAlive: true });
|
|
8
|
+
// Accept self-signed certificates when talking to local dev servers
|
|
9
|
+
// (localhost / 127.0.0.1). Any other host still enforces full TLS
|
|
10
|
+
// validation. Toggleable via THINR_INSECURE=1 for other local setups.
|
|
7
11
|
const httpsAgent = new https.Agent({ keepAlive: true });
|
|
12
|
+
const insecureAgent = new https.Agent({ keepAlive: true, rejectUnauthorized: false });
|
|
8
13
|
|
|
9
14
|
const api = axios.create({
|
|
10
15
|
// You can leave baseURL undefined initially
|
|
@@ -17,44 +22,62 @@ export function setBaseURL(url) {
|
|
|
17
22
|
api.defaults.baseURL = url;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
api.interceptors.request.use(
|
|
25
|
+
api.interceptors.request.use(
|
|
26
|
+
(request) => {
|
|
27
|
+
const token = getAccessToken();
|
|
28
|
+
if (token) {
|
|
29
|
+
request.headers['Authorization'] = `Bearer ${token}`;
|
|
30
|
+
}
|
|
21
31
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
// Allow self-signed TLS against localhost / 127.0.0.1 or when the user
|
|
33
|
+
// opts in explicitly. Anything else keeps default certificate validation.
|
|
34
|
+
try {
|
|
35
|
+
const base = request.baseURL || api.defaults.baseURL || '';
|
|
36
|
+
const url = new URL(request.url || '', base || 'https://placeholder/');
|
|
37
|
+
const isLocal = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
38
|
+
if (url.protocol === 'https:' && (isLocal || process.env.THINR_INSECURE === '1')) {
|
|
39
|
+
request.httpsAgent = insecureAgent;
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
|
|
43
|
+
return request;
|
|
44
|
+
},
|
|
45
|
+
(error) => {
|
|
46
|
+
return Promise.reject(error);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
30
49
|
|
|
31
50
|
api.interceptors.response.use(
|
|
32
|
-
response => response,
|
|
33
|
-
async error => {
|
|
51
|
+
(response) => response,
|
|
52
|
+
async (error) => {
|
|
34
53
|
const originalRequest = error.config;
|
|
35
54
|
|
|
36
|
-
// Only
|
|
37
|
-
if (error.response?.status
|
|
38
|
-
originalRequest._retry = true;
|
|
55
|
+
// Only intervene on 401s; everything else surfaces unchanged.
|
|
56
|
+
if (error.response?.status !== 401) return Promise.reject(error);
|
|
39
57
|
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
// Don't recurse: refresh requests themselves and already-retried
|
|
59
|
+
// requests must not trigger another refresh.
|
|
60
|
+
if (originalRequest?._isRefresh || originalRequest?._retry) {
|
|
61
|
+
return Promise.reject(error);
|
|
62
|
+
}
|
|
42
63
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
64
|
+
// Tokens issued via the username/password flow have no
|
|
65
|
+
// refresh_token. Skip the refresh attempt and let the 401 reach
|
|
66
|
+
// the caller — they can re-run `thinr` to re-authenticate.
|
|
67
|
+
if (!readConfig().refresh_token) return Promise.reject(error);
|
|
46
68
|
|
|
47
|
-
|
|
48
|
-
return api(originalRequest);
|
|
49
|
-
} catch (refreshError) {
|
|
50
|
-
// Token refresh failed, reject to trigger outer catch
|
|
51
|
-
return Promise.reject(refreshError);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
69
|
+
originalRequest._retry = true;
|
|
54
70
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
try {
|
|
72
|
+
const newToken = await refreshToken();
|
|
73
|
+
// Patch only this request; subsequent requests pick up the new
|
|
74
|
+
// token from config via the request interceptor.
|
|
75
|
+
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
|
76
|
+
return api(originalRequest);
|
|
77
|
+
} catch (refreshError) {
|
|
78
|
+
return Promise.reject(refreshError);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
58
81
|
);
|
|
59
82
|
|
|
60
|
-
export default api;
|
|
83
|
+
export default api;
|