@thinremote/thinr-cli 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/README.md +230 -16
- package/bin/thinr.js +23 -24
- package/commands/_shared.js +154 -0
- package/commands/dashboard.js +17 -0
- package/commands/device/_shared.js +17 -34
- package/commands/device/console.js +1 -0
- package/commands/device/delete.js +53 -0
- package/commands/device/exec.js +7 -6
- package/commands/device/fs.js +193 -0
- package/commands/device/index.js +9 -0
- package/commands/device/list.js +35 -23
- package/commands/device/logs.js +178 -0
- package/commands/device/property.js +30 -28
- package/commands/device/proxy.js +3 -0
- package/commands/device/resource.js +30 -29
- package/commands/device/status.js +35 -14
- package/commands/device/transfer.js +236 -0
- package/commands/device/update.js +15 -10
- package/commands/fleet/index.js +17 -0
- package/commands/fleet/upgrade.js +275 -0
- package/commands/logout.js +10 -11
- package/commands/playbook/_shared.js +12 -0
- package/commands/playbook/index.js +18 -0
- package/commands/playbook/run.js +302 -0
- package/commands/playbook/validate.js +38 -0
- package/commands/product/_shared.js +8 -0
- package/commands/product/exec.js +243 -0
- package/commands/product/fs.js +218 -0
- package/commands/product/index.js +35 -0
- package/commands/product/list.js +86 -0
- package/commands/product/logs.js +289 -0
- package/commands/product/metric.js +124 -0
- package/commands/product/playbook.js +911 -0
- package/commands/product/property.js +76 -0
- package/commands/product/props.js +125 -0
- package/commands/product/push.js +184 -0
- package/commands/product/resource.js +76 -0
- package/commands/product/status.js +213 -0
- package/commands/profile.js +76 -8
- package/dist/dashboard.js +5644 -0
- package/lib/agent-releases.js +59 -0
- package/lib/agent-versions.js +73 -0
- package/lib/alarms.js +252 -0
- package/lib/api.js +8 -2
- package/lib/auth.js +30 -17
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +199 -0
- package/lib/concurrency.js +52 -0
- package/lib/config.js +5 -3
- package/lib/console.js +12 -4
- package/lib/constants.js +76 -0
- package/lib/dashboard/event-stream.js +239 -0
- package/lib/dashboard/metric-aggregate.js +64 -0
- package/lib/dashboard/metric-filter.js +39 -0
- package/lib/debug-log.js +93 -0
- package/lib/device-api.js +109 -147
- package/lib/devices.js +18 -21
- package/lib/fleet-upgrade.js +106 -0
- package/lib/format.js +114 -0
- package/lib/mcp/registry.js +14 -0
- package/lib/mcp/tools-alarms.js +510 -0
- package/lib/mcp/tools-buckets.js +103 -0
- package/lib/mcp/tools-devices.js +33 -3
- package/lib/mcp/tools-exec.js +30 -17
- package/lib/mcp/tools-filesystem.js +102 -6
- package/lib/mcp/tools-install.js +153 -0
- package/lib/mcp/tools-logs.js +225 -0
- package/lib/mcp/tools-playbook.js +211 -0
- package/lib/mcp/tools-products.js +1343 -10
- package/lib/mcp/tools-profile.js +634 -0
- package/lib/mcp/tools-tokens.js +371 -0
- package/lib/monitoring.js +26 -1
- package/lib/playbook/actions.js +447 -0
- package/lib/playbook/expression.js +302 -0
- package/lib/playbook/fleet.js +175 -0
- package/lib/playbook/loader.js +247 -0
- package/lib/playbook/runner.js +300 -0
- package/lib/playbook/schema.js +209 -0
- package/lib/playbook/vars.js +209 -0
- package/lib/product/log-presets.js +196 -0
- package/lib/product/logs.js +485 -0
- package/lib/product-orchestrator.js +124 -0
- package/lib/product.js +523 -20
- package/lib/profile.js +665 -0
- package/lib/property.js +7 -40
- package/lib/proxy.js +9 -8
- package/lib/resource.js +10 -14
- package/lib/status.js +78 -37
- package/lib/tokens.js +374 -0
- package/package.json +15 -3
- package/commands/product.js +0 -167
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { writeFile as writeLocalFile } from 'fs/promises';
|
|
4
|
+
import { resolve as resolvePath } from 'path';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
import { createDeviceAPI } from '../device-api.js';
|
|
7
|
+
import { setDeviceProperty, getDeviceProperty } from '../property.js';
|
|
8
|
+
import { callDeviceResource, readDeviceResource } from '../resource.js';
|
|
9
|
+
import { TIMEOUTS } from '../constants.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Runtime handlers for every action in schema.js. Each handler takes
|
|
13
|
+
* a `ctx` (shared per-device context: apiUser, deviceId, api client)
|
|
14
|
+
* and the step's params, and either resolves with a short result
|
|
15
|
+
* string or throws to fail the step.
|
|
16
|
+
*
|
|
17
|
+
* The execution envelope (step order, concurrency, error handling) is
|
|
18
|
+
* in runner.js — handlers only care about their one job.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function resolveLocalPath(relPath, baseDir) {
|
|
22
|
+
return baseDir ? resolvePath(baseDir, relPath) : resolvePath(relPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveLocalFile(relPath, baseDir) {
|
|
26
|
+
return readFileSync(resolveLocalPath(relPath, baseDir), 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAX_CAPTURED_STREAM_BYTES = 32 * 1024;
|
|
30
|
+
|
|
31
|
+
function capStream(s) {
|
|
32
|
+
if (typeof s !== 'string' || s.length <= MAX_CAPTURED_STREAM_BYTES) return s;
|
|
33
|
+
return s.slice(0, MAX_CAPTURED_STREAM_BYTES) + `\n…[truncated, ${s.length - MAX_CAPTURED_STREAM_BYTES} more bytes]`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @type {Record<string, (ctx: any, params: any) => Promise<string | { summary: string, stdout?: string, stderr?: string, exitCode?: number }>>} */
|
|
37
|
+
export const HANDLERS = {
|
|
38
|
+
async exec(ctx, params) {
|
|
39
|
+
const timeout =
|
|
40
|
+
Number.isFinite(params.timeout) && params.timeout > 0
|
|
41
|
+
? params.timeout
|
|
42
|
+
: TIMEOUTS.DEFAULT_EXEC_SECONDS;
|
|
43
|
+
let stdout = '';
|
|
44
|
+
let stderr = '';
|
|
45
|
+
const { exitCode, timedOut } = await ctx.api.execStream(params.command, {
|
|
46
|
+
timeout,
|
|
47
|
+
onStdout: (s) => {
|
|
48
|
+
if (stdout.length < MAX_CAPTURED_STREAM_BYTES) stdout += s;
|
|
49
|
+
},
|
|
50
|
+
onStderr: (s) => {
|
|
51
|
+
if (stderr.length < MAX_CAPTURED_STREAM_BYTES) stderr += s;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const capturedStdout = capStream(stdout);
|
|
55
|
+
const capturedStderr = capStream(stderr);
|
|
56
|
+
if (timedOut) {
|
|
57
|
+
const err = new Error(`timed out after ${timeout}s`);
|
|
58
|
+
// @ts-ignore — attach captured streams for the runner to surface
|
|
59
|
+
err.stdout = capturedStdout;
|
|
60
|
+
// @ts-ignore
|
|
61
|
+
err.stderr = capturedStderr;
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
if (exitCode !== 0) {
|
|
65
|
+
const err = new Error(`exit=${exitCode}`);
|
|
66
|
+
// @ts-ignore
|
|
67
|
+
err.stdout = capturedStdout;
|
|
68
|
+
// @ts-ignore
|
|
69
|
+
err.stderr = capturedStderr;
|
|
70
|
+
// @ts-ignore
|
|
71
|
+
err.exitCode = exitCode;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
summary: 'exit=0',
|
|
76
|
+
stdout: capturedStdout,
|
|
77
|
+
stderr: capturedStderr,
|
|
78
|
+
exitCode: 0,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async sleep(_ctx, params) {
|
|
83
|
+
const ms = Math.max(0, Math.floor(Number(params.seconds) * 1000));
|
|
84
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
85
|
+
return `slept ${params.seconds}s`;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async write(ctx, params) {
|
|
89
|
+
if (typeof params.content !== 'string') {
|
|
90
|
+
throw new Error('`content` is required');
|
|
91
|
+
}
|
|
92
|
+
await ctx.api.writeFile(params.path, Buffer.from(params.content, 'utf8'));
|
|
93
|
+
return `wrote ${Buffer.byteLength(params.content, 'utf8')} bytes`;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async push(ctx, params) {
|
|
97
|
+
const local = resolveLocalPath(params.source, ctx.baseDir);
|
|
98
|
+
const content = readFileSync(local);
|
|
99
|
+
await ctx.api.writeFile(params.destination, content);
|
|
100
|
+
return `pushed ${content.byteLength} bytes`;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async pull(ctx, params) {
|
|
104
|
+
const local = resolveLocalPath(params.destination, ctx.baseDir);
|
|
105
|
+
const buf = await ctx.api.readFile(params.source);
|
|
106
|
+
await writeLocalFile(local, buf);
|
|
107
|
+
return `pulled ${buf.byteLength} bytes`;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async rm(ctx, params) {
|
|
111
|
+
await ctx.api.delete(params.path, params.recursive !== false);
|
|
112
|
+
return `removed ${params.path}`;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async mkdir(ctx, params) {
|
|
116
|
+
await ctx.api.mkdir(params.path);
|
|
117
|
+
return `created ${params.path}`;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async mv(ctx, params) {
|
|
121
|
+
await ctx.api.move(params.source, params.destination, !!params.overwrite);
|
|
122
|
+
return `moved ${params.source} → ${params.destination}`;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async property_set(ctx, params) {
|
|
126
|
+
await setDeviceProperty(ctx.deviceId, params.property, params.value);
|
|
127
|
+
return `set ${params.property}`;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async resource(ctx, params) {
|
|
131
|
+
const result = await callDeviceResource(ctx.deviceId, params.resource, params.inputs || null);
|
|
132
|
+
const summary =
|
|
133
|
+
typeof result === 'string'
|
|
134
|
+
? result.slice(0, 40)
|
|
135
|
+
: JSON.stringify(result).slice(0, 40);
|
|
136
|
+
return `resource ${params.resource} → ${summary}`;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async update(ctx, params) {
|
|
140
|
+
const op = params.op;
|
|
141
|
+
if (op !== 'check' && op !== 'apply') throw new Error('`op` must be "check" or "apply"');
|
|
142
|
+
const payload = { action: op, channel: params.channel || 'latest' };
|
|
143
|
+
// Agent updates can take a while. Give `apply` the same 5-min
|
|
144
|
+
// window the CLI's `thinr device update apply` uses.
|
|
145
|
+
const timeout =
|
|
146
|
+
op === 'apply' ? TIMEOUTS.DEVICE_UPDATE_APPLY_MS : TIMEOUTS.DEVICE_RESOURCE_CALL_MS;
|
|
147
|
+
const result = await ctx.api.callResource('update', payload, { timeout });
|
|
148
|
+
return `update ${op}: ${result?.status || 'ok'}`;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async script_install(ctx, params) {
|
|
152
|
+
let content;
|
|
153
|
+
if (typeof params.content === 'string') content = params.content;
|
|
154
|
+
else if (typeof params.content_file === 'string')
|
|
155
|
+
content = resolveLocalFile(params.content_file, ctx.baseDir);
|
|
156
|
+
else throw new Error('`content` or `content_file` is required');
|
|
157
|
+
|
|
158
|
+
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
159
|
+
throw new Error(`invalid script name "${params.name}"`);
|
|
160
|
+
}
|
|
161
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
162
|
+
const baseDir = info?.path;
|
|
163
|
+
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
164
|
+
const path = `${baseDir}/${params.name}`;
|
|
165
|
+
await ctx.api.writeFile(path, content, true);
|
|
166
|
+
const chmod = await ctx.api.exec(
|
|
167
|
+
`chmod +x ${JSON.stringify(path)}`,
|
|
168
|
+
TIMEOUTS.SCRIPT_CHMOD_SECONDS,
|
|
169
|
+
);
|
|
170
|
+
if (chmod.retcode !== 0) throw new Error(`chmod failed: ${chmod.stderr || chmod.retcode}`);
|
|
171
|
+
await callDeviceResource(ctx.deviceId, '$scripts/reload', {});
|
|
172
|
+
return `installed ${params.name}`;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async script_delete(ctx, params) {
|
|
176
|
+
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
177
|
+
throw new Error(`invalid script name "${params.name}"`);
|
|
178
|
+
}
|
|
179
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
180
|
+
const baseDir = info?.path;
|
|
181
|
+
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
182
|
+
await ctx.api.delete(`${baseDir}/${params.name}`, false);
|
|
183
|
+
await callDeviceResource(ctx.deviceId, '$scripts/reload', {});
|
|
184
|
+
return `removed ${params.name}`;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read-only verdicts for `--check` mode. Each entry receives the same
|
|
190
|
+
* ctx/params as the corresponding HANDLERS entry and returns a verdict
|
|
191
|
+
* describing what would happen if the step ran. Must never write.
|
|
192
|
+
*
|
|
193
|
+
* { status: 'changed' | 'unchanged' | 'unknown', summary: string }
|
|
194
|
+
*
|
|
195
|
+
* Actions without an entry here fall back to 'unknown' automatically
|
|
196
|
+
* (e.g. `exec`, `resource` — their effect can't be predicted without
|
|
197
|
+
* running them; `write` / `script_install` — depend on the agent
|
|
198
|
+
* exposing a filesystem hash op, handled in a follow-up).
|
|
199
|
+
*
|
|
200
|
+
* @type {Record<string, (ctx: any, params: any) => Promise<{ status: 'changed' | 'unchanged' | 'unknown', summary: string }>>}
|
|
201
|
+
*/
|
|
202
|
+
export const CHECKERS = {
|
|
203
|
+
async sleep(_ctx, params) {
|
|
204
|
+
return { status: 'unchanged', summary: `would sleep ${params.seconds}s` };
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async write(ctx, params) {
|
|
208
|
+
if (typeof params.content !== 'string') {
|
|
209
|
+
throw new Error('`content` is required');
|
|
210
|
+
}
|
|
211
|
+
return hashCheck(ctx.api, params.path, Buffer.from(params.content, 'utf8'));
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async push(ctx, params) {
|
|
215
|
+
const local = resolveLocalPath(params.source, ctx.baseDir);
|
|
216
|
+
const content = readFileSync(local);
|
|
217
|
+
return hashCheck(ctx.api, params.destination, content);
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async pull(ctx, params) {
|
|
221
|
+
try {
|
|
222
|
+
const info = await ctx.api.info(params.source);
|
|
223
|
+
const size = info?.size ?? '?';
|
|
224
|
+
return { status: 'changed', summary: `would pull ${params.source} (${size} bytes) → ${params.destination}` };
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (err?.response?.status === 404) {
|
|
227
|
+
return { status: 'changed', summary: `source ${params.source} not found on device` };
|
|
228
|
+
}
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
async script_install(ctx, params) {
|
|
234
|
+
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
235
|
+
throw new Error(`invalid script name "${params.name}"`);
|
|
236
|
+
}
|
|
237
|
+
const content = resolveWriteContent(params, ctx.baseDir);
|
|
238
|
+
const localHash = sha256Hex(content);
|
|
239
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
240
|
+
const baseDir = info?.path;
|
|
241
|
+
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
242
|
+
const remotePath = `${baseDir}/${params.name}`;
|
|
243
|
+
let remote;
|
|
244
|
+
try {
|
|
245
|
+
remote = await ctx.api.hashFile(remotePath);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (err?.response?.status === 404) {
|
|
248
|
+
return { status: 'changed', summary: `install ${params.name}` };
|
|
249
|
+
}
|
|
250
|
+
if (err?.response?.status === 405 || err?.response?.status === 501) {
|
|
251
|
+
return { status: 'unknown', summary: 'agent lacks $fs/hash — cannot predict' };
|
|
252
|
+
}
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
if (remote?.hash === localHash) {
|
|
256
|
+
return { status: 'unchanged', summary: `${params.name} already matches` };
|
|
257
|
+
}
|
|
258
|
+
return { status: 'changed', summary: `${params.name} would be replaced` };
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
async property_set(ctx, params) {
|
|
262
|
+
let current;
|
|
263
|
+
try {
|
|
264
|
+
current = await getDeviceProperty(ctx.deviceId, params.property);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
if (err?.code === 'not_found') {
|
|
267
|
+
return { status: 'changed', summary: `create property ${params.property}` };
|
|
268
|
+
}
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
if (stableStringify(current) === stableStringify(params.value)) {
|
|
272
|
+
return { status: 'unchanged', summary: `property ${params.property} already set` };
|
|
273
|
+
}
|
|
274
|
+
return { status: 'changed', summary: `property ${params.property} would change` };
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
async rm(ctx, params) {
|
|
278
|
+
try {
|
|
279
|
+
await ctx.api.info(params.path);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (err?.response?.status === 404) {
|
|
282
|
+
return { status: 'unchanged', summary: `${params.path} does not exist` };
|
|
283
|
+
}
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
return { status: 'changed', summary: `would remove ${params.path}` };
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
async mkdir(ctx, params) {
|
|
290
|
+
try {
|
|
291
|
+
const info = await ctx.api.info(params.path);
|
|
292
|
+
if (info?.type === 'directory') {
|
|
293
|
+
return { status: 'unchanged', summary: `${params.path} already exists` };
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
status: 'changed',
|
|
297
|
+
summary: `${params.path} exists as ${info?.type || 'non-dir'} — mkdir would fail`,
|
|
298
|
+
};
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (err?.response?.status === 404) {
|
|
301
|
+
return { status: 'changed', summary: `would create ${params.path}` };
|
|
302
|
+
}
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
async mv(ctx, params) {
|
|
308
|
+
try {
|
|
309
|
+
await ctx.api.info(params.source);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (err?.response?.status === 404) {
|
|
312
|
+
return { status: 'unchanged', summary: `source ${params.source} does not exist` };
|
|
313
|
+
}
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
if (!params.overwrite) {
|
|
317
|
+
try {
|
|
318
|
+
await ctx.api.info(params.destination);
|
|
319
|
+
return {
|
|
320
|
+
status: 'unchanged',
|
|
321
|
+
summary: `destination ${params.destination} already exists (overwrite=false)`,
|
|
322
|
+
};
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (err?.response?.status !== 404) throw err;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
status: 'changed',
|
|
329
|
+
summary: `would move ${params.source} → ${params.destination}`,
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
async update(ctx, params) {
|
|
334
|
+
if (params.op !== 'check' && params.op !== 'apply') {
|
|
335
|
+
throw new Error('`op` must be "check" or "apply"');
|
|
336
|
+
}
|
|
337
|
+
const result = await ctx.api.callResource(
|
|
338
|
+
'update',
|
|
339
|
+
{ action: 'check', channel: params.channel || 'latest' },
|
|
340
|
+
{ timeout: TIMEOUTS.DEVICE_RESOURCE_CALL_MS },
|
|
341
|
+
);
|
|
342
|
+
const upToDate = result?.status === 'up_to_date';
|
|
343
|
+
return {
|
|
344
|
+
status: upToDate ? 'unchanged' : 'changed',
|
|
345
|
+
summary: `update ${params.op}: ${result?.status || 'unknown'}` +
|
|
346
|
+
(result?.latest ? ` (latest=${result.latest})` : ''),
|
|
347
|
+
};
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
async script_delete(ctx, params) {
|
|
351
|
+
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
352
|
+
throw new Error(`invalid script name "${params.name}"`);
|
|
353
|
+
}
|
|
354
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
355
|
+
const baseDir = info?.path;
|
|
356
|
+
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
357
|
+
try {
|
|
358
|
+
await ctx.api.info(`${baseDir}/${params.name}`);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
if (err?.response?.status === 404) {
|
|
361
|
+
return { status: 'unchanged', summary: `${params.name} is not installed` };
|
|
362
|
+
}
|
|
363
|
+
throw err;
|
|
364
|
+
}
|
|
365
|
+
return { status: 'changed', summary: `would remove ${params.name}` };
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Resolve the payload of a script_install step into a Buffer — same
|
|
371
|
+
* logic the handler uses — so the check path hashes exactly the same
|
|
372
|
+
* bytes that would be uploaded.
|
|
373
|
+
*/
|
|
374
|
+
function resolveWriteContent(params, baseDir) {
|
|
375
|
+
if (typeof params.content === 'string') {
|
|
376
|
+
return Buffer.from(params.content, 'utf8');
|
|
377
|
+
}
|
|
378
|
+
if (typeof params.content_file === 'string') {
|
|
379
|
+
return Buffer.from(resolveLocalFile(params.content_file, baseDir), 'utf8');
|
|
380
|
+
}
|
|
381
|
+
throw new Error('`content` or `content_file` is required');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Shared check-mode helper for write / push: compare the locally
|
|
386
|
+
* computed sha256 against the agent's remote hash, mapping the
|
|
387
|
+
* usual HTTP error codes into verdicts.
|
|
388
|
+
*/
|
|
389
|
+
async function hashCheck(api, path, contentBuffer) {
|
|
390
|
+
const localHash = sha256Hex(contentBuffer);
|
|
391
|
+
let remote;
|
|
392
|
+
try {
|
|
393
|
+
remote = await api.hashFile(path);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
if (err?.response?.status === 404) {
|
|
396
|
+
return { status: 'changed', summary: `create ${path} (${contentBuffer.length} bytes)` };
|
|
397
|
+
}
|
|
398
|
+
if (err?.response?.status === 400) {
|
|
399
|
+
return { status: 'changed', summary: `${path} is not a regular file — would overwrite` };
|
|
400
|
+
}
|
|
401
|
+
if (err?.response?.status === 405 || err?.response?.status === 501) {
|
|
402
|
+
return { status: 'unknown', summary: 'agent lacks $fs/hash — cannot predict' };
|
|
403
|
+
}
|
|
404
|
+
throw err;
|
|
405
|
+
}
|
|
406
|
+
if (remote?.hash === localHash) {
|
|
407
|
+
return { status: 'unchanged', summary: `${path} already matches (${remote.size} bytes)` };
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
status: 'changed',
|
|
411
|
+
summary: `${path} would change (${remote?.size ?? '?'} → ${contentBuffer.length} bytes)`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function sha256Hex(buf) {
|
|
416
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* JSON stringify with sorted object keys, so two structurally equal
|
|
421
|
+
* values compare equal regardless of property declaration order.
|
|
422
|
+
*/
|
|
423
|
+
function stableStringify(value) {
|
|
424
|
+
return JSON.stringify(value, function replacer(_k, v) {
|
|
425
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
426
|
+
return Object.keys(v)
|
|
427
|
+
.sort()
|
|
428
|
+
.reduce((acc, k) => {
|
|
429
|
+
acc[k] = v[k];
|
|
430
|
+
return acc;
|
|
431
|
+
}, {});
|
|
432
|
+
}
|
|
433
|
+
return v;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Build a per-device execution context that handlers share.
|
|
439
|
+
*/
|
|
440
|
+
export function createActionContext({ deviceId, user, baseDir }) {
|
|
441
|
+
return {
|
|
442
|
+
deviceId,
|
|
443
|
+
user,
|
|
444
|
+
baseDir,
|
|
445
|
+
api: createDeviceAPI(deviceId, { user: user || undefined }),
|
|
446
|
+
};
|
|
447
|
+
}
|