pi-freeflow 1.44.0 → 1.44.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.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * System login startup hook installer/uninstaller for pi-freeflow daemon.
3
+ *
4
+ * Windows: Scheduled Task (schtasks /sc onlogon)
5
+ * Linux/macOS: User systemd unit (~/.config/systemd/user/pi-freeflow.service)
6
+ */
7
+
8
+ import { execSync } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { isJsRuntimeExecutable, materializeDaemonSources, resolveDaemonRuntime } from "./client-staging.ts";
14
+ import { DAEMON_SPAWN_ENV } from "./config.ts";
15
+ import type { ExtensionContext } from "./types.ts";
16
+
17
+ export const STARTUP_TASK_NAME = "pi-freeflow-daemon";
18
+ export const STARTUP_SERVICE_NAME = "pi-freeflow.service";
19
+
20
+ /**
21
+ * How the POSIX startup hook talks to the user systemd manager. A seam
22
+ * because CI (and this Windows dev box) has no user bus: tests must be able to
23
+ * prove both the enabled and the systemd-less outcome deterministically.
24
+ */
25
+ let startupHookRunner: (cmd: string) => void = (cmd: string): void => {
26
+ execSync(cmd, { timeout: 10_000, stdio: "ignore" });
27
+ };
28
+
29
+ /** Test-only: replace the user-systemd runner (null restores execSync). */
30
+ export function _setStartupHookRunnerForTest(runner: ((cmd: string) => void) | null): void {
31
+ startupHookRunner =
32
+ runner ??
33
+ ((cmd: string): void => {
34
+ execSync(cmd, { timeout: 10_000, stdio: "ignore" });
35
+ });
36
+ }
37
+
38
+ export interface StartupPlan {
39
+ platform: string;
40
+ taskName: string;
41
+ command: string;
42
+ filePath: string;
43
+ fileContent: string;
44
+ undo: string;
45
+ }
46
+
47
+ function daemonEntryForStartup(scriptOverride?: string): string {
48
+ if (scriptOverride) return scriptOverride;
49
+ try {
50
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
51
+ } catch {
52
+ return path.join("src", "daemon.ts");
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The `/tr` payload for a Windows scheduled task action.
58
+ *
59
+ * A task action is launched by CreateProcess, which has no single-quote
60
+ * syntax: the old `'…'` substitution handed it a first token carrying a
61
+ * literal `'` ("The filename, directory name, or volume label syntax is
62
+ * incorrect"), so the startup task could never start. Double quotes are the
63
+ * only grouping CreateProcess understands, so keep them and escape them for
64
+ * cmd.exe (`\"`); schtasks stores the resulting action verbatim, which is
65
+ * exactly the quoted runtime command the task must run.
66
+ */
67
+ export function escapeSchTasksAction(quotedCommand: string): string {
68
+ return quotedCommand.replace(/"/g, '\\"');
69
+ }
70
+
71
+ /**
72
+ * Build the single-shot login-startup plan without touching the system.
73
+ * Windows → logon Scheduled Task (schtasks /sc onlogon, single run at logon).
74
+ * Linux/macOS → user systemd unit (Type=oneshot, WantedBy=default.target).
75
+ * Pure (testable): all paths/commands are derived from the overrides.
76
+ */
77
+ export function getStartupPlan(opts?: {
78
+ platform?: string;
79
+ homeDir?: string;
80
+ execPath?: string;
81
+ scriptPath?: string;
82
+ }): StartupPlan {
83
+ const platform = opts?.platform ?? process.platform;
84
+ const home = opts?.homeDir ?? os.homedir();
85
+ const script = daemonEntryForStartup(opts?.scriptPath);
86
+ // A Node runtime cannot strip types for files under node_modules, so the
87
+ // hook must point at staged sources when the package is installed (issue #52).
88
+ // Bun runs TypeScript anywhere — keep its command side-effect free.
89
+ const nodeEntry = (): string => materializeDaemonSources(script);
90
+ let quoted: string;
91
+ if (opts?.execPath) {
92
+ const type = isJsRuntimeExecutable(opts.execPath);
93
+ if (type === "bun") {
94
+ quoted = `"${opts.execPath}" "${script}"`;
95
+ } else {
96
+ quoted = `"${opts.execPath}" --experimental-strip-types "${nodeEntry()}"`;
97
+ }
98
+ } else {
99
+ const runtime = resolveDaemonRuntime({ scriptPath: script });
100
+ if (runtime) {
101
+ if (runtime.runtimeType === "bun") {
102
+ quoted = `"${runtime.execPath}" "${script}"`;
103
+ } else {
104
+ quoted = `"${runtime.execPath}" --experimental-strip-types "${nodeEntry()}"`;
105
+ }
106
+ } else {
107
+ quoted = `"node" --experimental-strip-types "${nodeEntry()}"`;
108
+ }
109
+ }
110
+ if (platform === "win32") {
111
+ const command = `schtasks /create /tn "${STARTUP_TASK_NAME}" /tr "${escapeSchTasksAction(quoted)}" /sc onlogon /rl limited /f`;
112
+ return {
113
+ platform,
114
+ taskName: STARTUP_TASK_NAME,
115
+ command,
116
+ filePath: `Scheduled Task \\${STARTUP_TASK_NAME} (logon trigger, single-shot)`,
117
+ fileContent: command,
118
+ undo: `schtasks /delete /tn "${STARTUP_TASK_NAME}" /f`,
119
+ };
120
+ }
121
+ const filePath = path.join(home, ".config", "systemd", "user", STARTUP_SERVICE_NAME);
122
+ const fileContent = [
123
+ "[Unit]",
124
+ "Description=pi-freeflow proxy daemon (single-shot at login)",
125
+ "After=default.target",
126
+ "",
127
+ "[Service]",
128
+ "Type=oneshot",
129
+ `ExecStart=${quoted}`,
130
+ "RemainAfterExit=yes",
131
+ "",
132
+ "[Install]",
133
+ "WantedBy=default.target",
134
+ "",
135
+ ].join("\n");
136
+ return {
137
+ platform,
138
+ taskName: STARTUP_SERVICE_NAME,
139
+ command: `systemctl --user enable ${STARTUP_SERVICE_NAME}`,
140
+ filePath,
141
+ fileContent,
142
+ undo: `systemctl --user disable ${STARTUP_SERVICE_NAME} && rm "${filePath}"`,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Install the login-startup hook (single-shot, idempotent). Returns whether a
148
+ * change was made plus the exact undo text for the caller to display.
149
+ * Refuses when DAEMON_SPAWN=0. Never throws — failures are returned.
150
+ */
151
+ export function installStartupHook(opts?: {
152
+ platform?: string;
153
+ homeDir?: string;
154
+ execPath?: string;
155
+ scriptPath?: string;
156
+ }): { ok: boolean; changed: boolean; undo: string; detail: string } {
157
+ if (process.env[DAEMON_SPAWN_ENV] === "0") {
158
+ return { ok: false, changed: false, undo: "", detail: "refused: daemon spawn is disabled (DAEMON_SPAWN=0)" };
159
+ }
160
+ const plan = getStartupPlan(opts);
161
+ try {
162
+ if (plan.platform === "win32") {
163
+ try {
164
+ const q = execSync(`schtasks /query /tn "${STARTUP_TASK_NAME}"`, { encoding: "utf8", timeout: 5000, windowsHide: true }) as string;
165
+ if (q && q.includes(STARTUP_TASK_NAME)) {
166
+ execSync(plan.command, { timeout: 10_000, stdio: "ignore", windowsHide: true });
167
+ return { ok: true, changed: false, undo: plan.undo, detail: `already installed — refreshed logon task "${STARTUP_TASK_NAME}" (undo: ${plan.undo})` };
168
+ }
169
+ } catch { }
170
+ execSync(plan.command, { timeout: 10_000, stdio: "ignore", windowsHide: true });
171
+ return { ok: true, changed: true, undo: plan.undo, detail: `installed logon task "${STARTUP_TASK_NAME}" (undo: ${plan.undo})` };
172
+ }
173
+ const dir = path.dirname(plan.filePath);
174
+ fs.mkdirSync(dir, { recursive: true });
175
+ let existing = "";
176
+ try {
177
+ existing = fs.readFileSync(plan.filePath, "utf8");
178
+ } catch { }
179
+ if (existing === plan.fileContent) {
180
+ return { ok: true, changed: false, undo: plan.undo, detail: `already installed — ${plan.filePath} up to date (undo: ${plan.undo})` };
181
+ }
182
+ fs.writeFileSync(plan.filePath, plan.fileContent, "utf8");
183
+ // Writing the unit file arms nothing by itself: `systemctl --user enable`
184
+ // creates the login symlink. WSL, containers and hosts without a user
185
+ // manager fail here, and reporting ok:true left the hook silently dead.
186
+ let enableError = "";
187
+ for (const cmd of ["systemctl --user daemon-reload", plan.command]) {
188
+ try {
189
+ startupHookRunner(cmd);
190
+ } catch (e) {
191
+ enableError = enableError || String(e);
192
+ }
193
+ }
194
+ if (enableError !== "") {
195
+ return {
196
+ ok: false,
197
+ changed: true,
198
+ undo: plan.undo,
199
+ detail: `installed ${plan.filePath} but could not enable it at login: ${enableError} — enable by hand: "${plan.command}" (undo: ${plan.undo})`,
200
+ };
201
+ }
202
+ return { ok: true, changed: true, undo: plan.undo, detail: `installed ${plan.filePath} (undo: ${plan.undo})` };
203
+ } catch (e) {
204
+ return { ok: false, changed: false, undo: plan.undo, detail: `install failed: ${String(e)} (undo: ${plan.undo})` };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Remove the login-startup hook (idempotent). Returns the undo/redo text.
210
+ * Refuses when DAEMON_SPAWN=0, matching install.
211
+ */
212
+ export function uninstallStartupHook(opts?: {
213
+ platform?: string;
214
+ homeDir?: string;
215
+ execPath?: string;
216
+ scriptPath?: string;
217
+ }): { ok: boolean; changed: boolean; undo: string; detail: string } {
218
+ if (process.env[DAEMON_SPAWN_ENV] === "0") {
219
+ return { ok: false, changed: false, undo: "", detail: "refused: daemon spawn is disabled (DAEMON_SPAWN=0)" };
220
+ }
221
+ const plan = getStartupPlan(opts);
222
+ try {
223
+ if (plan.platform === "win32") {
224
+ try {
225
+ execSync(plan.undo, { timeout: 10_000, stdio: "ignore", windowsHide: true });
226
+ return { ok: true, changed: true, undo: plan.command, detail: `removed logon task "${STARTUP_TASK_NAME}" (re-install: ${plan.command})` };
227
+ } catch {
228
+ return { ok: true, changed: false, undo: plan.command, detail: `already removed — no logon task "${STARTUP_TASK_NAME}" found` };
229
+ }
230
+ }
231
+ let existed = false;
232
+ try {
233
+ fs.accessSync(plan.filePath, fs.constants.F_OK);
234
+ existed = true;
235
+ } catch { }
236
+ if (!existed) {
237
+ return { ok: true, changed: false, undo: plan.command, detail: `already removed — no unit file at ${plan.filePath}` };
238
+ }
239
+ try {
240
+ execSync(`systemctl --user disable ${STARTUP_SERVICE_NAME}`, { timeout: 10_000, stdio: "ignore" });
241
+ } catch { }
242
+ fs.rmSync(plan.filePath, { force: true });
243
+ return { ok: true, changed: true, undo: plan.command, detail: `removed ${plan.filePath} (re-install: ${plan.command})` };
244
+ } catch (e) {
245
+ return { ok: false, changed: false, undo: plan.undo, detail: `uninstall failed: ${String(e)}` };
246
+ }
247
+ }
248
+
249
+ export function handleStartupSubcommand(sub: string, ctx: ExtensionContext): boolean {
250
+ if (sub === "install-startup") {
251
+ const r = installStartupHook();
252
+ ctx.ui.notify(r.detail, r.ok ? "info" : "warning");
253
+ return true;
254
+ }
255
+ if (sub === "uninstall-startup") {
256
+ const r = uninstallStartupHook();
257
+ ctx.ui.notify(r.detail, r.ok ? "info" : "warning");
258
+ return true;
259
+ }
260
+ return false;
261
+ }