@cfour-hi/dsh-plugin-devicex 1.5.0 → 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/host/transport.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The Device X browser API: four same-origin JSON routes on the DSH Web
3
- * server, each behind the shipped Connection trust fence (the page's signed
4
- * session cookie authenticates the caller; foreign origins are rejected by
5
- * the Host/Origin fence). Route handlers never shell out themselves — they
6
- * call the shared operation surface, exactly like the agent tools.
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
7
  *
8
8
  * @module @cfour-hi/dsh-plugin-devicex/host/transport
9
9
  */
@@ -219,6 +219,28 @@ export function registerRoutes(ctx, operations) {
219
219
  }),
220
220
  }));
221
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
+
222
244
  disposers.push(ctx.webServer.register({
223
245
  kind: 'exact',
224
246
  path: `${API_PREFIX}/benchmark`,
@@ -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
+ }
package/index.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from './host/collector.js';
19
19
  import { GROUPS, actionCatalog, actionHistory, runAction } from './host/actions.js';
20
20
  import { scanCleanup } from './host/cleanup.js';
21
+ import { scanFootprint } from './host/footprint.js';
21
22
  import { runBenchmark } from './host/benchmark.js';
22
23
  import { registerRoutes } from './host/transport.js';
23
24
  import { registerTools } from './host/tool.js';
@@ -44,6 +45,7 @@ const operations = {
44
45
  actionHistory,
45
46
  runAction,
46
47
  scanCleanup,
48
+ scanFootprint,
47
49
  runBenchmark,
48
50
  };
49
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfour-hi/dsh-plugin-devicex",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Inspect every part of this device and control what can be changed (macOS + Windows).",
6
6
  "license": "MIT",