@floless/app 0.5.1

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/teardown.mjs ADDED
@@ -0,0 +1,128 @@
1
+ // teardown — the SHARED pure layer behind `floless-app uninstall` (Part A.5).
2
+ //
3
+ // Plain JS (.mjs) on purpose: it is imported by BOTH launch.mjs (the npm bin,
4
+ // plain JS) and main.ts (the SEA dispatcher, TS), AND by autostart.ts/protocol.ts
5
+ // for the registry-key constants — one source, no JS/TS boundary problem (Codex S1).
6
+ // It holds ONLY pure functions + constants: no process killing, no reg/npm calls,
7
+ // no I/O. The executors that perform real side effects live in launch.mjs / main.ts
8
+ // and are exercised by the human's real teardown test, never here.
9
+ //
10
+ // Shipped to npm (added to package.json `files`), so it must stay dependency-free.
11
+
12
+ // --- registry-key constants (single source — must match autostart.ts/protocol.ts) ---
13
+ // autostart.ts: RUN_KEY + VALUE_NAME='FloLess'; protocol.ts: BASE.
14
+ export const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
15
+ export const RUN_VALUE = 'FloLess';
16
+ export const PROTOCOL_KEY = 'HKCU\\Software\\Classes\\floless';
17
+
18
+ /**
19
+ * Decide what `floless-app uninstall` should do about the AWARE runtime.
20
+ *
21
+ * --purge → remove AWARE, never prompt.
22
+ * --keep-aware → keep AWARE, never prompt.
23
+ * (no flags) → prompt ONLY when a TTY is present; a non-TTY (Velopack
24
+ * Add/Remove, piped stdin) NEVER blocks → keep AWARE.
25
+ *
26
+ * @param {{ purge?: boolean, keepAware?: boolean, isTTY?: boolean }} opts
27
+ * @returns {{ removeAware: boolean, prompt: boolean }}
28
+ * @throws {Error} when both --purge and --keep-aware are given (conflicting).
29
+ */
30
+ export function teardownDecision({ purge = false, keepAware = false, isTTY = false } = {}) {
31
+ if (purge && keepAware) {
32
+ throw new Error('conflicting flags: --purge and --keep-aware cannot be combined');
33
+ }
34
+ if (purge) return { removeAware: true, prompt: false };
35
+ if (keepAware) return { removeAware: false, prompt: false };
36
+ // No flags: ask iff interactive; otherwise keep AWARE and never block.
37
+ return { removeAware: false, prompt: Boolean(isTTY) };
38
+ }
39
+
40
+ // The global npm package id for the AWARE runtime. Single source so the uninstall
41
+ // shell command and the JSON-verify check can't drift.
42
+ export const AWARE_PKG = '@aware-aeco/cli';
43
+
44
+ /**
45
+ * Decide whether AWARE is STILL installed from the output of
46
+ * `npm ls -g @aware-aeco/cli --depth=0 --json`. The package, when present, appears as
47
+ * a key under the top-level `dependencies` object; when absent, `dependencies` is
48
+ * missing or empty. Parsing this is more reliable than `npm ls`'s exit code, which
49
+ * varies across npm versions for a missing global (review Fix 3 / Codex S4).
50
+ *
51
+ * Defensive: any empty/undefined/unparseable input, or a non-object `dependencies`,
52
+ * is treated as ABSENT (removed) — a flaky `npm ls` must never raise a false
53
+ * "still installed" alarm.
54
+ *
55
+ * @param {string | undefined | null} npmLsJson raw stdout of the `npm ls … --json` call
56
+ * @returns {boolean} true iff the AWARE package is listed under dependencies
57
+ */
58
+ export function awareIsPresent(npmLsJson) {
59
+ let parsed;
60
+ try { parsed = JSON.parse(npmLsJson || '{}'); } catch { return false; }
61
+ const deps = parsed && typeof parsed === 'object' ? parsed.dependencies : null;
62
+ if (!deps || typeof deps !== 'object') return false;
63
+ return Object.prototype.hasOwnProperty.call(deps, AWARE_PKG);
64
+ }
65
+
66
+ // A process is a floless.app supervisor iff its command line both names THIS app's
67
+ // exe and carries the supervise action. We match `--supervise` or a bare ` supervise`
68
+ // token (the exe-channel action) on a word boundary, so `--serve` /
69
+ // `--veloapp-uninstall` / a substring like `supervised` never qualify.
70
+ const SUPERVISE_TOKEN = /(?:^|\s)(?:--)?supervise(?:\s|$)/i;
71
+
72
+ /**
73
+ * Select supervisor PIDs to kill during teardown, with the self-kill guard baked in.
74
+ *
75
+ * A proc qualifies iff its `cmd` (its full command line):
76
+ * - contains AT LEAST ONE of the `exeMatch` candidates (this app's exe path),
77
+ * case-insensitively, AND
78
+ * - carries the supervise action (`--supervise` / ` supervise`), AND
79
+ * - its pid !== selfPid (NEVER kill the running uninstall process — Codex self-kill), AND
80
+ * - when `scriptMatch` is provided (non-empty), ALSO contains `scriptMatch`
81
+ * case-insensitively (review #1).
82
+ *
83
+ * `exeMatch` accepts EITHER a single string OR a string array (added 2026-05-30 for
84
+ * the Claude MSIX container case): inside the container `process.execPath` is the
85
+ * bind-link VIRTUAL path while a supervisor launched at logon — via a Run key /
86
+ * Scheduled Task registered with the REAL un-virtualized path — runs with the REAL
87
+ * path in its command line. Passing `[virtualPath, realPath]` matches BOTH so the
88
+ * in-container uninstaller can still find it. A single-string call still works
89
+ * unchanged for non-container installs (where the two paths are identical).
90
+ *
91
+ * The `scriptMatch` arg exists for the npm channel: there `exeMatch` is the generic
92
+ * `node.exe`, so without it the predicate could match ANY `node … supervise` process
93
+ * (e.g. an unrelated third-party watchdog). Passing the launcher script (launch.mjs)
94
+ * as `scriptMatch` narrows it to OUR supervisor. The packaged channel uses a unique
95
+ * `FlolessApp.exe` and passes no scriptMatch — behavior is unchanged there.
96
+ *
97
+ * This deliberately EXCLUDES the current `--veloapp-uninstall` proc (same exe, wrong
98
+ * action) and unrelated tools whose command line merely contains "--supervise"
99
+ * (right action, wrong exe).
100
+ *
101
+ * @param {Array<{ pid: number, cmd: string }>} procs enumerated processes
102
+ * @param {number} selfPid process.pid of the running uninstall — always excluded
103
+ * @param {string | string[]} exeMatch this app's exe path(s) to match in each cmd line;
104
+ * pass an array to match a supervisor that may have been launched at either of two
105
+ * equivalent paths (the MSIX virtual + real un-virtualized path)
106
+ * @param {string} [scriptMatch] optional launcher-script substring; when non-empty,
107
+ * the cmd must ALSO contain it (case-insensitively). Omitted/empty → no narrowing.
108
+ * @returns {number[]} pids to taskkill (caller does the killing; this is pure)
109
+ */
110
+ export function supervisorPidsToKill(procs, selfPid, exeMatch, scriptMatch) {
111
+ if (!Array.isArray(procs) || !exeMatch) return [];
112
+ const needles = (Array.isArray(exeMatch) ? exeMatch : [exeMatch])
113
+ .filter((s) => typeof s === 'string' && s.length > 0)
114
+ .map((s) => s.toLowerCase());
115
+ if (needles.length === 0) return [];
116
+ const scriptNeedle = scriptMatch ? String(scriptMatch).toLowerCase() : '';
117
+ return procs
118
+ .filter((p) => {
119
+ if (!p || typeof p.cmd !== 'string') return false;
120
+ if (p.pid === selfPid) return false; // never self-kill
121
+ const cmd = p.cmd;
122
+ const lower = cmd.toLowerCase();
123
+ if (!needles.some((n) => lower.includes(n)) || !SUPERVISE_TOKEN.test(cmd)) return false;
124
+ if (scriptNeedle && !lower.includes(scriptNeedle)) return false; // npm-channel narrowing
125
+ return true;
126
+ })
127
+ .map((p) => p.pid);
128
+ }