@join-pantheon/cli 0.32.1 → 0.33.0-rc.2

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/dist/service.js CHANGED
@@ -1,12 +1,14 @@
1
- import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync } from "node:fs";
1
+ import { writeFileSync, mkdirSync, existsSync, lstatSync, unlinkSync, readFileSync, renameSync, rmSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { homedir, platform, userInfo } from "node:os";
4
4
  import { which } from "./which.js";
5
- import { dirname, isAbsolute, join, posix, resolve } from "node:path";
5
+ import { basename, dirname, isAbsolute, join, posix, resolve } from "node:path";
6
+ import { linuxGraphicalSession } from "./host-env.js";
6
7
  import { fileURLToPath } from "node:url";
7
- import { launcher } from "./install.js";
8
+ import { isVersionManagedPath, launcher } from "./install.js";
8
9
  import { childNodeFlags, managedLauncher, managedLayout } from "./managed.js";
9
10
  import { CONFIG_DIR } from "./config.js";
11
+ import { HOST_KINDS, LEGACY_TASK_NAME, clearTrayQuit, ensureHostShim, fromUtf16le, hostAlive, hostArgv, hostChildRunning, hostFiles, hostLaunch, isHostKind, launchFileText, legacyTaskIsUsers, readDefinition, readHostState, taskName, taskUserId, taskXml, utf16le, windowsPowerShell, writeDefinition, } from "./win-host.js";
10
12
  const HERE = dirname(fileURLToPath(import.meta.url));
11
13
  const LABEL = (kind) => `dev.pantheon.${kind}`;
12
14
  export function borrowedHome() {
@@ -19,14 +21,55 @@ export function borrowedHome() {
19
21
  return false;
20
22
  }
21
23
  }
24
+ export function borrowedScheduler(env = process.env) {
25
+ if (env.PANTHEON_SERVICES_ANY_HOME === "1")
26
+ return false;
27
+ if (borrowedHome())
28
+ return true;
29
+ if (!env.PANTHEON_HOME)
30
+ return false;
31
+ try {
32
+ return resolve(env.PANTHEON_HOME).toLowerCase() !== resolve(join(userInfo().homedir, ".pantheon")).toLowerCase();
33
+ }
34
+ catch {
35
+ return true;
36
+ }
37
+ }
38
+ export function borrowedProfile(env = process.env, profile = () => userInfo().homedir) {
39
+ if (env.PANTHEON_SERVICES_ANY_HOME === "1")
40
+ return false;
41
+ if (borrowedScheduler(env))
42
+ return true;
43
+ let real;
44
+ try {
45
+ real = resolve(profile()).toLowerCase();
46
+ }
47
+ catch {
48
+ return true;
49
+ }
50
+ return [env.HOME, env.USERPROFILE].some((h) => !!h && resolve(h).toLowerCase() !== real);
51
+ }
52
+ const isSchtasks = (c) => /(^|[\\/])schtasks(\.exe)?$/i.test(c);
22
53
  const READ_ONLY = (c, a) => c === "launchctl" ? ["print", "print-disabled", "list"].includes(a[0] ?? "")
23
54
  : c === "systemctl" ? a.some((x) => ["is-enabled", "is-active", "status", "show", "show-environment"].includes(x))
24
- : true;
25
- function guardedExecFile(c, a) {
26
- if ((c === "launchctl" || c === "systemctl") && !READ_ONLY(c, a) && borrowedHome()) {
27
- throw new Error(`refused: ${c} ${a[0] ?? ""} would change this user's real services, but HOME (${homedir()}) is not the user's home — a test or a sandbox. Set PANTHEON_SERVICES_ANY_HOME=1 to allow it.`);
55
+ : isSchtasks(c) ? (a[0] ?? "").toLowerCase() === "/query"
56
+ : true;
57
+ export function serviceRefusal(c, a, borrowed = { home: borrowedHome, scheduler: () => borrowedScheduler() }) {
58
+ if (READ_ONLY(c, a))
59
+ return null;
60
+ if ((c === "launchctl" || c === "systemctl") && borrowed.home()) {
61
+ return `refused: ${c} ${a[0] ?? ""} would change this user's real services, but HOME (${homedir()}) is not the user's home — a test or a sandbox. Set PANTHEON_SERVICES_ANY_HOME=1 to allow it.`;
62
+ }
63
+ if (isSchtasks(c) && borrowed.scheduler()) {
64
+ return `refused: schtasks ${a[0] ?? ""} would change this user's real Task Scheduler task, but this Pantheon home (${CONFIG_DIR}) is not the user's own — a test or a sandbox. Set PANTHEON_SERVICES_ANY_HOME=1 to allow it.`;
28
65
  }
29
- return execFileSync(c, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) ?? "";
66
+ return null;
67
+ }
68
+ function guardedExecFile(c, a) {
69
+ const refused = serviceRefusal(c, a);
70
+ if (refused)
71
+ throw new Error(refused);
72
+ return execFileSync(c, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }) ?? "";
30
73
  }
31
74
  export const realSys = () => ({
32
75
  platform: platform(),
@@ -51,11 +94,8 @@ export function servicePath(envPath = process.env.PATH ?? "", managed = managedL
51
94
  const last = managed ? [posix.dirname(managed.node)] : [];
52
95
  return [...new Set([...first, ...envPath.split(":"), ...SERVICE_BASE_PATH, ...last].filter(Boolean))].join(":");
53
96
  }
54
- export function servicesUnavailable(plat = platform()) {
55
- if (plat !== "win32")
56
- return null;
57
- return "not available — Pantheon does not run in the background on Windows yet. "
58
- + "Your agent still answers peers whenever it is open; nothing runs here while you are away.";
97
+ export function servicesUnavailable(_plat = platform()) {
98
+ return null;
59
99
  }
60
100
  export function plistPath(kind = "worker") {
61
101
  return join(homedir(), "Library", "LaunchAgents", `${LABEL(kind)}.plist`);
@@ -157,15 +197,16 @@ const systemdValue = (v) => {
157
197
  throw new Error(`a path with a line break in it cannot be written into a systemd unit: ${JSON.stringify(v)}`);
158
198
  return v.replace(/%/g, "%%");
159
199
  };
160
- export function systemdUnit(kind, cwd, args, path = servicePath()) {
200
+ export function systemdUnit(kind, cwd, args, path = servicePath(), opts = {}) {
161
201
  const description = kind === "relay"
162
202
  ? "pantheon relay — the coordination plane for a space"
163
203
  : kind === "worker"
164
204
  ? "pantheon worker — answer peers while away"
165
205
  : "pantheon notifier — alert a person when their decision is required";
206
+ const graphical = kind === "notifier" && !!opts.graphical;
166
207
  return `[Unit]
167
208
  Description=${description}
168
- After=network-online.target
209
+ ${graphical ? "After=graphical-session.target\nPartOf=graphical-session.target" : "After=network-online.target"}
169
210
 
170
211
  [Service]
171
212
  Type=simple
@@ -176,9 +217,47 @@ Restart=always
176
217
  RestartSec=30
177
218
 
178
219
  [Install]
179
- WantedBy=default.target
220
+ WantedBy=${graphical ? "graphical-session.target" : "default.target"}
180
221
  `;
181
222
  }
223
+ export const SESSION_ENV_VARS = ["DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY"];
224
+ export function sessionEnvToImport(managerEnv, shellEnv = process.env, exists = existsSync) {
225
+ if (shellEnv.SSH_CONNECTION || shellEnv.SSH_CLIENT || shellEnv.SSH_TTY)
226
+ return [];
227
+ if (!linuxGraphicalSession(shellEnv))
228
+ return [];
229
+ if (!shellEnv.DBUS_SESSION_BUS_ADDRESS || managerEnv.DBUS_SESSION_BUS_ADDRESS)
230
+ return [];
231
+ const runtime = managerEnv.XDG_RUNTIME_DIR || shellEnv.XDG_RUNTIME_DIR;
232
+ if (runtime && exists(join(runtime, "bus")))
233
+ return [];
234
+ return ["DBUS_SESSION_BUS_ADDRESS"];
235
+ }
236
+ export function dropOtherTargetLink(unit, graphical) {
237
+ const other = graphical ? "default.target" : "graphical-session.target";
238
+ const link = join(dirname(unit), `${other}.wants`, basename(unit));
239
+ try {
240
+ lstatSync(link);
241
+ }
242
+ catch {
243
+ return null;
244
+ }
245
+ try {
246
+ rmSync(link, { force: true });
247
+ return link;
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
253
+ export function graphicalSessionActive(sys = realSys()) {
254
+ try {
255
+ return String(sys.exec("systemctl", ["--user", "is-active", "graphical-session.target"])).trim() === "active";
256
+ }
257
+ catch {
258
+ return false;
259
+ }
260
+ }
182
261
  export function lingerEnabled(sys = realSys()) {
183
262
  try {
184
263
  const out = sys.exec("loginctl", ["show-user", sys.user || String(sys.uid), "--property=Linger"]);
@@ -193,6 +272,8 @@ export function installService(kind, cwd, args, sys = realSys()) {
193
272
  const noServices = servicesUnavailable(sys.platform);
194
273
  if (noServices)
195
274
  throw new Error(noServices.replace(/^not available — /, ""));
275
+ if (sys.platform === "win32")
276
+ return installWindows(kind, resolve(cwd), args, sys);
196
277
  const out = [];
197
278
  let loaded = true;
198
279
  let reason;
@@ -233,13 +314,35 @@ export function installService(kind, cwd, args, sys = realSys()) {
233
314
  out.push(` stop: pantheon service uninstall ${kind}`);
234
315
  }
235
316
  else {
236
- if (kind === "menubar" || kind === "notifier") {
237
- throw new Error(`${kind === "menubar" ? "the menu bar" : "desktop alerts"} run on macOS only, and this machine reports ${sys.platform}`);
317
+ if (kind === "menubar") {
318
+ throw new Error(`the menu bar runs on macOS only, and this machine reports ${sys.platform} — on Linux, pantheon board is the view`);
238
319
  }
239
- const unit = unitPath(kind, systemdUserEnv(sys));
320
+ const managerEnv = systemdUserEnv(sys);
321
+ const graphical = kind === "notifier" && graphicalSessionActive(sys);
322
+ const unit = unitPath(kind, managerEnv);
240
323
  mkdirSync(dirname(unit), { recursive: true });
241
- writeFileSync(unit, systemdUnit(kind, abs, args));
324
+ writeFileSync(unit, systemdUnit(kind, abs, args, servicePath(), { graphical }));
242
325
  out.push(` wrote ${unit}`);
326
+ const dropped = dropOtherTargetLink(unit, graphical);
327
+ if (dropped)
328
+ out.push(` removed ${dropped} (an earlier install's start-up link)`);
329
+ if (kind === "notifier") {
330
+ const missing = sessionEnvToImport(managerEnv);
331
+ if (missing.length) {
332
+ try {
333
+ sys.exec("systemctl", ["--user", "import-environment", ...missing]);
334
+ out.push(` gave systemd this desktop session's bus address (${missing.join(", ")}); it lasts until systemd --user stops, and is good only while this session is`);
335
+ out.push(` ! your desktop does not pass it to systemd at login — for alerts after the next login, add this to your session's autostart:`);
336
+ out.push(` dbus-update-activation-environment --systemd ${missing.join(" ")}`);
337
+ }
338
+ catch (e) {
339
+ out.push(` could not pass ${missing.join(", ")} to systemd (${e.message.split("\n")[0]}) — the notifier cannot reach this session's bus until you add dbus-update-activation-environment --systemd ${missing.join(" ")} to your session's autostart`);
340
+ }
341
+ }
342
+ out.push(graphical
343
+ ? " starts with your desktop session (graphical-session.target) and stops when you log out"
344
+ : " starts with your user session (default.target) — this desktop does not use graphical-session.target");
345
+ }
243
346
  try {
244
347
  sys.exec("systemctl", ["--user", "daemon-reload"]);
245
348
  sys.exec("systemctl", ["--user", "enable", "--now", `pantheon-${kind}`]);
@@ -262,7 +365,10 @@ export function uninstallService(kind, sys = realSys()) {
262
365
  const strays = unitPaths(kind, {}).filter((u) => existsSync(u));
263
366
  for (const u of strays)
264
367
  unlinkSync(u);
265
- return strays.length ? strays.map((u) => ` removed ${u}`) : [` no ${kind} service installed`];
368
+ const host = uninstallWindows(kind, sys);
369
+ if (!host)
370
+ return strays.length ? strays.map((u) => ` removed ${u}`) : [` no ${kind} service installed`];
371
+ return [...host, ...strays.map((u) => ` removed ${u}`)];
266
372
  }
267
373
  if (sys.platform === "darwin") {
268
374
  const p = plistPath(kind);
@@ -306,6 +412,10 @@ export function uninstallService(kind, sys = realSys()) {
306
412
  return out;
307
413
  }
308
414
  export function disableService(kind, sys = realSys()) {
415
+ if (sys.platform === "win32") {
416
+ disableWindows(kind, sys);
417
+ return;
418
+ }
309
419
  if (sys.platform === "darwin") {
310
420
  try {
311
421
  sys.exec("launchctl", ["bootout", `gui/${sys.uid}/${LABEL(kind)}`]);
@@ -326,6 +436,11 @@ function outOf(exec, c, a) {
326
436
  }
327
437
  }
328
438
  export function serviceState(kind, exec = realExec, plat = platform(), uid = process.getuid?.() ?? 501) {
439
+ if (plat === "win32") {
440
+ const f = hostFiles();
441
+ const loaded = outOf(exec, "schtasks", ["/Query", "/TN", taskName(exec)]).ok;
442
+ return { disabled: isHostKind(kind) && existsSync(f.disabled(kind)), loaded, running: isHostKind(kind) && !!hostChildRunning(kind), ...(isHostKind(kind) ? { from: f.definition(kind) } : {}) };
443
+ }
329
444
  if (plat === "darwin") {
330
445
  const dis = outOf(exec, "launchctl", ["print-disabled", `gui/${uid}`]).out;
331
446
  const disabled = new RegExp(`"${LABEL(kind).replace(/\./g, "\\.")}"\\s*=>\\s*(disabled|true)`).test(dis);
@@ -344,8 +459,20 @@ export function restartService(kind, exec = realExec, opts = {}) {
344
459
  const uid = process.getuid?.() ?? 501;
345
460
  const st = serviceState(kind, exec, plat, uid);
346
461
  if (st.disabled)
347
- return [` ${kind}: left stopped (you stopped it) — start it again: pantheon service install ${kind}`];
462
+ return [` ${kind}: left stopped (you stopped it) — start it again: pantheon service ${plat === "win32" ? "start" : "install"} ${kind}`];
348
463
  try {
464
+ if (plat === "win32") {
465
+ if (hostAlive(readHostState())) {
466
+ const f = hostFiles();
467
+ mkdirSync(f.dir, { recursive: true });
468
+ writeFileSync(f.restart(kind), new Date().toISOString());
469
+ return [` ${kind}: restarted on the new code (the Pantheon host starts it again within seconds)`];
470
+ }
471
+ if (!st.loaded)
472
+ return [` ${kind}: the ${taskName(exec)} task is not registered — pantheon service install ${kind}`];
473
+ exec("schtasks", ["/Run", "/TN", taskName(exec)]);
474
+ return [` ${kind}: the Pantheon host was not running — started it, and the ${kind} with it, on the new code`];
475
+ }
349
476
  if (plat === "darwin") {
350
477
  if (st.loaded && st.from && resolve(st.from) !== resolve(plistPath(kind)) && existsSync(plistPath(kind))) {
351
478
  exec("launchctl", ["bootout", `gui/${uid}/${LABEL(kind)}`]);
@@ -377,8 +504,20 @@ export function restartService(kind, exec = realExec, opts = {}) {
377
504
  return [` ${kind}: could not restart (${e.message.split("\n")[0]}) — pantheon service restart ${kind}`];
378
505
  }
379
506
  }
380
- export function refreshServiceDefinition(kind, plat = platform()) {
507
+ export function refreshServiceDefinition(kind, plat = platform(), sys) {
381
508
  try {
509
+ if (plat === "win32") {
510
+ if (!isHostKind(kind) || !existsSync(hostFiles().definition(kind)))
511
+ return { changed: false };
512
+ const s = sys ?? realSys();
513
+ const t = ensureTask(s, undefined, { migrate: "unattended" });
514
+ const name = taskName(s.exec, s.env);
515
+ const said = [
516
+ ...(t.changed ? [` ${kind}: the ${name} task now runs ${t.launch.command} — from the host's next start`] : []),
517
+ ...(t.migrated ? [` removed the "${LEGACY_TASK_NAME}" task an earlier Pantheon registered — ${name} starts the host from your next logon (the one running now keeps running)`] : []),
518
+ ];
519
+ return said.length ? { changed: false, line: said.join("\n") } : { changed: false };
520
+ }
382
521
  if (plat === "darwin") {
383
522
  const p = plistPath(kind);
384
523
  if (!existsSync(p))
@@ -397,21 +536,21 @@ export function refreshServiceDefinition(kind, plat = platform()) {
397
536
  writeFileSync(p, servicePlist(kind, cwd, logDir, argv.slice(at + 1)));
398
537
  return { changed: true, line: ` ${kind}: its log now goes to ${join(logDir, `${kind}.log`)}${log ? ` (was ${unxml(log)})` : ""}` };
399
538
  }
400
- if (plat === "win32")
401
- return { changed: false };
402
539
  const u = existingUnitPath(kind);
403
540
  if (!u)
404
541
  return { changed: false };
405
542
  const unit = readFileSync(u, "utf8");
406
- if (/^Environment="PATH=/m.test(unit) && !/^Environment="PATH=.*\$\$/m.test(unit))
407
- return { changed: false };
543
+ const dropped = dropOtherTargetLink(u, kind === "notifier" && /^WantedBy=graphical-session\.target/m.test(unit));
544
+ if (/^Environment="PATH=/m.test(unit) && !/^Environment="PATH=.*\$\$/m.test(unit)) {
545
+ return dropped ? { changed: true, line: ` ${kind}: removed a second start-up link (${dropped})` } : { changed: false };
546
+ }
408
547
  const exec = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? "";
409
548
  const words = exec.startsWith('"') ? [...exec.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%").replace(/\$\$/g, "$")) : exec.split(/\s+/);
410
549
  const at = words.indexOf(kind);
411
550
  const cwd = (/^WorkingDirectory=(.*)$/m.exec(unit)?.[1] ?? homedir()).replace(/%%/g, "%");
412
551
  if (at < 0)
413
552
  return { changed: false };
414
- writeFileSync(u, systemdUnit(kind, cwd, words.slice(at + 1)));
553
+ writeFileSync(u, systemdUnit(kind, cwd, words.slice(at + 1), servicePath(), { graphical: /^WantedBy=graphical-session\.target/m.test(unit) }));
415
554
  return { changed: true, line: ` ${kind}: its unit now quotes its paths and carries your PATH` };
416
555
  }
417
556
  catch (e) {
@@ -422,11 +561,298 @@ const unxml = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp
422
561
  export function serviceInstalled(kind, plat = platform()) {
423
562
  try {
424
563
  if (plat === "win32")
425
- return false;
564
+ return isHostKind(kind) && existsSync(hostFiles().definition(kind));
426
565
  return plat === "darwin" ? existsSync(plistPath(kind)) : existingUnitPath(kind) !== null;
427
566
  }
428
567
  catch {
429
568
  return false;
430
569
  }
431
570
  }
571
+ const blockingSleep = (ms) => { try {
572
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
573
+ }
574
+ catch { } };
575
+ function waitFor(done, ms, sleep) {
576
+ for (let waited = 0;; waited += 200) {
577
+ if (done())
578
+ return true;
579
+ if (waited >= ms)
580
+ return false;
581
+ sleep(200);
582
+ }
583
+ }
584
+ function nonDefaultHome() {
585
+ const ph = process.env.PANTHEON_HOME;
586
+ if (!ph)
587
+ return null;
588
+ return resolve(ph).toLowerCase() === resolve(join(homedir(), ".pantheon")).toLowerCase() ? null : resolve(ph);
589
+ }
590
+ export function windowsVersionManaged(p) {
591
+ return isVersionManagedPath(p)
592
+ || /[\\/]nvm[\\/]v\d+\.\d+\.\d+[\\/]/i.test(p)
593
+ || /[\\/]volta[\\/]tools[\\/]/i.test(p)
594
+ || /[\\/]fnm[\\/]node-versions[\\/]/i.test(p)
595
+ || /[\\/]fnm_multishells[\\/]/i.test(p)
596
+ || /[\\/]scoop[\\/]apps[\\/]nodejs(?:-lts)?[\\/]\d/i.test(p);
597
+ }
598
+ export function hostRuntime(o) {
599
+ if (o.layout)
600
+ return { node: o.layout.node, cli: o.layout.cli, flags: ["--use-system-ca"] };
601
+ const moving = windowsVersionManaged;
602
+ if (!moving(o.execPath) && !moving(o.cli))
603
+ return { node: o.execPath, cli: o.cli, flags: o.flags };
604
+ if (o.installed && o.exists(o.installed.node) && o.exists(o.installed.cli))
605
+ return { node: o.installed.node, cli: o.installed.cli, flags: ["--use-system-ca"] };
606
+ if (!moving(o.cli)) {
607
+ const node = o.nodesOnPath.find((n) => !!n && !moving(n) && o.exists(n));
608
+ if (node)
609
+ return { node, cli: o.cli, flags: o.flags };
610
+ }
611
+ throw new Error(`this Pantheon runs from ${moving(o.cli) ? o.cli : o.execPath}, a folder a Node version manager replaces when you switch Node versions — a logon task naming it would start nothing after the next switch. Install Pantheon with its own Node (irm https://relay.joinpantheon.network/install.ps1 | iex) or with a Node from nodejs.org (npm install -g @join-pantheon/cli), then run this again`);
612
+ }
613
+ function installedManagedLayout(env) {
614
+ const local = env.LOCALAPPDATA;
615
+ return local ? managedLayout(`${local.replace(/[\\/]+$/, "")}\\Pantheon\\runtime\\node\\node.exe`, "win32") : null;
616
+ }
617
+ export function desiredTask(sys, log, rt) {
618
+ const env = sys.env ?? process.env;
619
+ const runtime = rt || !sys.hostRuntime ? hostRuntime({
620
+ execPath: process.execPath, cli: resolve(HERE, "cli.js"), flags: childNodeFlags(),
621
+ layout: managedLayout(process.execPath, "win32"), installed: installedManagedLayout(env),
622
+ nodesOnPath: [which("node") ?? ""], exists: existsSync,
623
+ ...rt,
624
+ }) : sys.hostRuntime;
625
+ const shim = ensureHostShim({ exec: (c, a) => sys.exec(c, a), env, ...(log ? { log } : {}) });
626
+ const home = nonDefaultHome();
627
+ const launch = hostLaunch({ shim, node: runtime.node, flags: runtime.flags, cli: runtime.cli, home, launch: hostFiles().launch, powershell: windowsPowerShell(env) });
628
+ const name = taskName(sys.exec, env);
629
+ return { launch, argv: hostArgv(runtime), name, xml: taskXml({ launch, userId: taskUserId(sys.user, env), workingDirectory: homedir(), name }) };
630
+ }
631
+ export function ensureTask(sys, log, opts = {}) {
632
+ const f = hostFiles();
633
+ const { xml, launch, argv, name } = desiredTask(sys, log);
634
+ const text = launchFileText(argv);
635
+ let had = null;
636
+ try {
637
+ had = readFileSync(f.launch, "utf8");
638
+ }
639
+ catch { }
640
+ if (had !== text) {
641
+ mkdirSync(f.dir, { recursive: true });
642
+ writeFileSync(`${f.launch}.next`, text);
643
+ renameSync(`${f.launch}.next`, f.launch);
644
+ }
645
+ let last = null;
646
+ try {
647
+ last = fromUtf16le(readFileSync(f.taskXml));
648
+ }
649
+ catch { }
650
+ let registered = false;
651
+ try {
652
+ sys.exec("schtasks", ["/Query", "/TN", name]);
653
+ registered = true;
654
+ }
655
+ catch { }
656
+ let changed = false;
657
+ if (!registered || last !== xml) {
658
+ mkdirSync(f.dir, { recursive: true });
659
+ const next = `${f.taskXml}.next`;
660
+ writeFileSync(next, utf16le(xml));
661
+ try {
662
+ sys.exec("schtasks", ["/Create", "/XML", next, "/TN", name, "/F"]);
663
+ }
664
+ catch (e) {
665
+ rmSync(next, { force: true });
666
+ throw e;
667
+ }
668
+ renameSync(next, f.taskXml);
669
+ changed = true;
670
+ }
671
+ const migrated = opts.migrate ? replaceLegacyTask(sys, name, opts.migrate === "unattended", log) : false;
672
+ return { changed, launch, migrated };
673
+ }
674
+ function replaceLegacyTask(sys, name, unattended, log) {
675
+ const env = sys.env ?? process.env;
676
+ if (!legacyTaskIsUsers(sys.exec, env))
677
+ return false;
678
+ if (!unattended)
679
+ for (const l of stopWindowsHost(sys))
680
+ log?.(l);
681
+ try {
682
+ sys.exec("schtasks", ["/Delete", "/TN", LEGACY_TASK_NAME, "/F"]);
683
+ log?.(` removed the "${LEGACY_TASK_NAME}" task an earlier Pantheon registered — it is ${name} now, so a second person on this PC can have their own${unattended && hostUp() ? " (the host running now keeps running; from your next logon the new task starts it)" : ""}`);
684
+ return true;
685
+ }
686
+ catch (e) {
687
+ log?.(` could not remove the older "${LEGACY_TASK_NAME}" task (${e.message.split("\n")[0]}) — schtasks /Delete /TN ${LEGACY_TASK_NAME} /F`);
688
+ return false;
689
+ }
690
+ }
691
+ const hostUp = () => hostAlive(readHostState());
692
+ const windowsBorrowed = (sys) => (sys.borrowed ?? (() => borrowedScheduler(sys.env ?? process.env)))();
693
+ function installWindows(kind, cwd, args, sys) {
694
+ if (!isHostKind(kind)) {
695
+ throw new Error(kind === "menubar"
696
+ ? "the menu bar is macOS's — on Windows the Pantheon tray icon runs with the background host that pantheon service install worker (or notifier) sets up"
697
+ : "the relay does not run as a Windows background service — run it in a terminal (pantheon relay), or use the hosted relay");
698
+ }
699
+ const out = [];
700
+ let loaded = true;
701
+ let reason;
702
+ const f = hostFiles();
703
+ out.push(` wrote ${writeDefinition({ kind, cwd, args })}`);
704
+ rmSync(f.disabled(kind), { force: true });
705
+ if (clearTrayQuit())
706
+ out.push(" the tray icon, which you had quit, shows again");
707
+ mkdirSync(serviceLogDir(), { recursive: true });
708
+ const name = taskName(sys.exec, sys.env);
709
+ try {
710
+ const t = ensureTask(sys, (l) => out.push(l), { migrate: true });
711
+ if (t.changed) {
712
+ out.push(` registered the ${name} task — it starts Pantheon's background host at logon, hidden${t.launch.how === "powershell" ? " (through PowerShell: a console may show for a moment at logon)" : ""}`);
713
+ }
714
+ if (hostUp())
715
+ out.push(` the host is running — it starts the ${kind} within seconds, and restarts it if it stops`);
716
+ else {
717
+ sys.exec("schtasks", ["/Run", "/TN", name]);
718
+ out.push(` started — the ${kind} now starts at logon and restarts if it stops`);
719
+ }
720
+ }
721
+ catch (e) {
722
+ const why = e.message.split("\n")[0];
723
+ loaded = false;
724
+ reason = why;
725
+ if (windowsBorrowed(sys)) {
726
+ reason = `this Pantheon home (${CONFIG_DIR}) is not your own, so Task Scheduler was not touched`;
727
+ out.push(` left unregistered — ${reason}; your own services are unchanged`);
728
+ }
729
+ else
730
+ out.push(` could not register or start the ${name} task: ${why}`);
731
+ }
732
+ out.push(` logs: ${serviceLogFile(kind)}`);
733
+ out.push(` stop: pantheon service uninstall ${kind}`);
734
+ return Object.assign(out, { loaded, ...(reason ? { reason } : {}) });
735
+ }
736
+ export function stopWindowsHost(sys = realSys()) {
737
+ const f = hostFiles();
738
+ if (!hostUp())
739
+ return [];
740
+ mkdirSync(f.dir, { recursive: true });
741
+ writeFileSync(f.stop, new Date().toISOString());
742
+ const gone = waitFor(() => !hostUp(), 10_000, sys.sleep ?? blockingSleep);
743
+ if (gone)
744
+ return [" the Pantheon host stopped (the tray with it)"];
745
+ const env = sys.env ?? process.env;
746
+ try {
747
+ sys.exec("schtasks", ["/End", "/TN", taskName(sys.exec, env)]);
748
+ }
749
+ catch { }
750
+ if (legacyTaskIsUsers(sys.exec, env)) {
751
+ try {
752
+ sys.exec("schtasks", ["/End", "/TN", LEGACY_TASK_NAME]);
753
+ }
754
+ catch { }
755
+ }
756
+ const pid = readHostState()?.pid;
757
+ return [` the Pantheon host did not stop in time — ended its task${hostUp() ? `; pid ${pid} still answers: taskkill /PID ${pid} /T /F` : ""}`];
758
+ }
759
+ function uninstallWindows(kind, sys) {
760
+ if (!isHostKind(kind))
761
+ return null;
762
+ const f = hostFiles();
763
+ const def = f.definition(kind);
764
+ if (!existsSync(def))
765
+ return null;
766
+ const out = [];
767
+ rmSync(def, { force: true });
768
+ rmSync(f.disabled(kind), { force: true });
769
+ rmSync(f.restart(kind), { force: true });
770
+ const others = HOST_KINDS.filter((k) => k !== kind && existsSync(f.definition(k)));
771
+ const sleep = sys.sleep ?? blockingSleep;
772
+ if (others.length) {
773
+ const stopped = waitFor(() => !hostChildRunning(kind), 6_000, sleep);
774
+ out.push(stopped ? " stopped" : " the host stops it within seconds");
775
+ out.push(` removed ${def}`);
776
+ out.push(` the Pantheon host keeps running the ${others.join(" and ")}`);
777
+ return out;
778
+ }
779
+ out.push(...stopWindowsHost(sys));
780
+ const env = sys.env ?? process.env;
781
+ const name = taskName(sys.exec, env);
782
+ const remove = (task) => {
783
+ try {
784
+ sys.exec("schtasks", ["/Delete", "/TN", task, "/F"]);
785
+ out.push(` removed the ${task} task`);
786
+ if (task === name) {
787
+ rmSync(f.taskXml, { force: true });
788
+ rmSync(f.launch, { force: true });
789
+ }
790
+ }
791
+ catch (e) {
792
+ let still = false;
793
+ try {
794
+ sys.exec("schtasks", ["/Query", "/TN", task]);
795
+ still = true;
796
+ }
797
+ catch { }
798
+ if (still) {
799
+ out.push(windowsBorrowed(sys)
800
+ ? ` left the ${task} task alone — this Pantheon home (${CONFIG_DIR}) is not your own; with nothing installed here its host exits at once`
801
+ : ` could not remove the ${task} task (${e.message.split("\n")[0]}) — schtasks /Delete /TN ${task} /F`);
802
+ }
803
+ }
804
+ };
805
+ remove(name);
806
+ if (legacyTaskIsUsers(sys.exec, env))
807
+ remove(LEGACY_TASK_NAME);
808
+ out.push(` removed ${def}`);
809
+ if (!hostUp()) {
810
+ try {
811
+ rmSync(f.dir, { recursive: true, force: true });
812
+ out.push(` removed ${f.dir}`);
813
+ }
814
+ catch { }
815
+ }
816
+ return out;
817
+ }
818
+ function disableWindows(kind, sys) {
819
+ if (!isHostKind(kind))
820
+ throw new Error(`the ${kind} is not a background service on Windows`);
821
+ const f = hostFiles();
822
+ mkdirSync(f.dir, { recursive: true });
823
+ writeFileSync(f.disabled(kind), new Date().toISOString());
824
+ waitFor(() => !hostChildRunning(kind), 6_000, sys.sleep ?? blockingSleep);
825
+ }
826
+ export function enableService(kind, sys = realSys()) {
827
+ if (sys.platform !== "win32")
828
+ throw new Error("service start is the Windows host's; elsewhere: pantheon service install");
829
+ if (!isHostKind(kind) || !readDefinition(kind))
830
+ throw new Error(`the ${kind} is not installed here — pantheon service install ${kind}`);
831
+ const f = hostFiles();
832
+ const was = existsSync(f.disabled(kind));
833
+ rmSync(f.disabled(kind), { force: true });
834
+ const tray = clearTrayQuit() ? [" the tray icon, which you had quit, shows again"] : [];
835
+ if (hostUp())
836
+ return [` ${kind}: ${was ? "resumed" : "already on"} — the Pantheon host ${was ? "starts it within seconds" : "runs it"}`, ...tray];
837
+ const lines = [];
838
+ ensureTask(sys, (l) => lines.push(l), { migrate: true });
839
+ sys.exec("schtasks", ["/Run", "/TN", taskName(sys.exec, sys.env)]);
840
+ return [...lines, ` ${kind}: ${was ? "resumed" : "on"} — started the Pantheon host, which runs it`, ...tray];
841
+ }
842
+ export function showTray(sys = realSys()) {
843
+ const f = hostFiles();
844
+ const quit = clearTrayQuit();
845
+ if (!HOST_KINDS.some((k) => existsSync(f.definition(k)))) {
846
+ return [" the tray runs with Pantheon's background host, and nothing is installed here — pantheon service install notifier (or worker)"];
847
+ }
848
+ if (hostUp()) {
849
+ mkdirSync(f.dir, { recursive: true });
850
+ writeFileSync(f.restart("tray"), new Date().toISOString());
851
+ return [` ${quit ? "the tray icon shows again within seconds" : "the tray icon is started again within seconds"}`];
852
+ }
853
+ const lines = [];
854
+ ensureTask(sys, (l) => lines.push(l), { migrate: true });
855
+ sys.exec("schtasks", ["/Run", "/TN", taskName(sys.exec, sys.env)]);
856
+ return [...lines, " started the Pantheon host, and the tray icon with it"];
857
+ }
432
858
  //# sourceMappingURL=service.js.map
@@ -7,6 +7,7 @@ import { applyProfileInit, loadProfile, profilePath, saveProfile, wakeMode } fro
7
7
  import { installService as realInstall, uninstallService as realUninstall, serviceInstalled as realInstalled } from "./service.js";
8
8
  import { personaDir } from "./persona.js";
9
9
  import { hudBinaryPath } from "./notifier.js";
10
+ import { hostEnv } from "./host-env.js";
10
11
  import { grant, inWokenRun, loadPolicy, revoke, savePolicy, unsandboxedAllowed } from "./worker-policy.js";
11
12
  export const DECISION_AREAS = [
12
13
  { id: "product", label: "Product — what we build, for whom, and what goes in a release" },
@@ -175,6 +176,8 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
175
176
  const serves = ctx.project ? { path: ctx.project, repos: true } : ctx.serves ?? null;
176
177
  const os = deps.platform ?? process.platform;
177
178
  const mac = os === "darwin";
179
+ const linuxDesktop = os === "linux" && (deps.desktop ?? hostEnv(process.env, os).desktop);
180
+ const winDesktop = os === "win32" && (deps.desktop ?? hostEnv(process.env, os).desktop);
178
181
  const install = deps.installService ?? realInstall;
179
182
  const uninstall = deps.uninstallService ?? realUninstall;
180
183
  const installed = deps.serviceInstalled ?? realInstalled;
@@ -295,7 +298,7 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
295
298
  warnings.push(`profile saved but not published (${e.message}) — run \`pantheon profile publish\` when the relay is reachable`);
296
299
  }
297
300
  }
298
- const wiring = deps.applyAgentWiring ?? ((ap) => installForUser(ap).map((t) => ({ tool: t.tool, status: t.status })));
301
+ const wiring = deps.applyAgentWiring ?? ((ap) => installForUser(ap, undefined, { direct: true }).map((t) => ({ tool: t.tool, status: t.status })));
299
302
  try {
300
303
  const rows = wiring(a.approval);
301
304
  const failed = rows.filter((t) => /^failed/.test(t.status));
@@ -385,13 +388,27 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
385
388
  applied.push(`alerts off — decisions still wait in ${waitIn}`);
386
389
  }
387
390
  else if (optOut.notifications) {
388
- applied.push(`alerts: not installed (--no-notifications) — decisions wait in ${waitIn}. Your choice (${chosenAlerts.toLowerCase()}) is saved for when you turn them on${mac ? ": pantheon service install notifier" : ""}`);
391
+ applied.push(`alerts: not installed (--no-notifications) — decisions wait in ${waitIn}. Your choice (${chosenAlerts.toLowerCase()}) is saved for when you turn them on${mac || linuxDesktop || winDesktop ? ": pantheon service install notifier" : ""}`);
389
392
  }
390
- else if (!mac) {
391
- applied.push(`alerts: none on this machine — desktop alerts are macOS-only. Decisions wait in ${waitIn} (your choice, ${chosenAlerts.toLowerCase()}, is saved)`);
393
+ else if (os === "linux" && !linuxDesktop) {
394
+ applied.push(`alerts: none in this session — desktop alerts need a graphical login, and this is SSH or headless. Decisions wait in ${waitIn} and pantheon board --watch (your choice, ${chosenAlerts.toLowerCase()}, is saved)`);
395
+ }
396
+ else if (os === "win32" && !winDesktop) {
397
+ applied.push(`alerts: none in this session — desktop alerts need the Windows desktop, and this is a remote shell (SSH). Decisions wait in ${waitIn} and pantheon board; set Pantheon up from a session at the desktop for alerts (your choice, ${chosenAlerts.toLowerCase()}, is saved)`);
398
+ }
399
+ else if (!mac && !linuxDesktop && !winDesktop) {
400
+ applied.push(`alerts: none on this machine — desktop alerts run on macOS, Windows and Linux desktops, and this is ${os}. Decisions wait in ${waitIn} (your choice, ${chosenAlerts.toLowerCase()}, is saved)`);
392
401
  }
393
402
  else {
394
- if (!installed("notifier")) {
403
+ if (!installed("notifier") && (linuxDesktop || winDesktop)) {
404
+ try {
405
+ install("notifier", deps.home ?? homedir(), []);
406
+ }
407
+ catch (e) {
408
+ warnings.push(`alerts did not install: ${e.message}`);
409
+ }
410
+ }
411
+ else if (!installed("notifier")) {
395
412
  if (existsSync(hudBinaryPath(deps.home))) {
396
413
  try {
397
414
  install("notifier", deps.home ?? homedir(), []);