@thinremote/thinr-cli 1.0.2 → 1.1.0-alpha.2
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 +67 -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/lib/output.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import ora from 'ora';
|
|
2
|
+
|
|
3
|
+
let jsonMode = false;
|
|
4
|
+
|
|
5
|
+
export function setJsonMode(on) {
|
|
6
|
+
jsonMode = !!on;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isJsonMode() {
|
|
10
|
+
return jsonMode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Detect --json / -j in process.argv as early as possible so every
|
|
15
|
+
* subcommand can rely on the mode being set before it runs its own
|
|
16
|
+
* parser. Mirrors the early --profile scan in bin/thinr.js.
|
|
17
|
+
*/
|
|
18
|
+
export function detectJsonModeFromArgv(argv = process.argv) {
|
|
19
|
+
for (let i = 2; i < argv.length; i++) {
|
|
20
|
+
const a = argv[i];
|
|
21
|
+
if (a === '--json' || a === '-j') return setJsonMode(true);
|
|
22
|
+
if (a === '--') return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Print a successful result. In JSON mode, writes a single line with the
|
|
28
|
+
* canonical envelope to stdout. Otherwise the caller is expected to have
|
|
29
|
+
* already printed a human-readable rendering.
|
|
30
|
+
*/
|
|
31
|
+
export function printOk(data) {
|
|
32
|
+
if (!jsonMode) return;
|
|
33
|
+
process.stdout.write(JSON.stringify({ ok: true, data: data ?? null }, null, 2) + '\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Print an error envelope and exit. In JSON mode writes the envelope to
|
|
38
|
+
* stdout so scripts can pipe it to jq; in human mode writes a red error
|
|
39
|
+
* line to stderr. Always exits with the given code (default 1).
|
|
40
|
+
*/
|
|
41
|
+
export function printErr(message, { code = 'error', exitCode = 1 } = {}) {
|
|
42
|
+
if (jsonMode) {
|
|
43
|
+
process.stdout.write(
|
|
44
|
+
JSON.stringify({ ok: false, error: { message: String(message), code } }, null, 2) +
|
|
45
|
+
'\n',
|
|
46
|
+
);
|
|
47
|
+
} else {
|
|
48
|
+
process.stderr.write(`Error [${code}]: ${message}\n`);
|
|
49
|
+
}
|
|
50
|
+
process.exit(exitCode);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a spinner that becomes a no-op when JSON mode is active. Keeps
|
|
55
|
+
* call-sites symmetrical: spinner.start(), spinner.succeed(), spinner.fail()
|
|
56
|
+
* all work whether JSON mode is on or off.
|
|
57
|
+
*/
|
|
58
|
+
export function createSpinner(text) {
|
|
59
|
+
if (jsonMode) {
|
|
60
|
+
return {
|
|
61
|
+
start() {
|
|
62
|
+
return this;
|
|
63
|
+
},
|
|
64
|
+
stop() {
|
|
65
|
+
return this;
|
|
66
|
+
},
|
|
67
|
+
succeed() {
|
|
68
|
+
return this;
|
|
69
|
+
},
|
|
70
|
+
fail() {
|
|
71
|
+
return this;
|
|
72
|
+
},
|
|
73
|
+
set text(_) {},
|
|
74
|
+
get text() {
|
|
75
|
+
return '';
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return ora(text);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Map an error from api.js / axios into a stable { message, code } pair.
|
|
84
|
+
* Centralized here so every command reports the same codes in JSON mode.
|
|
85
|
+
*/
|
|
86
|
+
export function classifyError(error) {
|
|
87
|
+
// Preferred path: library functions use apiError() in lib/errors.js to
|
|
88
|
+
// attach a .code before throwing. Fall back to the raw-axios mapping
|
|
89
|
+
// only when something throws without going through that helper.
|
|
90
|
+
if (error && typeof error.code === 'string' && error.message) {
|
|
91
|
+
return { code: error.code, message: error.message };
|
|
92
|
+
}
|
|
93
|
+
if (error && error.response) {
|
|
94
|
+
const status = error.response.status;
|
|
95
|
+
if (status === 401 || status === 403)
|
|
96
|
+
return {
|
|
97
|
+
code: 'unauthorized',
|
|
98
|
+
message: 'Unauthorized. Your token may have expired. Please reconfigure.',
|
|
99
|
+
};
|
|
100
|
+
if (status === 404)
|
|
101
|
+
return { code: 'not_found', message: error.message || 'Resource not found' };
|
|
102
|
+
return {
|
|
103
|
+
code: 'server_error',
|
|
104
|
+
message: `Server error: ${status} ${error.response.statusText || ''}`.trim(),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (error && error.request) {
|
|
108
|
+
return {
|
|
109
|
+
code: 'network_error',
|
|
110
|
+
message: 'No response from server. Please check your connection.',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { code: 'error', message: error?.message || String(error) };
|
|
114
|
+
}
|
package/lib/product.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import api from './api.js';
|
|
2
|
+
import { apiError, inputError } from './errors.js';
|
|
3
|
+
import { requireConfig } from './config.js';
|
|
4
|
+
import {
|
|
5
|
+
storageExists,
|
|
6
|
+
createStorage,
|
|
7
|
+
deleteStorage,
|
|
8
|
+
listStorageFiles,
|
|
9
|
+
readStorageFile,
|
|
10
|
+
storageFileExists,
|
|
11
|
+
writeStorageFile,
|
|
12
|
+
deleteStorageFile,
|
|
13
|
+
} from './storage.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Helpers for ThinRemote products and the product-script workflow.
|
|
17
|
+
*
|
|
18
|
+
* Low-level helpers (`getProducts`, `getProduct`, `createProduct`, …)
|
|
19
|
+
* each map to a single REST endpoint and throw `apiError` on failure
|
|
20
|
+
* — except `*Exists` (boolean) and `delete*` (true if removed, false
|
|
21
|
+
* if the resource was already absent).
|
|
22
|
+
*
|
|
23
|
+
* Higher-level orchestrators (`installProductScript`,
|
|
24
|
+
* `removeProductScript`, `deleteProductWithStorage`) compose those
|
|
25
|
+
* primitives and return a `{ steps: string[] }` log so callers can
|
|
26
|
+
* surface a human-readable summary without re-implementing the
|
|
27
|
+
* sequence.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
function resolveUser(user) {
|
|
31
|
+
return user || requireConfig().username;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function v1(user) {
|
|
35
|
+
return `/v1/users/${resolveUser(user)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Low-level: products ─────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export async function getProducts(user) {
|
|
41
|
+
try {
|
|
42
|
+
const res = await api.get(`${v1(user)}/products`);
|
|
43
|
+
return res.data || [];
|
|
44
|
+
} catch (e) {
|
|
45
|
+
throw apiError(e);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function getProduct(productId, user) {
|
|
50
|
+
try {
|
|
51
|
+
const res = await api.get(`${v1(user)}/products/${productId}`);
|
|
52
|
+
return res.data || {};
|
|
53
|
+
} catch (e) {
|
|
54
|
+
throw apiError(e, { notFound: `Product not found: ${productId}` });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function productExists(productId, user) {
|
|
59
|
+
try {
|
|
60
|
+
await api.get(`${v1(user)}/products/${productId}`);
|
|
61
|
+
return true;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e.response?.status === 404) return false;
|
|
64
|
+
throw apiError(e);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a product with the standard ThinRemote scaffold: `type:
|
|
70
|
+
* thinremote`, a Font Awesome icon (defaults to Linux), and a
|
|
71
|
+
* `monitoring` bucket fed every 60 s from the device's `monitoring`
|
|
72
|
+
* resource. Caller controls only `icon`, `name`, `description`.
|
|
73
|
+
*/
|
|
74
|
+
export async function createProduct(
|
|
75
|
+
productId,
|
|
76
|
+
user,
|
|
77
|
+
{ icon = 'fab fa-linux', name, description } = {},
|
|
78
|
+
) {
|
|
79
|
+
try {
|
|
80
|
+
await api.post(`${v1(user)}/products`, {
|
|
81
|
+
product: productId,
|
|
82
|
+
name: name || productId,
|
|
83
|
+
description: description || 'ThinRemote product',
|
|
84
|
+
enabled: true,
|
|
85
|
+
config: {
|
|
86
|
+
type: 'thinremote',
|
|
87
|
+
icons: [
|
|
88
|
+
{
|
|
89
|
+
conditions: [],
|
|
90
|
+
icon: { type: 'fa', source: icon, color: '#000000', background: '#ffffff' },
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
profile: {
|
|
95
|
+
buckets: {
|
|
96
|
+
monitoring: {
|
|
97
|
+
name: 'Monitoring Data',
|
|
98
|
+
description: 'ThinRemote device metrics',
|
|
99
|
+
enabled: true,
|
|
100
|
+
backend: 'mongodb',
|
|
101
|
+
data: {
|
|
102
|
+
source: 'resource',
|
|
103
|
+
resource: 'monitoring',
|
|
104
|
+
update: 'interval',
|
|
105
|
+
interval: 60,
|
|
106
|
+
magnitude: 'second',
|
|
107
|
+
payload: '{{payload}}',
|
|
108
|
+
payload_function: '',
|
|
109
|
+
payload_type: 'source_payload',
|
|
110
|
+
},
|
|
111
|
+
retention: { period: 3, unit: 'months' },
|
|
112
|
+
tags: [],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
} catch (e) {
|
|
118
|
+
throw apiError(e);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function updateProduct(productId, user, patch) {
|
|
123
|
+
try {
|
|
124
|
+
await api.put(`${v1(user)}/products/${productId}`, patch);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
throw apiError(e, { notFound: `Product not found: ${productId}` });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function deleteProduct(productId, user) {
|
|
131
|
+
try {
|
|
132
|
+
await api.delete(`${v1(user)}/products/${productId}`);
|
|
133
|
+
return true;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (e.response?.status === 404) return false;
|
|
136
|
+
throw apiError(e);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Low-level: product API resources (profile/api/<name>) ───────────
|
|
141
|
+
|
|
142
|
+
/** Returns the `{ resourceName: definition }` map; `{}` when none. */
|
|
143
|
+
export async function getProductApi(productId, user) {
|
|
144
|
+
try {
|
|
145
|
+
const res = await api.get(`${v1(user)}/products/${productId}/profile/api`);
|
|
146
|
+
return res.data || {};
|
|
147
|
+
} catch (e) {
|
|
148
|
+
if (e.response?.status === 404) return {};
|
|
149
|
+
throw apiError(e);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function setProductApiResource(productId, resourceName, user, payload) {
|
|
154
|
+
try {
|
|
155
|
+
await api.put(`${v1(user)}/products/${productId}/profile/api/${resourceName}`, payload);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
throw apiError(e);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function deleteProductApiResource(productId, resourceName, user) {
|
|
162
|
+
try {
|
|
163
|
+
await api.delete(`${v1(user)}/products/${productId}/profile/api/${resourceName}`);
|
|
164
|
+
return true;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (e.response?.status === 404) return false;
|
|
167
|
+
throw apiError(e);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Low-level: product code link (profile/code) ─────────────────────
|
|
172
|
+
|
|
173
|
+
export async function getProductCode(productId, user) {
|
|
174
|
+
try {
|
|
175
|
+
const res = await api.get(`${v1(user)}/products/${productId}/profile/code`);
|
|
176
|
+
return res.data || {};
|
|
177
|
+
} catch (e) {
|
|
178
|
+
if (e.response?.status === 404) return {};
|
|
179
|
+
throw apiError(e);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function setProductCode(productId, user, payload) {
|
|
184
|
+
try {
|
|
185
|
+
await api.put(`${v1(user)}/products/${productId}/profile/code`, payload);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
throw apiError(e);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Low-level: device ↔ product binding ─────────────────────────────
|
|
192
|
+
|
|
193
|
+
export async function setDeviceProduct(deviceId, productId, user) {
|
|
194
|
+
try {
|
|
195
|
+
await api.put(`${v1(user)}/devices/${deviceId}`, { product: productId });
|
|
196
|
+
} catch (e) {
|
|
197
|
+
throw apiError(e, { notFound: `Device not found: ${deviceId}` });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Orchestrators ───────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Generic wrapper hosted in the storage's `index.js`. Dispatches the
|
|
205
|
+
* incoming product-API event to the matching script file by name,
|
|
206
|
+
* shelling out via the device's `cmd` resource. Idempotent on
|
|
207
|
+
* subsequent writes — the wrapper only changes if the contract does.
|
|
208
|
+
*/
|
|
209
|
+
const PRODUCT_SCRIPT_INDEX_JS = `function script(ev) {
|
|
210
|
+
const fs = require("fs"), path = require("path");
|
|
211
|
+
const dir = path.join(__dirname, "scripts");
|
|
212
|
+
const match = fs.readdirSync(dir).find(f =>
|
|
213
|
+
f === ev.resource || f.replace(/\\.[^.]+$/, "") === ev.resource
|
|
214
|
+
);
|
|
215
|
+
if (!match) throw new Error("script not found: " + ev.resource);
|
|
216
|
+
const body = fs.readFileSync(path.join(dir, match), "utf8");
|
|
217
|
+
return {
|
|
218
|
+
cmd: body, mode: "api", timeout: 30,
|
|
219
|
+
stdin: ev.payload && ev.payload.input !== undefined
|
|
220
|
+
? JSON.stringify(ev.payload.input) : undefined
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
`;
|
|
224
|
+
|
|
225
|
+
/** Drop a product (and, by default, its companion file storage). */
|
|
226
|
+
export async function deleteProductWithStorage(productId, user, { keepStorage = false } = {}) {
|
|
227
|
+
const steps = [];
|
|
228
|
+
|
|
229
|
+
if (await deleteProduct(productId, user)) {
|
|
230
|
+
steps.push(`deleted product "${productId}"`);
|
|
231
|
+
} else {
|
|
232
|
+
steps.push(`product "${productId}" already absent`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (keepStorage) {
|
|
236
|
+
steps.push(`kept file storage "${productId}" (keepStorage=true)`);
|
|
237
|
+
} else if (await deleteStorage(productId, user)) {
|
|
238
|
+
steps.push(`deleted file storage "${productId}"`);
|
|
239
|
+
} else {
|
|
240
|
+
steps.push(`file storage "${productId}" already absent`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { steps };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Install (create or update) a product script. Ensures the product
|
|
248
|
+
* exists, the storage exists, the index.js wrapper is present, the
|
|
249
|
+
* script body is uploaded, the product is enabled and linked to the
|
|
250
|
+
* storage, and the API resource named after the script's stem is
|
|
251
|
+
* registered. Idempotent.
|
|
252
|
+
*/
|
|
253
|
+
export async function installProductScript({ product, name, content, user, icon } = {}) {
|
|
254
|
+
if (!product) throw inputError('product is required');
|
|
255
|
+
if (!name) throw inputError('name is required');
|
|
256
|
+
if (typeof content !== 'string') throw inputError('content is required (string)');
|
|
257
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
258
|
+
throw inputError(
|
|
259
|
+
`Invalid script name "${name}" (allowed: letters, digits, dot, dash, underscore)`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const storage = product;
|
|
264
|
+
const stem = name.replace(/\.[^.]+$/, '');
|
|
265
|
+
const steps = [];
|
|
266
|
+
|
|
267
|
+
// 1. Ensure product exists
|
|
268
|
+
if (!(await productExists(product, user))) {
|
|
269
|
+
const usedIcon = icon || 'fab fa-linux';
|
|
270
|
+
await createProduct(product, user, { icon: usedIcon });
|
|
271
|
+
steps.push(`created product "${product}" (icon: ${usedIcon})`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 2. Ensure storage exists
|
|
275
|
+
if (!(await storageExists(storage, user))) {
|
|
276
|
+
await createStorage(storage, user, { description: `Product scripts for ${product}` });
|
|
277
|
+
steps.push(`created storage "${storage}"`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 3. Ensure the generic index.js wrapper is in place
|
|
281
|
+
if (!(await storageFileExists(storage, 'index.js', user))) {
|
|
282
|
+
await writeStorageFile(storage, 'index.js', PRODUCT_SCRIPT_INDEX_JS, user);
|
|
283
|
+
steps.push('wrote index.js wrapper');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 4. Upload the script body
|
|
287
|
+
await writeStorageFile(storage, `scripts/${name}`, content, user);
|
|
288
|
+
steps.push(`wrote scripts/${name} (${content.length} bytes)`);
|
|
289
|
+
|
|
290
|
+
// 5. Ensure profile.code.storage points at this storage
|
|
291
|
+
const code = await getProductCode(product, user);
|
|
292
|
+
if (code.storage !== storage) {
|
|
293
|
+
await setProductCode(product, user, { storage, code: '', version: '1.0' });
|
|
294
|
+
steps.push(`linked profile.code.storage → ${storage}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 6. Ensure product is enabled
|
|
298
|
+
const prod = await getProduct(product, user);
|
|
299
|
+
if (!prod.enabled) {
|
|
300
|
+
await updateProduct(product, user, { enabled: true });
|
|
301
|
+
steps.push('enabled product');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 7. Create/update the API resource bound to the script stem
|
|
305
|
+
await setProductApiResource(product, stem, user, {
|
|
306
|
+
enabled: true,
|
|
307
|
+
request: {
|
|
308
|
+
data: {
|
|
309
|
+
target: 'resource',
|
|
310
|
+
resource: 'cmd',
|
|
311
|
+
payload_type: 'source_event',
|
|
312
|
+
payload_function: 'script',
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
response: { data: { source: 'request_response', payload_type: 'source_payload' } },
|
|
316
|
+
});
|
|
317
|
+
steps.push(`upserted API resource "${stem}"`);
|
|
318
|
+
|
|
319
|
+
return { steps, stem };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Reverse of installProductScript: drops both the file and the API resource. */
|
|
323
|
+
export async function removeProductScript({ product, name, user } = {}) {
|
|
324
|
+
if (!product) throw inputError('product is required');
|
|
325
|
+
if (!name) throw inputError('name is required');
|
|
326
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
327
|
+
throw inputError(`Invalid script name "${name}"`);
|
|
328
|
+
}
|
|
329
|
+
const stem = name.replace(/\.[^.]+$/, '');
|
|
330
|
+
const steps = [];
|
|
331
|
+
|
|
332
|
+
if (await deleteStorageFile(product, `scripts/${name}`, user)) {
|
|
333
|
+
steps.push(`deleted scripts/${name}`);
|
|
334
|
+
} else {
|
|
335
|
+
steps.push(`scripts/${name} already absent`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (await deleteProductApiResource(product, stem, user)) {
|
|
339
|
+
steps.push(`deleted API resource "${stem}"`);
|
|
340
|
+
} else {
|
|
341
|
+
steps.push(`API resource "${stem}" already absent`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { steps, stem };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* List script files in a product's storage and pair them with the
|
|
349
|
+
* matching API resource (or flag as `[orphan]` when the file exists
|
|
350
|
+
* but no API resource references it).
|
|
351
|
+
*/
|
|
352
|
+
export async function listProductScripts(productId, user) {
|
|
353
|
+
const allFiles = await listStorageFiles(productId, user);
|
|
354
|
+
const files = allFiles.filter((f) => f.type === 'file' && f.path.startsWith('scripts/'));
|
|
355
|
+
const apis = await getProductApi(productId, user);
|
|
356
|
+
return files.map((f) => {
|
|
357
|
+
const stem = f.name.replace(/\.[^.]+$/, '');
|
|
358
|
+
return {
|
|
359
|
+
name: f.name,
|
|
360
|
+
path: f.path,
|
|
361
|
+
size: f.size,
|
|
362
|
+
stem,
|
|
363
|
+
registered: Boolean(apis[stem]),
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function readProductScript(productId, name, user) {
|
|
369
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
370
|
+
throw inputError(`Invalid script name "${name}"`);
|
|
371
|
+
}
|
|
372
|
+
return readStorageFile(productId, `scripts/${name}`, user);
|
|
373
|
+
}
|
package/lib/property.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { requireConfig } from './config.js';
|
|
3
3
|
import api from './api.js';
|
|
4
|
+
import { apiError } from './errors.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Get device properties
|
|
@@ -8,37 +9,15 @@ import api from './api.js';
|
|
|
8
9
|
* @returns {Promise<Object>} Device properties
|
|
9
10
|
*/
|
|
10
11
|
export async function getDeviceProperties(deviceId) {
|
|
11
|
-
|
|
12
|
-
const config = readConfig();
|
|
13
|
-
|
|
14
|
-
if (!config.token || !config.server || !config.username) {
|
|
15
|
-
throw new Error('Not configured. Run thinr without parameters to set up.');
|
|
16
|
-
}
|
|
12
|
+
const config = requireConfig();
|
|
17
13
|
|
|
18
14
|
try {
|
|
19
|
-
// Fetch device properties
|
|
20
15
|
const response = await api.get(
|
|
21
16
|
`/v3/users/${config.username}/devices/${deviceId}/properties`,
|
|
22
17
|
);
|
|
23
|
-
|
|
24
18
|
return response.data;
|
|
25
19
|
} catch (error) {
|
|
26
|
-
|
|
27
|
-
// The request was made and the server responded with a status code
|
|
28
|
-
if (error.response.status === 404) {
|
|
29
|
-
throw new Error(`Device not found: ${deviceId}`);
|
|
30
|
-
} else if (error.response.status === 401) {
|
|
31
|
-
throw new Error('Unauthorized. Your token may have expired. Please reconfigure.');
|
|
32
|
-
} else {
|
|
33
|
-
throw new Error(`Server error: ${error.response.status} ${error.response.statusText}`);
|
|
34
|
-
}
|
|
35
|
-
} else if (error.request) {
|
|
36
|
-
// The request was made but no response was received
|
|
37
|
-
throw new Error('No response from server. Please check your connection.');
|
|
38
|
-
} else {
|
|
39
|
-
// Something happened in setting up the request that triggered an Error
|
|
40
|
-
throw new Error(`Error: ${error.message}`);
|
|
41
|
-
}
|
|
20
|
+
throw apiError(error, { notFound: `Device not found: ${deviceId}` });
|
|
42
21
|
}
|
|
43
22
|
}
|
|
44
23
|
|
|
@@ -49,38 +28,41 @@ export async function getDeviceProperties(deviceId) {
|
|
|
49
28
|
* @returns {Promise<Object>} Property value
|
|
50
29
|
*/
|
|
51
30
|
export async function getDeviceProperty(deviceId, propertyId) {
|
|
52
|
-
|
|
53
|
-
const config = readConfig();
|
|
54
|
-
|
|
55
|
-
if (!config.token || !config.server || !config.username) {
|
|
56
|
-
throw new Error('Not configured. Run thinr without parameters to set up.');
|
|
57
|
-
}
|
|
31
|
+
const config = requireConfig();
|
|
58
32
|
|
|
59
33
|
try {
|
|
60
|
-
// Fetch property value
|
|
61
34
|
const response = await api.get(
|
|
62
|
-
`/v3/users/${config.username}/devices/${deviceId}/properties/${propertyId}
|
|
35
|
+
`/v3/users/${config.username}/devices/${deviceId}/properties/${propertyId}`,
|
|
63
36
|
);
|
|
64
|
-
|
|
65
37
|
return response.data.value;
|
|
66
38
|
} catch (error) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
39
|
+
throw apiError(error, {
|
|
40
|
+
notFound: `Property not found: ${propertyId} on device ${deviceId}`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create or update a property value on a device. Uses PUT so the
|
|
47
|
+
* operation is idempotent — missing properties are created, existing
|
|
48
|
+
* ones are overwritten wholesale.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} deviceId
|
|
51
|
+
* @param {string} propertyId
|
|
52
|
+
* @param {*} value JSON-serialisable value to store.
|
|
53
|
+
* @returns {Promise<Object>} The server's persisted record.
|
|
54
|
+
*/
|
|
55
|
+
export async function setDeviceProperty(deviceId, propertyId, value) {
|
|
56
|
+
const config = requireConfig();
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const response = await api.put(
|
|
60
|
+
`/v3/users/${config.username}/devices/${deviceId}/properties/${propertyId}`,
|
|
61
|
+
{ value },
|
|
62
|
+
);
|
|
63
|
+
return response.data;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw apiError(error, { notFound: `Device not found: ${deviceId}` });
|
|
84
66
|
}
|
|
85
67
|
}
|
|
86
68
|
|
|
@@ -91,12 +73,11 @@ export async function getDeviceProperty(deviceId, propertyId) {
|
|
|
91
73
|
* @returns {string} Formatted properties
|
|
92
74
|
*/
|
|
93
75
|
export function formatDeviceProperties(deviceId, properties) {
|
|
94
|
-
|
|
95
76
|
let formattedProperties = '\nProperties of device ' + chalk.cyan(deviceId) + ':\n';
|
|
96
77
|
|
|
97
|
-
properties.forEach(property => {
|
|
78
|
+
properties.forEach((property) => {
|
|
98
79
|
formattedProperties += `- ${property.property}\n`;
|
|
99
|
-
})
|
|
80
|
+
});
|
|
100
81
|
|
|
101
82
|
return formattedProperties;
|
|
102
83
|
}
|
|
@@ -110,7 +91,12 @@ export function formatDeviceProperties(deviceId, properties) {
|
|
|
110
91
|
*/
|
|
111
92
|
export function formatDeviceProperty(deviceId, propertyId, propertyValue) {
|
|
112
93
|
// show json with flattened structure
|
|
113
|
-
let formattedValue =
|
|
94
|
+
let formattedValue =
|
|
95
|
+
'\nValue of property ' +
|
|
96
|
+
chalk.cyan(propertyId) +
|
|
97
|
+
' for device ' +
|
|
98
|
+
chalk.cyan(deviceId) +
|
|
99
|
+
':\n';
|
|
114
100
|
const flattenedValue = flatten(propertyValue);
|
|
115
101
|
for (const key in flattenedValue) {
|
|
116
102
|
formattedValue += `${key}: ${chalk.green(flattenedValue[key])}\n`;
|
|
@@ -130,5 +116,3 @@ function flatten(obj, prefix = '', res = {}) {
|
|
|
130
116
|
}
|
|
131
117
|
return res;
|
|
132
118
|
}
|
|
133
|
-
|
|
134
|
-
|