@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.7.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/LICENSE +21 -0
- package/README.md +38 -2
- package/client.js +1008 -0
- package/cordis.patch.yml +3 -0
- package/host/actions-darwin.js +1261 -0
- package/host/actions-win32.js +1333 -0
- package/host/actions.js +45 -0
- package/host/benchmark.js +413 -0
- package/host/cleanup-darwin.js +715 -0
- package/host/cleanup-win32.js +760 -0
- package/host/cleanup.js +62 -0
- package/host/collector-darwin.js +2995 -0
- package/host/collector-win32.js +2060 -0
- package/host/collector.js +71 -0
- package/host/footprint-core.js +399 -0
- package/host/footprint-darwin.js +663 -0
- package/host/footprint-win32.js +411 -0
- package/host/footprint.js +284 -0
- package/host/proc.js +232 -0
- package/host/tool.js +254 -0
- package/host/transport.js +316 -0
- package/host/uninstall.js +244 -0
- package/host/win/hardware.ps1 +501 -0
- package/host/win/metrics.ps1 +535 -0
- package/host/win/network.ps1 +322 -0
- package/host/win/processes.ps1 +169 -0
- package/host/win/security.ps1 +197 -0
- package/host/win/software.ps1 +119 -0
- package/host/win/system.ps1 +279 -0
- package/host/win.js +187 -0
- package/icon.svg +18 -0
- package/index.js +67 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X — whitelisted device action engine.
|
|
3
|
+
*
|
|
4
|
+
* Every operation is implemented with argv-only `execFile` / `process.kill` /
|
|
5
|
+
* `fs` calls (never a shell string), validates its parameters strictly, and
|
|
6
|
+
* returns a JSON-safe result. `runAction` never throws for bad input.
|
|
7
|
+
*
|
|
8
|
+
* Plain Node ESM; `node:*` imports only (plus the sibling process helper).
|
|
9
|
+
*
|
|
10
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/actions
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as fsp from 'node:fs/promises';
|
|
14
|
+
import * as net from 'node:net';
|
|
15
|
+
import * as os from 'node:os';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { cleanupItems, deleteCleanup } from './cleanup-darwin.js';
|
|
19
|
+
import { consumeToken, resolveScanItems, scanFootprint } from './footprint.js';
|
|
20
|
+
import { runUninstallPipeline } from './uninstall.js';
|
|
21
|
+
import { run } from './proc.js';
|
|
22
|
+
|
|
23
|
+
/** Action groups, in UI order. */
|
|
24
|
+
export const GROUPS = ['process', 'app', 'system', 'network', 'software', 'power'];
|
|
25
|
+
|
|
26
|
+
/** Maximum retained history entries. */
|
|
27
|
+
const HISTORY_LIMIT = 50;
|
|
28
|
+
|
|
29
|
+
/* -------------------------------------------------------------------------- */
|
|
30
|
+
/* Command paths */
|
|
31
|
+
/* -------------------------------------------------------------------------- */
|
|
32
|
+
|
|
33
|
+
const OSASCRIPT = '/usr/bin/osascript';
|
|
34
|
+
const OPEN = '/usr/bin/open';
|
|
35
|
+
const SCUTIL = '/usr/sbin/scutil';
|
|
36
|
+
const LAUNCHCTL = '/bin/launchctl';
|
|
37
|
+
const NETWORKSETUP = '/usr/sbin/networksetup';
|
|
38
|
+
const SOCKETFILTERFW = '/usr/libexec/ApplicationFirewall/socketfilterfw';
|
|
39
|
+
const DSCACHEUTIL = '/usr/bin/dscacheutil';
|
|
40
|
+
const PMSET = '/usr/bin/pmset';
|
|
41
|
+
const BREW_CANDIDATES = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
|
|
42
|
+
|
|
43
|
+
/** AppleScript-visible System Settings anchors for `system.openSettings`. */
|
|
44
|
+
const SETTINGS_PANES = {
|
|
45
|
+
wifi: 'com.apple.wifi-settings-extension',
|
|
46
|
+
network: 'com.apple.network-settings-extension',
|
|
47
|
+
bluetooth: 'com.apple.BluetoothSettings',
|
|
48
|
+
sound: 'com.apple.Sound-Settings.extension',
|
|
49
|
+
display: 'com.apple.Displays-Settings.extension',
|
|
50
|
+
appearance: 'com.apple.Appearance-Settings.extension',
|
|
51
|
+
wallpaper: 'com.apple.Wallpaper-Settings.extension',
|
|
52
|
+
battery: 'com.apple.Battery-Settings.extension',
|
|
53
|
+
general: 'com.apple.General-Settings.extension',
|
|
54
|
+
privacy: 'com.apple.PrivacySecurity.extension',
|
|
55
|
+
users: 'com.apple.Users-Groups-Settings.extension',
|
|
56
|
+
keyboard: 'com.apple.Keyboard-Settings.extension',
|
|
57
|
+
mouse: 'com.apple.Mouse-Settings.extension',
|
|
58
|
+
trackpad: 'com.apple.Trackpad-Settings.extension',
|
|
59
|
+
notifications: 'com.apple.Notifications-Settings.extension',
|
|
60
|
+
softwareupdate: 'com.apple.Software-Update-Settings.extension',
|
|
61
|
+
storage: 'com.apple.Storage-Settings.extension',
|
|
62
|
+
datetime: 'com.apple.Date-Time-Settings.extension',
|
|
63
|
+
language: 'com.apple.Localization-Settings.extension',
|
|
64
|
+
sharing: 'com.apple.Sharing-Settings.extension',
|
|
65
|
+
loginitems: 'com.apple.LoginItems-Settings.extension',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Supported POSIX signals for `process.signal`. */
|
|
69
|
+
const SIGNAL_NAMES = new Map([
|
|
70
|
+
['TERM', 'SIGTERM'],
|
|
71
|
+
['KILL', 'SIGKILL'],
|
|
72
|
+
['INT', 'SIGINT'],
|
|
73
|
+
['STOP', 'SIGSTOP'],
|
|
74
|
+
['CONT', 'SIGCONT'],
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
78
|
+
const HOSTNAME_PATTERN = /^[A-Za-z0-9.-]{1,63}$/;
|
|
79
|
+
const BREW_TOKEN_PATTERN = /^[A-Za-z0-9@+._/-]+$/;
|
|
80
|
+
|
|
81
|
+
/* -------------------------------------------------------------------------- */
|
|
82
|
+
/* JSON safety */
|
|
83
|
+
/* -------------------------------------------------------------------------- */
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Deep JSON-safe clone (undefined → null, non-finite → null, cycles → null).
|
|
87
|
+
* @param {unknown} value
|
|
88
|
+
* @param {Set<object>} [seen]
|
|
89
|
+
* @returns {any}
|
|
90
|
+
*/
|
|
91
|
+
function jsonSafe(value, seen = new Set()) {
|
|
92
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
93
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
94
|
+
if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') return null;
|
|
95
|
+
if (typeof value !== 'object') return null;
|
|
96
|
+
if (seen.has(value)) return null;
|
|
97
|
+
seen.add(value);
|
|
98
|
+
let result;
|
|
99
|
+
if (Array.isArray(value)) {
|
|
100
|
+
result = value.map((item) => jsonSafe(item, seen));
|
|
101
|
+
} else if (value instanceof Date) {
|
|
102
|
+
const time = value.getTime();
|
|
103
|
+
result = Number.isFinite(time) ? time : null;
|
|
104
|
+
} else {
|
|
105
|
+
result = {};
|
|
106
|
+
for (const [key, item] of Object.entries(value)) result[key] = jsonSafe(item, seen);
|
|
107
|
+
}
|
|
108
|
+
seen.delete(value);
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* -------------------------------------------------------------------------- */
|
|
113
|
+
/* Action catalog (frozen order and ids) */
|
|
114
|
+
/* -------------------------------------------------------------------------- */
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @typedef {object} ActionParam
|
|
118
|
+
* @property {string} name
|
|
119
|
+
* @property {'number'|'string'|'boolean'|'select'} type
|
|
120
|
+
* @property {boolean} required
|
|
121
|
+
* @property {string[]} [options]
|
|
122
|
+
* @property {number} [min]
|
|
123
|
+
* @property {number} [max]
|
|
124
|
+
* @property {string} [placeholder]
|
|
125
|
+
* @property {boolean} [multiple] true when the client sends an array of values.
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build a parameter descriptor, dropping absent optional fields so the JSON
|
|
130
|
+
* shape stays exactly the contract's.
|
|
131
|
+
* @param {string} name
|
|
132
|
+
* @param {'number'|'string'|'boolean'|'select'} type
|
|
133
|
+
* @param {object} [options]
|
|
134
|
+
* @returns {ActionParam}
|
|
135
|
+
*/
|
|
136
|
+
function param(name, type, options = {}) {
|
|
137
|
+
/** @type {ActionParam} */
|
|
138
|
+
const descriptor = { name, type, required: options.required !== false };
|
|
139
|
+
if (Array.isArray(options.options)) descriptor.options = [...options.options];
|
|
140
|
+
if (typeof options.min === 'number') descriptor.min = options.min;
|
|
141
|
+
if (typeof options.max === 'number') descriptor.max = options.max;
|
|
142
|
+
if (typeof options.placeholder === 'string') descriptor.placeholder = options.placeholder;
|
|
143
|
+
if (options.multiple === true) descriptor.multiple = true;
|
|
144
|
+
return descriptor;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The frozen action table, in contract order. */
|
|
148
|
+
const SPECS = [
|
|
149
|
+
{
|
|
150
|
+
id: 'process.signal',
|
|
151
|
+
group: 'process',
|
|
152
|
+
danger: 'medium',
|
|
153
|
+
requiresAdmin: false,
|
|
154
|
+
params: [
|
|
155
|
+
param('pid', 'number', { min: 2, max: 4_194_304, placeholder: '1234' }),
|
|
156
|
+
param('signal', 'select', {
|
|
157
|
+
required: false,
|
|
158
|
+
options: [...SIGNAL_NAMES.keys()],
|
|
159
|
+
placeholder: 'TERM',
|
|
160
|
+
}),
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'app.quit',
|
|
165
|
+
group: 'app',
|
|
166
|
+
danger: 'low',
|
|
167
|
+
requiresAdmin: false,
|
|
168
|
+
params: [param('name', 'string', { placeholder: 'Safari' })],
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 'app.launch',
|
|
172
|
+
group: 'app',
|
|
173
|
+
danger: 'low',
|
|
174
|
+
requiresAdmin: false,
|
|
175
|
+
params: [param('path', 'string', { placeholder: '/Applications/Safari.app' })],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'app.reveal',
|
|
179
|
+
group: 'app',
|
|
180
|
+
danger: 'low',
|
|
181
|
+
requiresAdmin: false,
|
|
182
|
+
params: [param('path', 'string', { placeholder: '/Applications/Safari.app' })],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'app.uninstall',
|
|
186
|
+
group: 'app',
|
|
187
|
+
danger: 'high',
|
|
188
|
+
requiresAdmin: false,
|
|
189
|
+
params: [
|
|
190
|
+
param('path', 'string', { placeholder: '/Applications/SomeApp.app' }),
|
|
191
|
+
param('mode', 'select', { required: false, options: ['trash', 'delete'], placeholder: 'trash' }),
|
|
192
|
+
// 1.6: optional footprint cleanup. All three are omitted by the plain UI
|
|
193
|
+
// flow, which keeps the single-step behaviour exactly as before.
|
|
194
|
+
param('scanId', 'string', { required: false, placeholder: 'scan id from GET /devicex/api/footprint' }),
|
|
195
|
+
param('itemIds', 'string', { required: false, multiple: true, placeholder: 'fp_1a2b3c4d, fp_5e6f7a8b' }),
|
|
196
|
+
param('cleanup', 'select', { required: false, options: ['trash', 'none'], placeholder: 'trash' }),
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'system.setHostname',
|
|
201
|
+
group: 'system',
|
|
202
|
+
danger: 'medium',
|
|
203
|
+
requiresAdmin: true,
|
|
204
|
+
params: [
|
|
205
|
+
param('kind', 'select', { options: ['HostName', 'ComputerName', 'LocalHostName'], placeholder: 'ComputerName' }),
|
|
206
|
+
param('value', 'string', { placeholder: 'My-MacBook' }),
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: 'system.setVolume',
|
|
211
|
+
group: 'system',
|
|
212
|
+
danger: 'low',
|
|
213
|
+
requiresAdmin: false,
|
|
214
|
+
params: [param('percent', 'number', { min: 0, max: 100, placeholder: '50' })],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: 'system.setDarkMode',
|
|
218
|
+
group: 'system',
|
|
219
|
+
danger: 'low',
|
|
220
|
+
requiresAdmin: false,
|
|
221
|
+
params: [param('enabled', 'boolean', { placeholder: 'true' })],
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: 'system.launchAgent',
|
|
225
|
+
group: 'system',
|
|
226
|
+
danger: 'medium',
|
|
227
|
+
requiresAdmin: true,
|
|
228
|
+
params: [
|
|
229
|
+
param('path', 'string', { placeholder: '~/Library/LaunchAgents/com.example.agent.plist' }),
|
|
230
|
+
param('enabled', 'boolean', { placeholder: 'true' }),
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: 'system.openSettings',
|
|
235
|
+
group: 'system',
|
|
236
|
+
danger: 'low',
|
|
237
|
+
requiresAdmin: false,
|
|
238
|
+
params: [param('pane', 'select', { options: Object.keys(SETTINGS_PANES), placeholder: 'wifi' })],
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: 'system.toggleFirewall',
|
|
242
|
+
group: 'system',
|
|
243
|
+
danger: 'medium',
|
|
244
|
+
requiresAdmin: true,
|
|
245
|
+
params: [param('enabled', 'boolean', { placeholder: 'true' })],
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
id: 'system.flushDNS',
|
|
249
|
+
group: 'system',
|
|
250
|
+
danger: 'low',
|
|
251
|
+
requiresAdmin: false,
|
|
252
|
+
params: [],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 'system.emptyTrash',
|
|
256
|
+
group: 'system',
|
|
257
|
+
danger: 'medium',
|
|
258
|
+
requiresAdmin: false,
|
|
259
|
+
params: [],
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: 'cleanup.delete',
|
|
263
|
+
group: 'system',
|
|
264
|
+
danger: 'high',
|
|
265
|
+
requiresAdmin: false,
|
|
266
|
+
params: [param('ids', 'string', { multiple: true, placeholder: 'user-caches, user-logs' })],
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: 'network.setWifiPower',
|
|
270
|
+
group: 'network',
|
|
271
|
+
danger: 'medium',
|
|
272
|
+
requiresAdmin: false,
|
|
273
|
+
params: [param('enabled', 'boolean', { placeholder: 'true' })],
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: 'network.setDns',
|
|
277
|
+
group: 'network',
|
|
278
|
+
danger: 'medium',
|
|
279
|
+
requiresAdmin: true,
|
|
280
|
+
params: [
|
|
281
|
+
param('service', 'string', { placeholder: 'Wi-Fi' }),
|
|
282
|
+
param('servers', 'string', { placeholder: '8.8.8.8, 1.1.1.1' }),
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: 'power.sleep',
|
|
287
|
+
group: 'power',
|
|
288
|
+
danger: 'low',
|
|
289
|
+
requiresAdmin: false,
|
|
290
|
+
params: [],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: 'power.lock',
|
|
294
|
+
group: 'power',
|
|
295
|
+
danger: 'low',
|
|
296
|
+
requiresAdmin: false,
|
|
297
|
+
params: [],
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: 'software.brewUninstall',
|
|
301
|
+
group: 'software',
|
|
302
|
+
danger: 'medium',
|
|
303
|
+
requiresAdmin: false,
|
|
304
|
+
params: [param('name', 'string', { placeholder: 'wget' })],
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
const SPEC_BY_ID = new Map(SPECS.map((spec) => [spec.id, spec]));
|
|
309
|
+
|
|
310
|
+
/* -------------------------------------------------------------------------- */
|
|
311
|
+
/* History */
|
|
312
|
+
/* -------------------------------------------------------------------------- */
|
|
313
|
+
|
|
314
|
+
/** @type {Array<{ts: number, id: string, params: object, ok: boolean, message: string}>} */
|
|
315
|
+
const history = [];
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* @param {{ts: number, id: string, params: object, ok: boolean, message: string}} entry
|
|
319
|
+
* @returns {void}
|
|
320
|
+
*/
|
|
321
|
+
function recordHistory(entry) {
|
|
322
|
+
history.unshift(entry);
|
|
323
|
+
if (history.length > HISTORY_LIMIT) history.length = HISTORY_LIMIT;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* -------------------------------------------------------------------------- */
|
|
327
|
+
/* Result / validation helpers */
|
|
328
|
+
/* -------------------------------------------------------------------------- */
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* @param {string} message
|
|
332
|
+
* @param {string} [code]
|
|
333
|
+
* @returns {{ok: false, message: string, code?: string}}
|
|
334
|
+
*/
|
|
335
|
+
function failure(message, code) {
|
|
336
|
+
return code ? { ok: false, message, code } : { ok: false, message };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @param {string} message
|
|
341
|
+
* @param {object} [data]
|
|
342
|
+
* @returns {{ok: true, message: string, data?: object}}
|
|
343
|
+
*/
|
|
344
|
+
function success(message, data) {
|
|
345
|
+
return data && typeof data === 'object' && Object.keys(data).length
|
|
346
|
+
? { ok: true, message, data: jsonSafe(data) }
|
|
347
|
+
: { ok: true, message };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Turn a failed command result into an ActionResult carrying stderr.
|
|
352
|
+
* @param {string} action
|
|
353
|
+
* @param {Awaited<ReturnType<typeof run>>} result
|
|
354
|
+
* @param {string} [code]
|
|
355
|
+
* @returns {{ok: false, message: string, code?: string}}
|
|
356
|
+
*/
|
|
357
|
+
function commandFailure(action, result, code) {
|
|
358
|
+
const detail = (result.stderr || result.error || `exit code ${String(result.code)}`).trim();
|
|
359
|
+
const suffix = result.timedOut ? ' (timed out)' : '';
|
|
360
|
+
return failure(`${action} failed: ${detail || 'unknown error'}${suffix}`, code ?? 'COMMAND_FAILED');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Read a required integer parameter.
|
|
365
|
+
* @param {Record<string, unknown>} params
|
|
366
|
+
* @param {string} name
|
|
367
|
+
* @param {{min?: number, max?: number}} [bounds]
|
|
368
|
+
* @returns {{ok: true, value: number} | {ok: false, message: string}}
|
|
369
|
+
*/
|
|
370
|
+
function readInteger(params, name, bounds = {}) {
|
|
371
|
+
const raw = params[name];
|
|
372
|
+
let value = null;
|
|
373
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) value = raw;
|
|
374
|
+
else if (typeof raw === 'string' && /^-?\d+$/.test(raw.trim())) value = Number(raw.trim());
|
|
375
|
+
if (value === null || !Number.isInteger(value)) {
|
|
376
|
+
return { ok: false, message: `Parameter "${name}" must be an integer.` };
|
|
377
|
+
}
|
|
378
|
+
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = bounds;
|
|
379
|
+
if (value < min || value > max) {
|
|
380
|
+
return { ok: false, message: `Parameter "${name}" must be between ${min} and ${max}.` };
|
|
381
|
+
}
|
|
382
|
+
return { ok: true, value };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Read a required number parameter.
|
|
387
|
+
* @param {Record<string, unknown>} params
|
|
388
|
+
* @param {string} name
|
|
389
|
+
* @param {{min?: number, max?: number}} [bounds]
|
|
390
|
+
* @returns {{ok: true, value: number} | {ok: false, message: string}}
|
|
391
|
+
*/
|
|
392
|
+
function readNumber(params, name, bounds = {}) {
|
|
393
|
+
const raw = params[name];
|
|
394
|
+
let value = null;
|
|
395
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) value = raw;
|
|
396
|
+
else if (typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))) value = Number(raw);
|
|
397
|
+
if (value === null) return { ok: false, message: `Parameter "${name}" must be a number.` };
|
|
398
|
+
const { min = -Number.MAX_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = bounds;
|
|
399
|
+
if (value < min || value > max) {
|
|
400
|
+
return { ok: false, message: `Parameter "${name}" must be between ${min} and ${max}.` };
|
|
401
|
+
}
|
|
402
|
+
return { ok: true, value };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Read a required string parameter.
|
|
407
|
+
* @param {Record<string, unknown>} params
|
|
408
|
+
* @param {string} name
|
|
409
|
+
* @param {{maxLength?: number, allowControlChars?: boolean}} [options]
|
|
410
|
+
* @returns {{ok: true, value: string} | {ok: false, message: string}}
|
|
411
|
+
*/
|
|
412
|
+
function readString(params, name, options = {}) {
|
|
413
|
+
const raw = params[name];
|
|
414
|
+
const { maxLength = 1_024, allowControlChars = false } = options;
|
|
415
|
+
if (typeof raw !== 'string') return { ok: false, message: `Parameter "${name}" must be a string.` };
|
|
416
|
+
const value = raw.trim();
|
|
417
|
+
if (value === '') return { ok: false, message: `Parameter "${name}" must not be empty.` };
|
|
418
|
+
if (value.length > maxLength) {
|
|
419
|
+
return { ok: false, message: `Parameter "${name}" must be at most ${maxLength} characters.` };
|
|
420
|
+
}
|
|
421
|
+
if (!allowControlChars && CONTROL_CHARS.test(value)) {
|
|
422
|
+
return { ok: false, message: `Parameter "${name}" must not contain control characters.` };
|
|
423
|
+
}
|
|
424
|
+
return { ok: true, value };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Read a required boolean parameter (accepts `true`/`false` and their string
|
|
429
|
+
* spellings from an HTML form).
|
|
430
|
+
* @param {Record<string, unknown>} params
|
|
431
|
+
* @param {string} name
|
|
432
|
+
* @returns {{ok: true, value: boolean} | {ok: false, message: string}}
|
|
433
|
+
*/
|
|
434
|
+
function readBoolean(params, name) {
|
|
435
|
+
const raw = params[name];
|
|
436
|
+
if (typeof raw === 'boolean') return { ok: true, value: raw };
|
|
437
|
+
if (raw === 'true' || raw === '1' || raw === 1) return { ok: true, value: true };
|
|
438
|
+
if (raw === 'false' || raw === '0' || raw === 0) return { ok: true, value: false };
|
|
439
|
+
return { ok: false, message: `Parameter "${name}" must be a boolean.` };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Read a required select parameter.
|
|
444
|
+
* @param {Record<string, unknown>} params
|
|
445
|
+
* @param {string} name
|
|
446
|
+
* @param {readonly string[]} options
|
|
447
|
+
* @param {string} [fallback] default when absent.
|
|
448
|
+
* @returns {{ok: true, value: string} | {ok: false, message: string}}
|
|
449
|
+
*/
|
|
450
|
+
function readSelect(params, name, options, fallback) {
|
|
451
|
+
const raw = params[name];
|
|
452
|
+
if ((raw === undefined || raw === null || raw === '') && fallback !== undefined) {
|
|
453
|
+
return { ok: true, value: fallback };
|
|
454
|
+
}
|
|
455
|
+
if (typeof raw !== 'string' || !options.includes(raw)) {
|
|
456
|
+
return { ok: false, message: `Parameter "${name}" must be one of: ${options.join(', ')}.` };
|
|
457
|
+
}
|
|
458
|
+
return { ok: true, value: raw };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Normalise the request's params object.
|
|
463
|
+
* @param {unknown} value
|
|
464
|
+
* @returns {Record<string, unknown>}
|
|
465
|
+
*/
|
|
466
|
+
function paramsObject(value) {
|
|
467
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return /** @type {Record<string, unknown>} */ (value);
|
|
468
|
+
return {};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Read the `ids` parameter of `cleanup.delete`: an array (or comma separated
|
|
473
|
+
* string) of 1-20 unique ids from the cleanup allowlist. Unknown ids, caller
|
|
474
|
+
* paths, duplicates and oversized batches are refused before any filesystem
|
|
475
|
+
* call.
|
|
476
|
+
* @param {Record<string, unknown>} params
|
|
477
|
+
* @returns {{ok: true, value: string[]} | {ok: false, message: string}}
|
|
478
|
+
*/
|
|
479
|
+
function readCleanupIds(params) {
|
|
480
|
+
const raw = params.ids;
|
|
481
|
+
/** @type {unknown[]} */
|
|
482
|
+
let values;
|
|
483
|
+
if (Array.isArray(raw)) {
|
|
484
|
+
values = raw;
|
|
485
|
+
} else if (typeof raw === 'string') {
|
|
486
|
+
values = raw.split(',');
|
|
487
|
+
} else {
|
|
488
|
+
return {
|
|
489
|
+
ok: false,
|
|
490
|
+
message: 'Parameter "ids" must be an array of cleanup item ids (or a comma separated string).',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/** @type {string[]} */
|
|
494
|
+
const ids = [];
|
|
495
|
+
for (const value of values) {
|
|
496
|
+
if (typeof value !== 'string') return { ok: false, message: 'Cleanup item ids must be strings.' };
|
|
497
|
+
const id = value.trim();
|
|
498
|
+
if (id !== '') ids.push(id);
|
|
499
|
+
}
|
|
500
|
+
if (ids.length < 1 || ids.length > 20) {
|
|
501
|
+
return { ok: false, message: 'Parameter "ids" must contain 1 to 20 cleanup item ids.' };
|
|
502
|
+
}
|
|
503
|
+
const known = new Set(cleanupItems().map((item) => item.id));
|
|
504
|
+
const seen = new Set();
|
|
505
|
+
for (const id of ids) {
|
|
506
|
+
if (seen.has(id)) return { ok: false, message: `Duplicate cleanup item id: ${id}` };
|
|
507
|
+
seen.add(id);
|
|
508
|
+
if (!known.has(id)) return { ok: false, message: `Unknown cleanup item id: ${id}` };
|
|
509
|
+
}
|
|
510
|
+
return { ok: true, value: ids };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/* -------------------------------------------------------------------------- */
|
|
514
|
+
/* Filesystem guards */
|
|
515
|
+
/* -------------------------------------------------------------------------- */
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Resolve an existing path through symlinks.
|
|
519
|
+
* @param {string} raw
|
|
520
|
+
* @returns {Promise<{ok: true, path: string, stat: fs.Stats} | {ok: false, message: string, code?: string}>}
|
|
521
|
+
*/
|
|
522
|
+
async function resolveExisting(raw) {
|
|
523
|
+
const resolved = path.resolve(raw);
|
|
524
|
+
try {
|
|
525
|
+
const real = await fsp.realpath(resolved);
|
|
526
|
+
const stat = await fsp.stat(real);
|
|
527
|
+
return { ok: true, path: real, stat };
|
|
528
|
+
} catch (err) {
|
|
529
|
+
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : 'ENOENT';
|
|
530
|
+
return failure(`Path does not exist: ${resolved}`, code);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* True when `candidate` is `root` or lives beneath it (both already resolved).
|
|
536
|
+
* @param {string} candidate
|
|
537
|
+
* @param {string} root
|
|
538
|
+
* @returns {boolean}
|
|
539
|
+
*/
|
|
540
|
+
function isInside(candidate, root) {
|
|
541
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The three directories allowed as uninstall roots.
|
|
546
|
+
* @returns {string[]}
|
|
547
|
+
*/
|
|
548
|
+
function uninstallRoots() {
|
|
549
|
+
return ['/Applications', path.join(os.homedir(), 'Applications'), '/System/Applications'];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Resolve and guard an `.app` bundle for uninstall.
|
|
554
|
+
* @param {string} raw
|
|
555
|
+
* @param {string} mode
|
|
556
|
+
* @returns {Promise<{ok: true, path: string} | {ok: false, message: string, code?: string}>}
|
|
557
|
+
*/
|
|
558
|
+
async function guardedAppBundle(raw, mode) {
|
|
559
|
+
const resolved = await resolveExisting(raw);
|
|
560
|
+
if (!resolved.ok) return resolved;
|
|
561
|
+
if (!resolved.stat.isDirectory()) {
|
|
562
|
+
return failure(`Not an application bundle: ${resolved.path}`, 'NOT_A_BUNDLE');
|
|
563
|
+
}
|
|
564
|
+
if (!/\.app$/i.test(resolved.path)) {
|
|
565
|
+
return failure(`Path must end in ".app" after resolution: ${resolved.path}`, 'NOT_A_BUNDLE');
|
|
566
|
+
}
|
|
567
|
+
const roots = uninstallRoots();
|
|
568
|
+
const insideRoot = roots.some((root) => isInside(resolved.path, root) && resolved.path !== root);
|
|
569
|
+
if (!insideRoot) {
|
|
570
|
+
return failure(
|
|
571
|
+
`Refusing to uninstall outside ${roots.join(', ')}: ${resolved.path}`,
|
|
572
|
+
'PATH_NOT_ALLOWED',
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
if (resolved.path.startsWith('/System/')) {
|
|
576
|
+
return failure(`Refusing to uninstall a system application: ${resolved.path}`, 'SYSTEM_APP');
|
|
577
|
+
}
|
|
578
|
+
if (mode === 'delete' && resolved.path === '/Applications') {
|
|
579
|
+
return failure('Refusing to delete the /Applications directory itself.', 'PATH_NOT_ALLOWED');
|
|
580
|
+
}
|
|
581
|
+
return { ok: true, path: resolved.path };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Reserve a destination name inside the Trash atomically.
|
|
586
|
+
*
|
|
587
|
+
* `rename` overwrites an existing destination without complaint, so probing
|
|
588
|
+
* with `access()` first and then renaming is a race that can silently destroy
|
|
589
|
+
* whatever landed in between. Creating the destination ourselves first makes
|
|
590
|
+
* the reservation atomic: `mkdir` and `open('wx')` both fail with EEXIST when
|
|
591
|
+
* the name is taken, and the following `rename` only ever overwrites our own
|
|
592
|
+
* placeholder.
|
|
593
|
+
*
|
|
594
|
+
* @param {string} trash absolute Trash directory
|
|
595
|
+
* @param {string} stem desired base name
|
|
596
|
+
* @param {string} extension suffix to keep ('' for none, '.app' for a bundle)
|
|
597
|
+
* @param {boolean} isDirectory whether the source is a directory
|
|
598
|
+
* @returns {Promise<string>} the reserved absolute destination path
|
|
599
|
+
*/
|
|
600
|
+
async function reserveTrashName(trash, stem, extension, isDirectory) {
|
|
601
|
+
let attempt = 0;
|
|
602
|
+
while (attempt < 100) {
|
|
603
|
+
const suffix = attempt === 0 ? '' : `-${attempt}`;
|
|
604
|
+
const destination = path.join(trash, `${stem}-${Date.now()}${suffix}${extension}`);
|
|
605
|
+
try {
|
|
606
|
+
if (isDirectory) {
|
|
607
|
+
await fsp.mkdir(destination);
|
|
608
|
+
} else {
|
|
609
|
+
const handle = await fsp.open(destination, 'wx');
|
|
610
|
+
await handle.close();
|
|
611
|
+
}
|
|
612
|
+
return destination;
|
|
613
|
+
} catch (error) {
|
|
614
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
|
615
|
+
if (code === 'EEXIST') {
|
|
616
|
+
attempt += 1;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
throw Object.assign(new Error('Could not reserve a unique name in the Trash.'), { code: 'EEXIST' });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Move a file or directory into the user's Trash under a unique name.
|
|
627
|
+
*
|
|
628
|
+
* Portable across bundle and data-directory sources: the previous version
|
|
629
|
+
* hard-coded a `.app` suffix, so trashing `com.example.app` produced
|
|
630
|
+
* `com.example-1712345678901.app` — a data directory wearing an application
|
|
631
|
+
* name.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} source resolved absolute path
|
|
634
|
+
* @param {{trashDir?: string}} [options] `trashDir` exists only so offline tests
|
|
635
|
+
* can drive a sandbox instead of the real `~/.Trash`.
|
|
636
|
+
* @returns {Promise<string>} the destination path
|
|
637
|
+
*/
|
|
638
|
+
async function moveToTrash(source, options = {}) {
|
|
639
|
+
const trash = options.trashDir ?? path.join(os.homedir(), '.Trash');
|
|
640
|
+
await fsp.mkdir(trash, { recursive: true });
|
|
641
|
+
|
|
642
|
+
const base = path.basename(source);
|
|
643
|
+
const stat = await fsp.lstat(source);
|
|
644
|
+
const isDirectory = stat.isDirectory();
|
|
645
|
+
const extension = /\.app$/i.test(base) ? '.app' : '';
|
|
646
|
+
const stem = extension === '' ? base : base.slice(0, -extension.length);
|
|
647
|
+
|
|
648
|
+
const destination = await reserveTrashName(trash, stem, extension, isDirectory);
|
|
649
|
+
try {
|
|
650
|
+
// `rename` onto our own placeholder is atomic and cannot clobber a third
|
|
651
|
+
// party: the name was reserved a moment ago and nothing else can claim it.
|
|
652
|
+
await fsp.rename(source, destination);
|
|
653
|
+
return destination;
|
|
654
|
+
} catch (error) {
|
|
655
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
|
656
|
+
if (code !== 'EXDEV') {
|
|
657
|
+
// Release the placeholder so a failed move does not litter the Trash.
|
|
658
|
+
await fsp.rm(destination, { recursive: true, force: true }).catch(() => {});
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
// Cross-device: copy into the placeholder, then remove the source. The
|
|
662
|
+
// placeholder already exists, so `force: true` is safe here and keeps the
|
|
663
|
+
// partial copy from surviving a failure.
|
|
664
|
+
try {
|
|
665
|
+
await fsp.cp(source, destination, { recursive: true, force: true });
|
|
666
|
+
} catch (copyError) {
|
|
667
|
+
await fsp.rm(destination, { recursive: true, force: true }).catch(() => {});
|
|
668
|
+
throw copyError;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
await fsp.rm(source, { recursive: true, force: false, maxRetries: 2, retryDelay: 100 });
|
|
672
|
+
} catch (removeError) {
|
|
673
|
+
// The copy exists, so the data is safe; report the leftover so the caller
|
|
674
|
+
// can tell the user the original is still in place.
|
|
675
|
+
const reason = removeError instanceof Error ? removeError.message : String(removeError);
|
|
676
|
+
throw Object.assign(new Error(`Copied to Trash but could not remove the original: ${reason}`), {
|
|
677
|
+
code: 'PARTIAL_TRASH',
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
return destination;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Test seam: expose the Trash mover so offline tests can drive a sandbox. */
|
|
685
|
+
export const __trashTest = { moveToTrash, reserveTrashName };
|
|
686
|
+
|
|
687
|
+
/* -------------------------------------------------------------------------- */
|
|
688
|
+
/* Action runners */
|
|
689
|
+
/* -------------------------------------------------------------------------- */
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* @typedef {(params: Record<string, unknown>) => Promise<{ok: boolean, message: string, data?: object, code?: string}>} Runner
|
|
693
|
+
*/
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* The 1.6 footprint pipeline adapter for macOS.
|
|
697
|
+
*
|
|
698
|
+
* **No vendor uninstaller is ever executed here**, by design. macOS apps are
|
|
699
|
+
* uninstalled by dragging them to the Trash; the two helpers that exist on a
|
|
700
|
+
* real host ship as opaque binaries or scripts whose argument contract cannot
|
|
701
|
+
* be verified, and running one with a guessed flag risks a half-finished
|
|
702
|
+
* uninstall. So `hasUninstaller` is always false: the bundle goes to the Trash
|
|
703
|
+
* and the leftover list is offered separately. `uninstall.js` still supports
|
|
704
|
+
* the uninstaller path for platforms where it is verified (Windows
|
|
705
|
+
* MSI/winget/registry).
|
|
706
|
+
*
|
|
707
|
+
* @param {Record<string, unknown>} params validated-ish request params
|
|
708
|
+
* @param {string} appPath resolved bundle path
|
|
709
|
+
* @returns {Promise<object>} an ActionResult
|
|
710
|
+
*/
|
|
711
|
+
async function runDarwinUninstallPipeline(params, appPath) {
|
|
712
|
+
const scanId = typeof params.scanId === 'string' ? params.scanId : '';
|
|
713
|
+
const itemIds = params.itemIds;
|
|
714
|
+
const cleanup = params.cleanup === 'none' ? 'none' : 'trash';
|
|
715
|
+
|
|
716
|
+
let items = [];
|
|
717
|
+
if (cleanup === 'trash' && itemIds !== undefined && itemIds !== null) {
|
|
718
|
+
const resolved = resolveScanItems(scanId, itemIds);
|
|
719
|
+
if (resolved.ok !== true) return failure(resolved.message, resolved.code);
|
|
720
|
+
items = resolved.items;
|
|
721
|
+
if (resolved.appPath !== '' && resolved.appPath !== appPath) {
|
|
722
|
+
return failure('The scan belongs to a different application.', 'SCAN_MISMATCH');
|
|
723
|
+
}
|
|
724
|
+
} else if (cleanup === 'trash' && itemIds !== undefined) {
|
|
725
|
+
return failure('itemIds must be an array of scan item ids.', 'BAD_PARAMS');
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Refuse to touch a running app: its files would be recreated and the moves
|
|
729
|
+
// would fail half-way through.
|
|
730
|
+
const running = await isBundleRunning(appPath);
|
|
731
|
+
if (running !== null) {
|
|
732
|
+
return failure(
|
|
733
|
+
`"${running}" is still running. Quit it before uninstalling so its files are not recreated.`,
|
|
734
|
+
'APP_RUNNING',
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const report = await runUninstallPipeline(
|
|
739
|
+
{
|
|
740
|
+
appPath,
|
|
741
|
+
hasUninstaller: false,
|
|
742
|
+
cleanupApp: true,
|
|
743
|
+
items,
|
|
744
|
+
leftoverBytes: items.reduce((sum, item) => sum + (Number(item.bytes) || 0), 0),
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
async uninstallApp() {
|
|
748
|
+
// Unreachable: `hasUninstaller: false` above. Implemented as a refusal
|
|
749
|
+
// so a future change cannot quietly start executing a bundle helper.
|
|
750
|
+
return {
|
|
751
|
+
ok: false,
|
|
752
|
+
message: 'macOS uninstalls by moving the bundle to the Trash; no vendor uninstaller is executed.',
|
|
753
|
+
code: 'NO_UNINSTALLER',
|
|
754
|
+
};
|
|
755
|
+
},
|
|
756
|
+
async isStillInstalled(target) {
|
|
757
|
+
try {
|
|
758
|
+
await fsp.lstat(target);
|
|
759
|
+
return true;
|
|
760
|
+
} catch {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
async trashApp(target) {
|
|
765
|
+
try {
|
|
766
|
+
const destination = await moveToTrash(target);
|
|
767
|
+
return { ok: true, message: `Moved to Trash as ${path.basename(destination)}.`, destination };
|
|
768
|
+
} catch (error) {
|
|
769
|
+
return {
|
|
770
|
+
ok: false,
|
|
771
|
+
message: error instanceof Error ? error.message : String(error),
|
|
772
|
+
code: error && typeof error === 'object' && 'code' in error ? String(error.code) : 'TRASH_FAILED',
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
async trashPath(target) {
|
|
777
|
+
try {
|
|
778
|
+
const destination = await moveToTrash(target);
|
|
779
|
+
return { ok: true, message: `Moved to Trash as ${path.basename(destination)}.`, destination };
|
|
780
|
+
} catch (error) {
|
|
781
|
+
return {
|
|
782
|
+
ok: false,
|
|
783
|
+
message: error instanceof Error ? error.message : String(error),
|
|
784
|
+
code: error && typeof error === 'object' && 'code' in error ? String(error.code) : 'TRASH_FAILED',
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
async rescan(target) {
|
|
789
|
+
return scanFootprint({ path: target, fresh: true });
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
if (report.ok === true) consumeToken(scanId);
|
|
795
|
+
|
|
796
|
+
const data = { ...report };
|
|
797
|
+
if (report.ok === true) return success(report.message, data);
|
|
798
|
+
return { ok: false, message: report.message, code: report.aborted ? 'ABORTED' : 'PARTIAL', data };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* The display name of a running app whose bundle path matches, or null.
|
|
803
|
+
*
|
|
804
|
+
* Uses `ps` rather than `NSRunningApplication` because the whole host half is
|
|
805
|
+
* argv-only `execFile`; the bundle path appears in the process command line for
|
|
806
|
+
* every app launched by LaunchServices.
|
|
807
|
+
*
|
|
808
|
+
* @param {string} appPath
|
|
809
|
+
* @returns {Promise<string|null>}
|
|
810
|
+
*/
|
|
811
|
+
async function isBundleRunning(appPath) {
|
|
812
|
+
const result = await run('/bin/ps', ['-Ao', 'command='], { timeout: 8_000, maxBuffer: 8 * 1024 * 1024 });
|
|
813
|
+
if (!result.ok) return null;
|
|
814
|
+
const needle = `${appPath}/Contents/MacOS/`;
|
|
815
|
+
for (const line of String(result.stdout ?? '').split('\n')) {
|
|
816
|
+
if (line.includes(needle)) return path.basename(appPath);
|
|
817
|
+
}
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** @type {Record<string, Runner>} */
|
|
822
|
+
const RUNNERS = {
|
|
823
|
+
async 'process.signal'(params) {
|
|
824
|
+
const pidRead = readInteger(params, 'pid', { min: 2, max: 4_194_304 });
|
|
825
|
+
if (!pidRead.ok) return failure(pidRead.message, 'BAD_PARAMS');
|
|
826
|
+
const signalRead = readSelect(params, 'signal', [...SIGNAL_NAMES.keys()], 'TERM');
|
|
827
|
+
if (!signalRead.ok) return failure(signalRead.message, 'BAD_PARAMS');
|
|
828
|
+
const pid = pidRead.value;
|
|
829
|
+
if (pid === process.pid || pid === process.ppid) {
|
|
830
|
+
return failure(`Refusing to signal the Device X host process (pid ${pid}).`, 'PROTECTED_PID');
|
|
831
|
+
}
|
|
832
|
+
if (pid === 1) return failure('Refusing to signal launchd (pid 1).', 'PROTECTED_PID');
|
|
833
|
+
const signal = SIGNAL_NAMES.get(signalRead.value);
|
|
834
|
+
try {
|
|
835
|
+
process.kill(pid, signal);
|
|
836
|
+
return success(`Sent SIG${signalRead.value} to pid ${pid}.`, { pid, signal: signalRead.value });
|
|
837
|
+
} catch (err) {
|
|
838
|
+
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : 'ESRCH';
|
|
839
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
840
|
+
return failure(`Could not signal pid ${pid} (${code}): ${reason}`, code);
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
|
|
844
|
+
async 'app.quit'(params) {
|
|
845
|
+
const nameRead = readString(params, 'name', { maxLength: 200 });
|
|
846
|
+
if (!nameRead.ok) return failure(nameRead.message, 'BAD_PARAMS');
|
|
847
|
+
const escaped = nameRead.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
848
|
+
const result = await run(OSASCRIPT, ['-e', `tell application "${escaped}" to quit`], { timeout: 20_000 });
|
|
849
|
+
if (!result.ok) return commandFailure(`Quit "${nameRead.value}"`, result);
|
|
850
|
+
return success(`Asked "${nameRead.value}" to quit.`, { name: nameRead.value });
|
|
851
|
+
},
|
|
852
|
+
|
|
853
|
+
async 'app.launch'(params) {
|
|
854
|
+
const pathRead = readString(params, 'path', { maxLength: 1_024 });
|
|
855
|
+
if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
|
|
856
|
+
const resolved = await resolveExisting(pathRead.value);
|
|
857
|
+
if (!resolved.ok) return resolved;
|
|
858
|
+
const result = await run(OPEN, ['-a', resolved.path], { timeout: 15_000 });
|
|
859
|
+
if (!result.ok) return commandFailure(`Launch ${resolved.path}`, result);
|
|
860
|
+
return success(`Launched ${path.basename(resolved.path)}.`, { path: resolved.path });
|
|
861
|
+
},
|
|
862
|
+
|
|
863
|
+
async 'app.reveal'(params) {
|
|
864
|
+
const pathRead = readString(params, 'path', { maxLength: 1_024 });
|
|
865
|
+
if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
|
|
866
|
+
const resolved = await resolveExisting(pathRead.value);
|
|
867
|
+
if (!resolved.ok) return resolved;
|
|
868
|
+
const result = await run(OPEN, ['-R', resolved.path], { timeout: 15_000 });
|
|
869
|
+
if (!result.ok) return commandFailure(`Reveal ${resolved.path}`, result);
|
|
870
|
+
return success(`Revealed ${path.basename(resolved.path)} in Finder.`, { path: resolved.path });
|
|
871
|
+
},
|
|
872
|
+
|
|
873
|
+
async 'app.uninstall'(params) {
|
|
874
|
+
const pathRead = readString(params, 'path', { maxLength: 1_024 });
|
|
875
|
+
if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
|
|
876
|
+
const modeRead = readSelect(params, 'mode', ['trash', 'delete'], 'trash');
|
|
877
|
+
if (!modeRead.ok) return failure(modeRead.message, 'BAD_PARAMS');
|
|
878
|
+
const guarded = await guardedAppBundle(pathRead.value, modeRead.value);
|
|
879
|
+
if (!guarded.ok) return guarded;
|
|
880
|
+
|
|
881
|
+
// 1.6: when a scan id is present the caller wants the footprint pipeline.
|
|
882
|
+
// Without one, behaviour is byte-for-byte the pre-1.6 single step.
|
|
883
|
+
if (typeof params.scanId === 'string' && params.scanId !== '') {
|
|
884
|
+
return runDarwinUninstallPipeline(params, guarded.path);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const name = path.basename(guarded.path);
|
|
888
|
+
if (modeRead.value === 'trash') {
|
|
889
|
+
try {
|
|
890
|
+
const destination = await moveToTrash(guarded.path);
|
|
891
|
+
return success(`Moved ${name} to Trash as ${path.basename(destination)}.`, {
|
|
892
|
+
trashedPath: destination,
|
|
893
|
+
mode: 'trash',
|
|
894
|
+
});
|
|
895
|
+
} catch (err) {
|
|
896
|
+
return failure(
|
|
897
|
+
`Could not move ${name} to Trash: ${err instanceof Error ? err.message : String(err)}`,
|
|
898
|
+
'TRASH_FAILED',
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
try {
|
|
903
|
+
await fsp.rm(guarded.path, { recursive: true, force: false, maxRetries: 3, retryDelay: 150 });
|
|
904
|
+
return success(`Deleted ${name}.`, { path: guarded.path, mode: 'delete' });
|
|
905
|
+
} catch (err) {
|
|
906
|
+
return failure(
|
|
907
|
+
`Could not delete ${name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
908
|
+
'DELETE_FAILED',
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
|
|
913
|
+
async 'system.setHostname'(params) {
|
|
914
|
+
const kindRead = readSelect(params, 'kind', ['HostName', 'ComputerName', 'LocalHostName']);
|
|
915
|
+
if (!kindRead.ok) return failure(kindRead.message, 'BAD_PARAMS');
|
|
916
|
+
const valueRead = readString(params, 'value', { maxLength: 63 });
|
|
917
|
+
if (!valueRead.ok) return failure(valueRead.message, 'BAD_PARAMS');
|
|
918
|
+
if (!HOSTNAME_PATTERN.test(valueRead.value)) {
|
|
919
|
+
return failure(
|
|
920
|
+
'Hostname value must be 1-63 letters, digits, dots or dashes (no spaces or underscores).',
|
|
921
|
+
'BAD_PARAMS',
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
const result = await run(SCUTIL, ['--set', kindRead.value, valueRead.value], { timeout: 10_000 });
|
|
925
|
+
if (!result.ok) return commandFailure(`Set ${kindRead.value}`, result);
|
|
926
|
+
return success(`Set ${kindRead.value} to "${valueRead.value}".`, {
|
|
927
|
+
kind: kindRead.value,
|
|
928
|
+
value: valueRead.value,
|
|
929
|
+
});
|
|
930
|
+
},
|
|
931
|
+
|
|
932
|
+
async 'system.setVolume'(params) {
|
|
933
|
+
const percentRead = readNumber(params, 'percent', { min: 0, max: 100 });
|
|
934
|
+
if (!percentRead.ok) return failure(percentRead.message, 'BAD_PARAMS');
|
|
935
|
+
const percent = Math.round(percentRead.value);
|
|
936
|
+
const result = await run(OSASCRIPT, ['-e', `set volume output volume ${percent}`], { timeout: 10_000 });
|
|
937
|
+
if (!result.ok) return commandFailure('Set volume', result);
|
|
938
|
+
return success(`Set output volume to ${percent}%.`, { percent });
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
async 'system.setDarkMode'(params) {
|
|
942
|
+
const enabledRead = readBoolean(params, 'enabled');
|
|
943
|
+
if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
|
|
944
|
+
const script =
|
|
945
|
+
'tell application "System Events" to tell appearance preferences to set dark mode to ' +
|
|
946
|
+
(enabledRead.value ? 'true' : 'false');
|
|
947
|
+
const result = await run(OSASCRIPT, ['-e', script], { timeout: 10_000 });
|
|
948
|
+
if (!result.ok) return commandFailure('Set dark mode', result);
|
|
949
|
+
return success(`Dark mode ${enabledRead.value ? 'enabled' : 'disabled'}.`, { enabled: enabledRead.value });
|
|
950
|
+
},
|
|
951
|
+
|
|
952
|
+
async 'system.launchAgent'(params) {
|
|
953
|
+
const pathRead = readString(params, 'path', { maxLength: 1_024 });
|
|
954
|
+
if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
|
|
955
|
+
const enabledRead = readBoolean(params, 'enabled');
|
|
956
|
+
if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
|
|
957
|
+
const resolved = await resolveExisting(pathRead.value);
|
|
958
|
+
if (!resolved.ok) return resolved;
|
|
959
|
+
if (!/\.plist$/i.test(resolved.path)) {
|
|
960
|
+
return failure(`Launch agent path must end in ".plist": ${resolved.path}`, 'NOT_A_PLIST');
|
|
961
|
+
}
|
|
962
|
+
const home = os.homedir();
|
|
963
|
+
const allowed = [
|
|
964
|
+
path.join(home, 'Library', 'LaunchAgents'),
|
|
965
|
+
'/Library/LaunchAgents',
|
|
966
|
+
'/Library/LaunchDaemons',
|
|
967
|
+
];
|
|
968
|
+
if (!allowed.some((root) => isInside(resolved.path, root) && resolved.path !== root)) {
|
|
969
|
+
return failure(
|
|
970
|
+
`Refusing to manage a launch agent outside ${allowed.join(', ')}: ${resolved.path}`,
|
|
971
|
+
'PATH_NOT_ALLOWED',
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
const daemon = resolved.path.startsWith('/Library/LaunchDaemons');
|
|
975
|
+
const domain = daemon ? 'system' : `gui/${typeof process.getuid === 'function' ? process.getuid() : 501}`;
|
|
976
|
+
const verb = enabledRead.value ? 'bootstrap' : 'bootout';
|
|
977
|
+
const result = await run(LAUNCHCTL, [verb, domain, resolved.path], { timeout: 20_000 });
|
|
978
|
+
if (!result.ok) return commandFailure(`${enabledRead.value ? 'Load' : 'Unload'} launch agent`, result);
|
|
979
|
+
return success(
|
|
980
|
+
`${enabledRead.value ? 'Loaded' : 'Unloaded'} launch agent ${path.basename(resolved.path)}.`,
|
|
981
|
+
{ path: resolved.path, domain, enabled: enabledRead.value },
|
|
982
|
+
);
|
|
983
|
+
},
|
|
984
|
+
|
|
985
|
+
async 'system.openSettings'(params) {
|
|
986
|
+
const paneRead = readSelect(params, 'pane', Object.keys(SETTINGS_PANES));
|
|
987
|
+
if (!paneRead.ok) return failure(paneRead.message, 'BAD_PARAMS');
|
|
988
|
+
const anchor = SETTINGS_PANES[paneRead.value];
|
|
989
|
+
const url = `x-apple.systempreferences:${anchor}`;
|
|
990
|
+
const result = await run(OPEN, [url], { timeout: 10_000 });
|
|
991
|
+
if (!result.ok) return commandFailure('Open System Settings', result);
|
|
992
|
+
return success(`Opened System Settings (${paneRead.value}).`, { pane: paneRead.value, url });
|
|
993
|
+
},
|
|
994
|
+
|
|
995
|
+
async 'system.toggleFirewall'(params) {
|
|
996
|
+
const enabledRead = readBoolean(params, 'enabled');
|
|
997
|
+
if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
|
|
998
|
+
const state = enabledRead.value ? 'on' : 'off';
|
|
999
|
+
const result = await run(SOCKETFILTERFW, ['--setglobalstate', state], { timeout: 15_000 });
|
|
1000
|
+
if (!result.ok) return commandFailure(`Turn firewall ${state}`, result);
|
|
1001
|
+
return success(`Requested firewall ${state}.`, { enabled: enabledRead.value });
|
|
1002
|
+
},
|
|
1003
|
+
|
|
1004
|
+
async 'system.flushDNS'(_params) {
|
|
1005
|
+
const result = await run(DSCACHEUTIL, ['-flushcache'], { timeout: 10_000 });
|
|
1006
|
+
if (!result.ok) return commandFailure('Flush DNS cache', result);
|
|
1007
|
+
return success(
|
|
1008
|
+
'Flushed the DNS cache. If lookups still fail, restart mDNSResponder with: sudo killall -HUP mDNSResponder',
|
|
1009
|
+
{ mDNSResponderRestarted: false },
|
|
1010
|
+
);
|
|
1011
|
+
},
|
|
1012
|
+
|
|
1013
|
+
async 'system.emptyTrash'(_params) {
|
|
1014
|
+
const trash = path.join(os.homedir(), '.Trash');
|
|
1015
|
+
let entries;
|
|
1016
|
+
try {
|
|
1017
|
+
entries = await fsp.readdir(trash);
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
return failure(
|
|
1020
|
+
`Could not read Trash: ${err instanceof Error ? err.message : String(err)}`,
|
|
1021
|
+
'TRASH_READ_FAILED',
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
let removed = 0;
|
|
1025
|
+
/** @type {string[]} */
|
|
1026
|
+
const failed = [];
|
|
1027
|
+
for (const entry of entries) {
|
|
1028
|
+
const target = path.join(trash, entry);
|
|
1029
|
+
if (!isInside(target, trash)) continue;
|
|
1030
|
+
try {
|
|
1031
|
+
await fsp.rm(target, { recursive: true, force: true, maxRetries: 1, retryDelay: 100 });
|
|
1032
|
+
removed += 1;
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
failed.push(`${entry}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (failed.length) {
|
|
1038
|
+
return failure(
|
|
1039
|
+
`Removed ${removed} of ${entries.length} item(s) from Trash. Failures: ${failed.slice(0, 3).join('; ')}`,
|
|
1040
|
+
'TRASH_PARTIAL',
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
return success(`Removed ${removed} item(s) from Trash.`, { removed });
|
|
1044
|
+
},
|
|
1045
|
+
|
|
1046
|
+
async 'cleanup.delete'(params) {
|
|
1047
|
+
const idsRead = readCleanupIds(params);
|
|
1048
|
+
if (!idsRead.ok) return failure(idsRead.message, 'BAD_PARAMS');
|
|
1049
|
+
// The cleanup module owns the allowlist and the fixed absolute paths: the
|
|
1050
|
+
// action passes ids only, never a path, and reports per-item outcomes.
|
|
1051
|
+
return deleteCleanup(idsRead.value);
|
|
1052
|
+
},
|
|
1053
|
+
|
|
1054
|
+
async 'network.setWifiPower'(params) {
|
|
1055
|
+
const enabledRead = readBoolean(params, 'enabled');
|
|
1056
|
+
if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
|
|
1057
|
+
const ports = await run(NETWORKSETUP, ['-listallhardwareports'], { timeout: 10_000 });
|
|
1058
|
+
let device = 'en0';
|
|
1059
|
+
if (ports.ok) {
|
|
1060
|
+
const blocks = ports.stdout.split(/\n\s*\n/);
|
|
1061
|
+
for (const block of blocks) {
|
|
1062
|
+
if (!/Hardware Port:\s*Wi-?Fi/i.test(block)) continue;
|
|
1063
|
+
const match = /Device:\s*(\S+)/.exec(block);
|
|
1064
|
+
if (match) {
|
|
1065
|
+
device = match[1];
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const state = enabledRead.value ? 'on' : 'off';
|
|
1071
|
+
const result = await run(NETWORKSETUP, ['-setairportpower', device, state], { timeout: 15_000 });
|
|
1072
|
+
if (!result.ok) return commandFailure(`Turn Wi-Fi ${state} (${device})`, result);
|
|
1073
|
+
return success(`Requested Wi-Fi ${state} on ${device}.`, { device, enabled: enabledRead.value });
|
|
1074
|
+
},
|
|
1075
|
+
|
|
1076
|
+
async 'network.setDns'(params) {
|
|
1077
|
+
const serviceRead = readString(params, 'service', { maxLength: 64 });
|
|
1078
|
+
if (!serviceRead.ok) return failure(serviceRead.message, 'BAD_PARAMS');
|
|
1079
|
+
if (CONTROL_CHARS.test(serviceRead.value) || serviceRead.value.startsWith('-')) {
|
|
1080
|
+
return failure('Parameter "service" is not a valid network service name.', 'BAD_PARAMS');
|
|
1081
|
+
}
|
|
1082
|
+
const rawServers = params.servers;
|
|
1083
|
+
/** @type {string[]} */
|
|
1084
|
+
let servers;
|
|
1085
|
+
if (Array.isArray(rawServers)) {
|
|
1086
|
+
servers = rawServers.map((item) => String(item).trim()).filter((item) => item !== '');
|
|
1087
|
+
} else if (typeof rawServers === 'string') {
|
|
1088
|
+
servers = rawServers
|
|
1089
|
+
.split(/[,\s]+/)
|
|
1090
|
+
.map((item) => item.trim())
|
|
1091
|
+
.filter((item) => item !== '');
|
|
1092
|
+
} else {
|
|
1093
|
+
return failure('Parameter "servers" must be an array of IP addresses (or a comma separated string).', 'BAD_PARAMS');
|
|
1094
|
+
}
|
|
1095
|
+
if (servers.length > 4) return failure('At most 4 DNS servers are allowed.', 'BAD_PARAMS');
|
|
1096
|
+
for (const server of servers) {
|
|
1097
|
+
if (!net.isIP(server)) return failure(`Invalid IP address: ${server}`, 'BAD_PARAMS');
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// The page may name either the networksetup service ("Wi-Fi", a hardware
|
|
1101
|
+
// port name) or the BSD interface device ("en0"); resolve to the service
|
|
1102
|
+
// networksetup accepts before touching anything.
|
|
1103
|
+
let serviceName = serviceRead.value;
|
|
1104
|
+
const servicesRes = await run(NETWORKSETUP, ['-listallnetworkservices'], { timeout: 10_000 });
|
|
1105
|
+
if (servicesRes.ok) {
|
|
1106
|
+
const known = servicesRes.stdout
|
|
1107
|
+
.split(/\r?\n/)
|
|
1108
|
+
.map((line) => line.replace(/^\*/, '').trim())
|
|
1109
|
+
.filter((line) => line && !line.startsWith('An asterisk'));
|
|
1110
|
+
const exact =
|
|
1111
|
+
known.find((name) => name === serviceName) ??
|
|
1112
|
+
known.find((name) => name.toLowerCase() === serviceName.toLowerCase());
|
|
1113
|
+
if (exact !== undefined) {
|
|
1114
|
+
serviceName = exact;
|
|
1115
|
+
} else {
|
|
1116
|
+
let mapped;
|
|
1117
|
+
const portsRes = await run(NETWORKSETUP, ['-listallhardwareports'], { timeout: 10_000 });
|
|
1118
|
+
if (portsRes.ok) {
|
|
1119
|
+
for (const block of portsRes.stdout.split(/\n\s*\n/)) {
|
|
1120
|
+
const port = /Hardware Port:\s*(.+)/.exec(block);
|
|
1121
|
+
const device = /Device:\s*(\S+)/.exec(block);
|
|
1122
|
+
if (port === null || device === null) continue;
|
|
1123
|
+
const portName = port[1].trim();
|
|
1124
|
+
if (device[1] !== serviceName && portName.toLowerCase() !== serviceName.toLowerCase()) continue;
|
|
1125
|
+
mapped = known.find((name) => name.toLowerCase() === portName.toLowerCase());
|
|
1126
|
+
if (mapped !== undefined) break;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (mapped === undefined) {
|
|
1130
|
+
return failure(
|
|
1131
|
+
`Unknown network service: ${serviceName}. Known services: ${known.join(', ') || '(none)'}`,
|
|
1132
|
+
'UNKNOWN_SERVICE',
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
serviceName = mapped;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
const args = ['-setdnsservers', serviceName, ...(servers.length ? servers : ['Empty'])];
|
|
1139
|
+
const result = await run(NETWORKSETUP, args, { timeout: 20_000 });
|
|
1140
|
+
if (!result.ok) return commandFailure('Set DNS servers', result);
|
|
1141
|
+
return success(
|
|
1142
|
+
servers.length
|
|
1143
|
+
? `Set DNS servers for ${serviceName} to ${servers.join(', ')}.`
|
|
1144
|
+
: `Cleared DNS servers for ${serviceName}.`,
|
|
1145
|
+
{ service: serviceName, servers },
|
|
1146
|
+
);
|
|
1147
|
+
},
|
|
1148
|
+
|
|
1149
|
+
async 'power.sleep'(_params) {
|
|
1150
|
+
const result = await run(PMSET, ['sleepnow'], { timeout: 10_000 });
|
|
1151
|
+
if (!result.ok) return commandFailure('Sleep', result);
|
|
1152
|
+
return success('Requested system sleep.');
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1155
|
+
async 'power.lock'(_params) {
|
|
1156
|
+
const script =
|
|
1157
|
+
'tell application "System Events" to keystroke "q" using {control down, command down}';
|
|
1158
|
+
const result = await run(OSASCRIPT, ['-e', script], { timeout: 10_000 });
|
|
1159
|
+
if (!result.ok) return commandFailure('Lock screen', result);
|
|
1160
|
+
return success('Requested screen lock.');
|
|
1161
|
+
},
|
|
1162
|
+
|
|
1163
|
+
async 'software.brewUninstall'(params) {
|
|
1164
|
+
const nameRead = readString(params, 'name', { maxLength: 128 });
|
|
1165
|
+
if (!nameRead.ok) return failure(nameRead.message, 'BAD_PARAMS');
|
|
1166
|
+
const name = nameRead.value;
|
|
1167
|
+
if (!BREW_TOKEN_PATTERN.test(name) || name.startsWith('-') || name.includes('..')) {
|
|
1168
|
+
return failure(
|
|
1169
|
+
'Brew formula name may only contain letters, digits and @ + . _ / - (no spaces or leading dash).',
|
|
1170
|
+
'BAD_PARAMS',
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
let brewPath = null;
|
|
1174
|
+
for (const candidate of BREW_CANDIDATES) {
|
|
1175
|
+
try {
|
|
1176
|
+
if (fs.existsSync(candidate)) {
|
|
1177
|
+
brewPath = candidate;
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
1180
|
+
} catch {
|
|
1181
|
+
/* keep looking */
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (!brewPath) return failure('Homebrew is not installed on this machine.', 'NO_BREW');
|
|
1185
|
+
const result = await run(brewPath, ['uninstall', name], { timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1186
|
+
if (!result.ok) return commandFailure(`brew uninstall ${name}`, result);
|
|
1187
|
+
return success(`Uninstalled Homebrew formula "${name}".`, { name });
|
|
1188
|
+
},
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
/* -------------------------------------------------------------------------- */
|
|
1192
|
+
/* Public API */
|
|
1193
|
+
/* -------------------------------------------------------------------------- */
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* The frozen action catalog, in stable order.
|
|
1197
|
+
* @returns {Array<{id: string, group: string, danger: string,
|
|
1198
|
+
* requiresAdmin: boolean, params: ActionParam[]}>}
|
|
1199
|
+
*/
|
|
1200
|
+
export function actionCatalog() {
|
|
1201
|
+
return SPECS.map((spec) => ({
|
|
1202
|
+
id: spec.id,
|
|
1203
|
+
group: spec.group,
|
|
1204
|
+
danger: spec.danger,
|
|
1205
|
+
requiresAdmin: spec.requiresAdmin,
|
|
1206
|
+
params: spec.params.map((descriptor) => ({ ...descriptor, ...(descriptor.options ? { options: [...descriptor.options] } : {}) })),
|
|
1207
|
+
}));
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Attempted actions, newest first, at most 50 entries.
|
|
1212
|
+
* @returns {Array<{ts: number, id: string, params: object, ok: boolean, message: string}>}
|
|
1213
|
+
*/
|
|
1214
|
+
export function actionHistory() {
|
|
1215
|
+
return history.map((entry) => ({
|
|
1216
|
+
ts: entry.ts,
|
|
1217
|
+
id: entry.id,
|
|
1218
|
+
params: jsonSafe(entry.params),
|
|
1219
|
+
ok: entry.ok,
|
|
1220
|
+
message: entry.message,
|
|
1221
|
+
}));
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Validate and execute one action. Never throws for bad input; every attempt
|
|
1226
|
+
* (success or failure) is appended to the history.
|
|
1227
|
+
*
|
|
1228
|
+
* @param {string} id action id from {@link actionCatalog}
|
|
1229
|
+
* @param {object} [params]
|
|
1230
|
+
* @returns {Promise<{ok: boolean, message: string, data?: object, code?: string}>}
|
|
1231
|
+
*/
|
|
1232
|
+
export async function runAction(id, params) {
|
|
1233
|
+
const ts = Date.now();
|
|
1234
|
+
const actionId = typeof id === 'string' ? id : String(id);
|
|
1235
|
+
const requestParams = paramsObject(jsonSafe(params));
|
|
1236
|
+
let result;
|
|
1237
|
+
try {
|
|
1238
|
+
const spec = SPEC_BY_ID.get(actionId);
|
|
1239
|
+
const runner = RUNNERS[actionId];
|
|
1240
|
+
if (!spec || !runner) {
|
|
1241
|
+
result = failure(`Unknown action: ${actionId}`, 'UNKNOWN_ACTION');
|
|
1242
|
+
} else {
|
|
1243
|
+
const candidate = await runner(requestParams);
|
|
1244
|
+
result =
|
|
1245
|
+
candidate && typeof candidate === 'object' && typeof candidate.ok === 'boolean'
|
|
1246
|
+
? { ok: candidate.ok === true, message: String(candidate.message ?? ''), ...(candidate.data ? { data: jsonSafe(candidate.data) } : {}), ...(candidate.code ? { code: String(candidate.code) } : {}) }
|
|
1247
|
+
: failure('Action returned an invalid result.', 'INTERNAL');
|
|
1248
|
+
}
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
result = failure(`Action failed unexpectedly: ${err instanceof Error ? err.message : String(err)}`, 'INTERNAL');
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
recordHistory({ ts, id: actionId, params: requestParams, ok: result.ok === true, message: result.message });
|
|
1254
|
+
return jsonSafe(result);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Test seam for `scripts/verify-cleanup.mjs`: the pure cleanup-id validator
|
|
1259
|
+
* (no filesystem access, never deletes anything).
|
|
1260
|
+
*/
|
|
1261
|
+
export const __test = { readCleanupIds };
|