@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.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * The Device X browser API: same-origin JSON routes on the DSH Web server,
3
+ * each behind the shipped Connection trust fence (the page's signed session
4
+ * cookie authenticates the caller; foreign origins are rejected by the
5
+ * Host/Origin fence). Route handlers never shell out themselves — they call
6
+ * the shared operation surface, exactly like the agent tools.
7
+ *
8
+ * @module @cfour-hi/dsh-plugin-devicex/host/transport
9
+ */
10
+
11
+ /** URL prefix owned by this plugin. */
12
+ const API_PREFIX = '/devicex/api';
13
+
14
+ /** Action request bodies are tiny JSON objects; anything larger is hostile. */
15
+ const MAX_BODY_BYTES = 256 * 1024;
16
+
17
+ /** Reject malformed or unknown section names before they reach collection. */
18
+ function isSection(value) {
19
+ return typeof value === 'string' && value.length > 0;
20
+ }
21
+
22
+ /** JSON response, always uncached: device facts are live facts. */
23
+ function sendJson(res, status, payload) {
24
+ res.statusCode = status;
25
+ res.setHeader('content-type', 'application/json; charset=utf-8');
26
+ res.setHeader('cache-control', 'no-store');
27
+ res.end(JSON.stringify(payload));
28
+ }
29
+
30
+ /** 405 with the route's one supported method. */
31
+ function sendMethodNotAllowed(res, allow) {
32
+ res.statusCode = 405;
33
+ res.setHeader('allow', allow);
34
+ res.end();
35
+ }
36
+
37
+ /** Collect a bounded request body as UTF-8 text; null past the ceiling. */
38
+ async function readBoundedBody(req) {
39
+ const chunks = [];
40
+ let size = 0;
41
+ for await (const chunk of req) {
42
+ size += chunk.byteLength;
43
+ if (size > MAX_BODY_BYTES) {
44
+ req.resume();
45
+ return null;
46
+ }
47
+ chunks.push(chunk);
48
+ }
49
+ return Buffer.concat(chunks, size).toString('utf8');
50
+ }
51
+
52
+ /** Apply the connection trust fence; true when the request was rejected. */
53
+ function rejected(ctx, req, res) {
54
+ const rejection = ctx.connection.requestRejection(req);
55
+ if (rejection === undefined) return false;
56
+ res.statusCode = rejection;
57
+ res.end();
58
+ return true;
59
+ }
60
+
61
+ /** Parse the request URL against a placeholder origin (only path/query matter). */
62
+ function requestUrl(req) {
63
+ try {
64
+ return new URL(String(req.url), 'http://localhost');
65
+ } catch {
66
+ return new URL('http://localhost/');
67
+ }
68
+ }
69
+
70
+ /** Answer a GET/HEAD route, containing any handler failure as a 500. */
71
+ function guarded(handler) {
72
+ return async (req, res) => {
73
+ try {
74
+ await handler(req, res);
75
+ } catch (error) {
76
+ if (!res.headersSent) {
77
+ sendJson(res, 500, {
78
+ ok: false,
79
+ error: {
80
+ code: 'internal',
81
+ message: error instanceof Error ? error.message : String(error),
82
+ },
83
+ });
84
+ } else {
85
+ res.destroy();
86
+ }
87
+ }
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Register every Device X route as one disposable effect.
93
+ * @param {import('../../types/contracts.js').PluginContext} ctx - the plugin context carrying `webServer` and `connection`.
94
+ * @param {import('../../types/contracts.js').PluginOperations} operations - the shared operation surface.
95
+ * @returns the disposer removing every route.
96
+ */
97
+ export function registerRoutes(ctx, operations) {
98
+ const disposers = [];
99
+
100
+ disposers.push(ctx.webServer.register({
101
+ kind: 'exact',
102
+ path: `${API_PREFIX}/metrics`,
103
+ handler: guarded(async (req, res) => {
104
+ if (rejected(ctx, req, res)) return;
105
+ if (req.method !== 'GET') return sendMethodNotAllowed(res, 'GET');
106
+ const metrics = await operations.collectMetrics();
107
+ sendJson(res, 200, { ok: true, metrics });
108
+ }),
109
+ }));
110
+
111
+ disposers.push(ctx.webServer.register({
112
+ kind: 'exact',
113
+ path: `${API_PREFIX}/inventory`,
114
+ handler: guarded(async (req, res) => {
115
+ if (rejected(ctx, req, res)) return;
116
+ if (req.method !== 'GET') return sendMethodNotAllowed(res, 'GET');
117
+ const url = requestUrl(req);
118
+ const section = url.searchParams.get('section');
119
+ const fresh = url.searchParams.get('fresh') === '1';
120
+ if (section !== null && section !== '') {
121
+ if (!isSection(section) || !operations.SECTIONS.includes(section)) {
122
+ sendJson(res, 400, {
123
+ ok: false,
124
+ error: { code: 'unknown-section', message: `unknown section: ${section}` },
125
+ });
126
+ return;
127
+ }
128
+ const data = await operations.collectSection(section, { fresh });
129
+ sendJson(res, 200, { ok: true, section, data });
130
+ return;
131
+ }
132
+ const data = await operations.collectInventory({ fresh });
133
+ sendJson(res, 200, { ok: true, section: null, data });
134
+ }),
135
+ }));
136
+
137
+ disposers.push(ctx.webServer.register({
138
+ kind: 'exact',
139
+ path: `${API_PREFIX}/actions`,
140
+ handler: guarded(async (req, res) => {
141
+ if (rejected(ctx, req, res)) return;
142
+ if (req.method !== 'GET') return sendMethodNotAllowed(res, 'GET');
143
+ sendJson(res, 200, {
144
+ ok: true,
145
+ platform: operations.PLATFORM,
146
+ sections: operations.SECTIONS,
147
+ actions: operations.actionCatalog(),
148
+ history: operations.actionHistory(),
149
+ });
150
+ }),
151
+ }));
152
+
153
+ disposers.push(ctx.webServer.register({
154
+ kind: 'exact',
155
+ path: `${API_PREFIX}/action`,
156
+ handler: guarded(async (req, res) => {
157
+ if (rejected(ctx, req, res)) return;
158
+ if (req.method !== 'POST') return sendMethodNotAllowed(res, 'POST');
159
+ const contentType = String(req.headers['content-type'] ?? '')
160
+ .split(';', 1)[0]
161
+ .trim()
162
+ .toLowerCase();
163
+ if (contentType !== 'application/json') {
164
+ sendJson(res, 415, {
165
+ ok: false,
166
+ error: { code: 'unsupported-media-type', message: 'content-type must be application/json' },
167
+ });
168
+ return;
169
+ }
170
+ let text;
171
+ try {
172
+ text = await readBoundedBody(req);
173
+ } catch {
174
+ sendJson(res, 400, {
175
+ ok: false,
176
+ error: { code: 'bad-request', message: 'request body unreadable' },
177
+ });
178
+ return;
179
+ }
180
+ if (text === null) {
181
+ sendJson(res, 413, {
182
+ ok: false,
183
+ error: { code: 'payload-too-large', message: 'request body is too large' },
184
+ });
185
+ return;
186
+ }
187
+ let body;
188
+ try {
189
+ body = JSON.parse(text);
190
+ } catch {
191
+ body = undefined;
192
+ }
193
+ if (typeof body !== 'object' || body === null || typeof body.action !== 'string') {
194
+ sendJson(res, 400, {
195
+ ok: false,
196
+ error: {
197
+ code: 'bad-request',
198
+ message: 'body must be a JSON object with a string "action" and optional object "params"',
199
+ },
200
+ });
201
+ return;
202
+ }
203
+ const params = typeof body.params === 'object' && body.params !== null ? body.params : {};
204
+ const result = await operations.runAction(body.action, params);
205
+ sendJson(res, 200, result);
206
+ }),
207
+ }));
208
+
209
+ disposers.push(ctx.webServer.register({
210
+ kind: 'exact',
211
+ path: `${API_PREFIX}/cleanup`,
212
+ handler: guarded(async (req, res) => {
213
+ if (rejected(ctx, req, res)) return;
214
+ if (req.method !== 'GET') return sendMethodNotAllowed(res, 'GET');
215
+ const url = requestUrl(req);
216
+ const fresh = url.searchParams.get('fresh') === '1';
217
+ const cleanup = await operations.scanCleanup({ fresh });
218
+ sendJson(res, 200, { ok: true, cleanup });
219
+ }),
220
+ }));
221
+
222
+ disposers.push(ctx.webServer.register({
223
+ kind: 'exact',
224
+ path: `${API_PREFIX}/footprint`,
225
+ handler: guarded(async (req, res) => {
226
+ if (rejected(ctx, req, res)) return;
227
+ if (req.method !== 'GET') return sendMethodNotAllowed(res, 'GET');
228
+ const url = requestUrl(req);
229
+ const appPath = url.searchParams.get('path');
230
+ const fresh = url.searchParams.get('fresh') === '1';
231
+ if (appPath === null || appPath.trim() === '') {
232
+ sendJson(res, 400, {
233
+ ok: false,
234
+ error: { code: 'missing-path', message: 'query parameter "path" is required' },
235
+ });
236
+ return;
237
+ }
238
+ // Read-only: no action-history entry, no filesystem writes.
239
+ const footprint = await operations.scanFootprint({ path: appPath, fresh });
240
+ sendJson(res, 200, { ok: true, footprint });
241
+ }),
242
+ }));
243
+
244
+ disposers.push(ctx.webServer.register({
245
+ kind: 'exact',
246
+ path: `${API_PREFIX}/benchmark`,
247
+ handler: guarded(async (req, res) => {
248
+ if (rejected(ctx, req, res)) return;
249
+ if (req.method !== 'POST') return sendMethodNotAllowed(res, 'POST');
250
+ const contentType = String(req.headers['content-type'] ?? '')
251
+ .split(';', 1)[0]
252
+ .trim()
253
+ .toLowerCase();
254
+ if (contentType !== 'application/json') {
255
+ sendJson(res, 415, {
256
+ ok: false,
257
+ error: { code: 'unsupported-media-type', message: 'content-type must be application/json' },
258
+ });
259
+ return;
260
+ }
261
+ let text;
262
+ try {
263
+ text = await readBoundedBody(req);
264
+ } catch {
265
+ sendJson(res, 400, {
266
+ ok: false,
267
+ error: { code: 'bad-request', message: 'request body unreadable' },
268
+ });
269
+ return;
270
+ }
271
+ if (text === null) {
272
+ sendJson(res, 413, {
273
+ ok: false,
274
+ error: { code: 'payload-too-large', message: 'request body is too large' },
275
+ });
276
+ return;
277
+ }
278
+ /** @type {any} */
279
+ let body = {};
280
+ if (text.trim() !== '') {
281
+ try {
282
+ body = JSON.parse(text);
283
+ } catch {
284
+ body = undefined;
285
+ }
286
+ }
287
+ if (typeof body !== 'object' || body === null) {
288
+ sendJson(res, 400, {
289
+ ok: false,
290
+ error: { code: 'bad-request', message: 'body must be a JSON object' },
291
+ });
292
+ return;
293
+ }
294
+ const tests = body.tests;
295
+ if (tests !== undefined && (!Array.isArray(tests) || tests.some((id) => typeof id !== 'string'))) {
296
+ sendJson(res, 400, {
297
+ ok: false,
298
+ error: { code: 'bad-request', message: '"tests" must be an array of test ids' },
299
+ });
300
+ return;
301
+ }
302
+ const benchmark = await operations.runBenchmark({ tests, repeat: body.repeat === true });
303
+ sendJson(res, 200, benchmark);
304
+ }),
305
+ }));
306
+
307
+ return () => {
308
+ for (const dispose of disposers.splice(0).reverse()) {
309
+ try {
310
+ dispose();
311
+ } catch {
312
+ // A route disposer that already ran must not block the rest.
313
+ }
314
+ }
315
+ };
316
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Device X — uninstall pipeline.
3
+ *
4
+ * One shared state machine so both platforms behave identically and the
5
+ * ordering rule is enforced in exactly one place:
6
+ *
7
+ * scan -> [user confirms] -> uninstall -> VERIFY -> trash app -> re-scan -> trash leftovers
8
+ *
9
+ * Two invariants matter more than the happy path:
10
+ *
11
+ * 1. **Verification gates everything.** A vendor uninstaller exiting 0 is not
12
+ * evidence that the app is gone (MSI returns 0/3010/1641, vendor scripts
13
+ * lie). If the app is still installed afterwards, the run aborts and
14
+ * *nothing* is deleted — the failure path has zero side effects.
15
+ * 2. **A missing uninstaller is not a failure.** Only 2 of 79 measured apps
16
+ * ship one; treating "none" as failure would disable the feature almost
17
+ * everywhere. So `hasUninstaller: false` skips straight to trashing the app.
18
+ *
19
+ * The pipeline never invents paths. Leftover items come from a scan token
20
+ * resolved by `footprint.resolveScanItems`, and the platform adapter performs
21
+ * the actual moves.
22
+ *
23
+ * @module @cfour-hi/dsh-plugin-devicex/host/uninstall
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} UninstallSteps
28
+ * @property {(appPath: string) => Promise<{ok: boolean, message: string, code?: string}>} uninstallApp
29
+ * run the vendor uninstaller; `ok: false` aborts the pipeline
30
+ * @property {(appPath: string) => Promise<boolean>} isStillInstalled
31
+ * objective post-condition check (path gone / registry key gone)
32
+ * @property {(appPath: string) => Promise<{ok: boolean, message: string, destination?: string, code?: string}>} trashApp
33
+ * @property {(absolutePath: string) => Promise<{ok: boolean, message: string, code?: string}>} trashPath
34
+ * move one leftover to the trash
35
+ * @property {(appPath: string) => Promise<object>} rescan
36
+ * re-run the footprint scan after the app is gone
37
+ */
38
+
39
+ /** Pipeline outcomes, in the order they can occur. */
40
+ export const PHASES = Object.freeze([
41
+ 'uninstall',
42
+ 'verify',
43
+ 'trash-app',
44
+ 'rescan',
45
+ 'trash-leftovers',
46
+ 'done',
47
+ ]);
48
+
49
+ /**
50
+ * Format bytes for a user-facing message.
51
+ * @param {unknown} value
52
+ * @returns {string}
53
+ */
54
+ function humanBytes(value) {
55
+ const bytes = Number(value);
56
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
57
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
58
+ let index = 0;
59
+ let current = bytes;
60
+ while (current >= 1024 && index < units.length - 1) {
61
+ current /= 1024;
62
+ index += 1;
63
+ }
64
+ return `${current.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
65
+ }
66
+
67
+ /**
68
+ * Run the uninstall pipeline.
69
+ *
70
+ * @param {object} request
71
+ * @param {string} request.appPath resolved application path / install location
72
+ * @param {boolean} request.hasUninstaller whether the app ships its own uninstaller
73
+ * @param {boolean} [request.cleanupApp] move the app itself to the trash when
74
+ * there is no uninstaller (or after it ran); default true
75
+ * @param {Array<{id: string, path: string}>} [request.items] leftovers chosen by
76
+ * the user; already resolved through the scan token
77
+ * @param {number} [request.leftoverBytes] bytes those leftovers occupy, used
78
+ * only for the user-facing message
79
+ * @param {UninstallSteps} steps platform adapter
80
+ * @returns {Promise<object>} a JSON-safe report
81
+ */
82
+ export async function runUninstallPipeline(request, steps) {
83
+ const startedAt = Date.now();
84
+ const appPath = typeof request?.appPath === 'string' ? request.appPath : '';
85
+ /** @type {string[]} */
86
+ const errors = [];
87
+
88
+ /** @type {object} */
89
+ const report = {
90
+ ok: false,
91
+ aborted: false,
92
+ phase: 'uninstall',
93
+ appPath,
94
+ uninstaller: {
95
+ ran: false,
96
+ ok: false,
97
+ message: '',
98
+ },
99
+ verified: false,
100
+ appTrashed: false,
101
+ appTrashPath: null,
102
+ leftoversRequested: Array.isArray(request?.items) ? request.items.length : 0,
103
+ leftoversRemoved: 0,
104
+ leftoversFailed: 0,
105
+ leftoverBytes: 0,
106
+ leftoverResults: [],
107
+ errors,
108
+ durationMs: 0,
109
+ };
110
+
111
+ const finish = (phase, ok, message) => {
112
+ report.phase = phase;
113
+ report.ok = ok;
114
+ report.message = message;
115
+ report.durationMs = Date.now() - startedAt;
116
+ return report;
117
+ };
118
+
119
+ const abort = (phase, message) => {
120
+ report.phase = phase;
121
+ report.aborted = true;
122
+ report.ok = false;
123
+ report.message = message;
124
+ report.durationMs = Date.now() - startedAt;
125
+ return report;
126
+ };
127
+
128
+ if (appPath === '') return abort('uninstall', 'No application path was supplied.');
129
+
130
+ // ---- 1. vendor uninstaller (only when one exists) ----------------------
131
+ if (request.hasUninstaller === true) {
132
+ report.phase = 'uninstall';
133
+ const result = await steps.uninstallApp(appPath);
134
+ report.uninstaller.ran = true;
135
+ report.uninstaller.ok = result?.ok === true;
136
+ report.uninstaller.message = typeof result?.message === 'string' ? result.message : '';
137
+ if (report.uninstaller.ok !== true) {
138
+ // Invariant 2: the failure path changes nothing on disk.
139
+ return abort(
140
+ 'uninstall',
141
+ `The application's own uninstaller did not complete, so nothing was removed. ${report.uninstaller.message}`.trim(),
142
+ );
143
+ }
144
+ }
145
+
146
+ // ---- 2. verify the app is actually gone --------------------------------
147
+ // Verification only makes sense as a gate after something claimed to remove
148
+ // the app. When there is no vendor uninstaller the app is obviously still
149
+ // installed - that is the expected state, and step 3 removes it - so asking
150
+ // and then aborting would make the feature unusable for 77 of 79 apps.
151
+ report.phase = 'verify';
152
+ if (request.hasUninstaller === true) {
153
+ let stillInstalled;
154
+ try {
155
+ stillInstalled = await steps.isStillInstalled(appPath);
156
+ } catch (error) {
157
+ errors.push(`verification failed: ${error instanceof Error ? error.message : String(error)}`);
158
+ return abort('verify', 'Could not verify whether the application was removed, so nothing was deleted.');
159
+ }
160
+ if (stillInstalled === true) {
161
+ return abort(
162
+ 'verify',
163
+ 'The application is still installed after its uninstaller finished, so nothing was deleted.',
164
+ );
165
+ }
166
+ }
167
+ report.verified = true;
168
+
169
+ // ---- 3. move the app itself to the trash ------------------------------
170
+ // After a successful vendor uninstall the bundle is usually already gone;
171
+ // only attempt the move when something is still there.
172
+ if (request.cleanupApp !== false) {
173
+ report.phase = 'trash-app';
174
+ let appStillThere = false;
175
+ try {
176
+ appStillThere = await steps.isStillInstalled(appPath);
177
+ } catch {
178
+ appStillThere = false;
179
+ }
180
+ if (appStillThere) {
181
+ const moved = await steps.trashApp(appPath);
182
+ report.appTrashed = moved?.ok === true;
183
+ report.appTrashPath = typeof moved?.destination === 'string' ? moved.destination : null;
184
+ if (moved?.ok !== true) {
185
+ return abort(
186
+ 'trash-app',
187
+ `Could not move the application to the Trash, so no leftovers were touched. ${
188
+ typeof moved?.message === 'string' ? moved.message : ''
189
+ }`.trim(),
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ // ---- 4. re-scan, because the uninstaller may have cleaned some data ----
196
+ report.phase = 'rescan';
197
+ if (Array.isArray(request?.items) && request.items.length > 0) {
198
+ try {
199
+ const after = await steps.rescan(appPath);
200
+ report.rescan = {
201
+ items: Array.isArray(after?.items) ? after.items.length : 0,
202
+ totalBytes: Number(after?.totalBytes) || 0,
203
+ errors: Array.isArray(after?.errors) ? after.errors.slice(0, 5) : [],
204
+ };
205
+ } catch (error) {
206
+ errors.push(`re-scan failed: ${error instanceof Error ? error.message : String(error)}`);
207
+ }
208
+ }
209
+
210
+ // ---- 5. move the chosen leftovers to the trash ------------------------
211
+ if (Array.isArray(request?.items) && request.items.length > 0) {
212
+ report.phase = 'trash-leftovers';
213
+ for (const item of request.items) {
214
+ const path = typeof item?.path === 'string' ? item.path : '';
215
+ if (path === '') continue;
216
+ const outcome = await steps.trashPath(path);
217
+ const ok = outcome?.ok === true;
218
+ if (ok) report.leftoversRemoved += 1;
219
+ else report.leftoversFailed += 1;
220
+ report.leftoverResults.push({
221
+ id: typeof item?.id === 'string' ? item.id : '',
222
+ path,
223
+ ok,
224
+ message: typeof outcome?.message === 'string' ? outcome.message : '',
225
+ });
226
+ }
227
+ report.leftoverBytes = Number(request.leftoverBytes) || 0;
228
+ }
229
+
230
+ const leftoverPart =
231
+ report.leftoversRequested === 0
232
+ ? 'No leftover directories were selected.'
233
+ : report.leftoversFailed === 0
234
+ ? `Moved ${report.leftoversRemoved} leftover location(s) to the Trash (${humanBytes(report.leftoverBytes)}).`
235
+ : `Moved ${report.leftoversRemoved} of ${report.leftoversRequested} leftover location(s) to the Trash; ${report.leftoversFailed} could not be moved.`;
236
+
237
+ // Trash is not freed space; say so rather than implying the disk grew.
238
+ const spaceHint =
239
+ report.leftoverBytes > 0
240
+ ? ` Empty the Trash to actually free ${humanBytes(report.leftoverBytes)}.`
241
+ : '';
242
+
243
+ return finish('done', report.leftoversFailed === 0, `${leftoverPart}${spaceHint}`.trim());
244
+ }