@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.
@@ -16,6 +16,8 @@ import * as os from 'node:os';
16
16
  import * as path from 'node:path';
17
17
 
18
18
  import { cleanupItems, deleteCleanup } from './cleanup-darwin.js';
19
+ import { consumeToken, resolveScanItems, scanFootprint } from './footprint.js';
20
+ import { runUninstallPipeline } from './uninstall.js';
19
21
  import { run } from './proc.js';
20
22
 
21
23
  /** Action groups, in UI order. */
@@ -187,6 +189,11 @@ const SPECS = [
187
189
  params: [
188
190
  param('path', 'string', { placeholder: '/Applications/SomeApp.app' }),
189
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' }),
190
197
  ],
191
198
  },
192
199
  {
@@ -575,39 +582,108 @@ async function guardedAppBundle(raw, mode) {
575
582
  }
576
583
 
577
584
  /**
578
- * Move a bundle into the user's Trash with a unique name.
579
- * @param {string} source resolved bundle path
580
- * @returns {Promise<string>} the destination path
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
581
599
  */
582
- async function moveToTrash(source) {
583
- const trash = path.join(os.homedir(), '.Trash');
584
- await fsp.mkdir(trash, { recursive: true });
585
- const base = path.basename(source);
586
- const stem = base.replace(/\.app$/i, '');
587
- let destination = path.join(trash, `${stem}-${Date.now()}.app`);
588
- let suffix = 1;
589
- while (true) {
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}`);
590
605
  try {
591
- await fsp.access(destination);
592
- destination = path.join(trash, `${stem}-${Date.now()}-${suffix}.app`);
593
- suffix += 1;
594
- } catch {
595
- break;
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;
596
620
  }
597
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);
598
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.
599
652
  await fsp.rename(source, destination);
600
653
  return destination;
601
- } catch (err) {
602
- if (err && typeof err === 'object' && 'code' in err && err.code === 'EXDEV') {
603
- await fsp.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
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 {
604
671
  await fsp.rm(source, { recursive: true, force: false, maxRetries: 2, retryDelay: 100 });
605
- return destination;
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
+ });
606
679
  }
607
- throw err;
680
+ return destination;
608
681
  }
609
682
  }
610
683
 
684
+ /** Test seam: expose the Trash mover so offline tests can drive a sandbox. */
685
+ export const __trashTest = { moveToTrash, reserveTrashName };
686
+
611
687
  /* -------------------------------------------------------------------------- */
612
688
  /* Action runners */
613
689
  /* -------------------------------------------------------------------------- */
@@ -616,6 +692,132 @@ async function moveToTrash(source) {
616
692
  * @typedef {(params: Record<string, unknown>) => Promise<{ok: boolean, message: string, data?: object, code?: string}>} Runner
617
693
  */
618
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
+
619
821
  /** @type {Record<string, Runner>} */
620
822
  const RUNNERS = {
621
823
  async 'process.signal'(params) {
@@ -675,6 +877,13 @@ const RUNNERS = {
675
877
  if (!modeRead.ok) return failure(modeRead.message, 'BAD_PARAMS');
676
878
  const guarded = await guardedAppBundle(pathRead.value, modeRead.value);
677
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
+
678
887
  const name = path.basename(guarded.path);
679
888
  if (modeRead.value === 'trash') {
680
889
  try {
@@ -316,6 +316,22 @@ function parseUninstallId(params) {
316
316
  if (!raw.ok) return raw;
317
317
  const id = raw.value;
318
318
  const lower = id.toLowerCase();
319
+
320
+ // 1.6: an optional footprint tail. Windows can only scan for now, so a
321
+ // request that asks to clean leftovers is refused by the runner rather than
322
+ // silently ignored - but the id is still validated first so the caller gets
323
+ // the precise rejection reason.
324
+ const scanId = params.scanId;
325
+ if (scanId !== undefined && scanId !== null && scanId !== '') {
326
+ if (typeof scanId !== 'string' || scanId.length > 128) {
327
+ return failure('scanId must be a string of at most 128 characters.', 'BAD_PARAMS');
328
+ }
329
+ }
330
+ const withScan = (value) =>
331
+ typeof scanId === 'string' && scanId !== ''
332
+ ? /** @type {{ok: true, value: any}} */ ({ ok: true, value: { ...value, scanId } })
333
+ : /** @type {{ok: true, value: any}} */ ({ ok: true, value });
334
+
319
335
  if (lower.startsWith('msi:')) {
320
336
  if (!MSI_ID_PATTERN.test(id)) {
321
337
  return failure(
@@ -323,18 +339,18 @@ function parseUninstallId(params) {
323
339
  'BAD_PARAMS',
324
340
  );
325
341
  }
326
- return { ok: true, value: { kind: 'msi', id, guid: id.slice(4).toUpperCase() } };
342
+ return withScan({ kind: 'msi', id, guid: id.slice(4).toUpperCase() });
327
343
  }
328
344
  if (lower.startsWith('winget:')) {
329
345
  const token = id.slice('winget:'.length);
330
346
  if (!WINGET_TOKEN_PATTERN.test(token)) {
331
347
  return failure('winget uninstall ids may only contain letters, digits and . _ + -.', 'BAD_PARAMS');
332
348
  }
333
- return { ok: true, value: { kind: 'winget', id, token } };
349
+ return withScan({ kind: 'winget', id, token });
334
350
  }
335
351
  const registry = parseRegistryKey(id);
336
352
  if (!registry.ok) return registry;
337
- return { ok: true, value: { kind: 'registry', id, key: registry.value } };
353
+ return withScan({ kind: 'registry', id, key: registry.value });
338
354
  }
339
355
 
340
356
  /** Parse `startup:<HKCU|HKLM|HKLM32>:<ValueName>` for `system.launchAgent`. * @returns {{ok: true, value: any} | {ok: false, message: string, code?: string}}
@@ -520,7 +536,14 @@ const SPECS = [
520
536
  group: 'app',
521
537
  danger: 'high',
522
538
  requiresAdmin: false,
523
- params: [{ name: 'id', type: 'string', required: true, placeholder: 'msi:{GUID} | winget:<id> | HKLM:\\...\\Uninstall\\...' }],
539
+ params: [
540
+ { name: 'id', type: 'string', required: true, placeholder: 'msi:{GUID} | winget:<id> | HKLM:\\...\\Uninstall\\...' },
541
+ // 1.6: present so the schema matches macOS. Supplying it is refused today
542
+ // (see the runner) because the Recycle Bin move is not verified yet.
543
+ { name: 'scanId', type: 'string', required: false, placeholder: 'scan id from GET /devicex/api/footprint' },
544
+ { name: 'itemIds', type: 'string', required: false, multiple: true, placeholder: 'fp_1a2b3c4d' },
545
+ { name: 'cleanup', type: 'select', required: false, options: ['trash', 'none'] },
546
+ ],
524
547
  /**
525
548
  * @returns {{ok: true, value: Win32Params['app.uninstall']} | {ok: false, message: string, code?: string}}
526
549
  */
@@ -981,6 +1004,17 @@ Start-Process -FilePath ${psString('explorer.exe')} -ArgumentList ('/select,"' +
981
1004
  return success(`Revealed "${path}" in File Explorer.`, { path });
982
1005
  },
983
1006
  'app.uninstall': async (value) => {
1007
+ // 1.6: refuse the footprint tail explicitly instead of uninstalling and
1008
+ // then quietly leaving the leftovers the user selected. Windows has no
1009
+ // verified Recycle Bin move yet, so listing works and cleanup does not.
1010
+ if (typeof value?.scanId === 'string' && value.scanId !== '') {
1011
+ return {
1012
+ ok: false,
1013
+ message:
1014
+ 'Leftover cleanup is not available on Windows yet: this build can list an application\'s leftover folders but will not move them to the Recycle Bin. Uninstall without selecting leftovers to continue.',
1015
+ code: 'UNSUPPORTED_PLATFORM',
1016
+ };
1017
+ }
984
1018
  if (value.kind === 'msi') return uninstallMsi(value);
985
1019
  if (value.kind === 'winget') return uninstallWinget(value, true);
986
1020
  return uninstallRegistry(value);
@@ -1367,6 +1367,9 @@ function mapSoftware(raw, transportError) {
1367
1367
  arch: str(application.arch),
1368
1368
  lastUsed: null,
1369
1369
  sizeBytes: estimatedSizeKb === null ? null : Math.round(estimatedSizeKb * 1024),
1370
+ // Additive (1.6): `software.ps1` already reads Publisher, and Windows
1371
+ // has no bundle id, so leftover matching needs it.
1372
+ publisher: str(application.publisher),
1370
1373
  };
1371
1374
  })
1372
1375
  .filter((application) => application.name !== null);