@join-pantheon/cli 0.32.1 → 0.33.0-rc.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/dist/machine.js CHANGED
@@ -3,7 +3,8 @@ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, re
3
3
  import { homedir, hostname, networkInterfaces, platform } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { CONFIG_DIR } from "./config.js";
6
- import { borrowedHome, disableService, existingUnitPath, plistPath, serviceInstalled, serviceLogFile, unitPath, unitPaths, uninstallService } from "./service.js";
6
+ import { borrowedHome, disableService, existingUnitPath, plistPath, serviceInstalled, serviceLogFile, stopWindowsHost, unitPath, unitPaths, uninstallService } from "./service.js";
7
+ import { LEGACY_TASK_NAME, hostAlive, hostChildRunning, hostFiles, isHostKind, readHostState, recordedTaskName } from "./win-host.js";
7
8
  import { copilotMcpConfigFile, vsCodeUserDir, kiroMcpConfigFile, kiroDir } from "./install.js";
8
9
  const sh = (cmd) => {
9
10
  try {
@@ -100,7 +101,33 @@ function fromSystemd(kind) {
100
101
  ...(s.lastExit ? { lastExit: s.lastExit } : {}),
101
102
  };
102
103
  }
104
+ export function elapsed(ms) {
105
+ const s = Math.max(0, Math.floor(ms / 1000));
106
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
107
+ const two = (n) => String(n).padStart(2, "0");
108
+ return `${d ? `${d}-` : ""}${d || h ? `${two(h)}:` : ""}${two(m)}:${two(sec)}`;
109
+ }
110
+ export function hostProc(kind, o = {}) {
111
+ if ((o.platform ?? platform()) !== "win32" || !isHostKind(kind))
112
+ return {};
113
+ const home = o.home ?? CONFIG_DIR;
114
+ if (!existsSync(hostFiles(home).definition(kind)))
115
+ return {};
116
+ const now = o.now ?? Date.now();
117
+ const st = readHostState(home);
118
+ const up = hostAlive(st, now, o.alive);
119
+ const run = hostChildRunning(kind, home, now, o.alive);
120
+ const r = up ? st.children[kind] : undefined;
121
+ return {
122
+ managedBy: "host", label: `task ${recordedTaskName(home) ?? LEGACY_TASK_NAME}`, logPath: hostFiles(home).childLog(kind),
123
+ ...(run ? { running: true, pid: run.pid, ...(run.since ? { since: elapsed(now - run.since) } : {}) } : { running: false }),
124
+ ...(r?.lastExit && r.lastExit !== "0" ? { lastExit: r.lastExit } : {}),
125
+ };
126
+ }
103
127
  const fromManager = (kind) => {
128
+ const h = hostProc(kind);
129
+ if (h.managedBy)
130
+ return h;
104
131
  const l = fromLaunchd(kind);
105
132
  return l.managedBy ? l : fromSystemd(kind);
106
133
  };
@@ -438,6 +465,7 @@ const defaultStopIo = (port = 8787) => ({
438
465
  borrowed: () => borrowedHome(),
439
466
  loadedFrom: (kind) => managerDefinitionPath(kind),
440
467
  kill: killPid,
468
+ ...(platform() === "win32" ? { stopHost: () => stopWindowsHost() } : {}),
441
469
  });
442
470
  export const borrowedHomeLine = (kind) => `${kind === "services" ? "services" : `${kind}:`} left alone — HOME (${homedir()}) is not your own home (a test, a sandbox or another account's folder), so the service manager's jobs here are the real user's, not this home's. Nothing was stopped; run this from your own login to stop your services`;
443
471
  export const isPantheonCommand = (cmd) => /pantheon/i.test(cmd) || /\bcli\.js\s+(relay|start)\b/.test(cmd);
@@ -500,7 +528,9 @@ export function stopProcResult(kind, io = defaultStopIo()) {
500
528
  else {
501
529
  const how = platform() === "darwin"
502
530
  ? `launchctl disable gui/${process.getuid?.() ?? 0}/dev.pantheon.${kind}`
503
- : `systemctl --user disable --now pantheon-${kind}`;
531
+ : platform() === "win32"
532
+ ? `pantheon service uninstall ${kind}`
533
+ : `systemctl --user disable --now pantheon-${kind}`;
504
534
  lines.push(`${kind}: could not disable the service (${e.message.split("\n")[0]}) — it may start again at login; try: ${how}`);
505
535
  }
506
536
  }
@@ -511,7 +541,7 @@ export function stopProcResult(kind, io = defaultStopIo()) {
511
541
  const stillUp = !!after.running && !k?.killed;
512
542
  if (disabled) {
513
543
  lines.unshift(`${kind}: ${stillUp ? "disabled, but a process is still running (see below)" : p.running || k?.killed ? "stopped, and will not start again at login" : "was not running; disabled, and will not start again at login"}`);
514
- lines.push(` start it again with: pantheon service install ${kind}`);
544
+ lines.push(` start it again with: pantheon service ${p.managedBy === "host" ? "start" : "install"} ${kind}`);
515
545
  }
516
546
  return { lines, stopped: (p.running && disabled && !stillUp) || !!k?.killed };
517
547
  }
@@ -576,6 +606,11 @@ export function stopMachine(what, opts, io = defaultStopIo(opts.port), hosts = h
576
606
  lines.push(`worker: ${k}`);
577
607
  }
578
608
  }
609
+ if (what === "all" && io.stopHost) {
610
+ const h = io.stopHost();
611
+ if (h.length)
612
+ lines.push(...h.map((l) => `host: ${l.trim().replace(/^the Pantheon host /, "")}`), " it starts again at your next logon (services stay paused), or now: pantheon service start worker");
613
+ }
579
614
  if (!relayStopped)
580
615
  return { lines };
581
616
  const teams = opts.teams ?? (opts.teamRelay ? [{ name: "", relay: opts.teamRelay }] : []);
package/dist/managed.js CHANGED
@@ -23,6 +23,7 @@ export function managedLayout(execPath = process.execPath, os = platform()) {
23
23
  root, nodeDir, node: p, npm: win32.join(nodeDir, "npm.cmd"), npmCli: win32.join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js"), binDir,
24
24
  launcher: win32.join(binDir, "pantheon.cmd"), pkgDir,
25
25
  entry: win32.join(pkgDir, "bin", "pantheon.js"), cli: win32.join(pkgDir, "dist", "cli.js"),
26
+ prevDir: win32.join(runtime, "cli.prev"),
26
27
  };
27
28
  }
28
29
  const p = posix.normalize(execPath);
@@ -42,8 +43,33 @@ export function managedLayout(execPath = process.execPath, os = platform()) {
42
43
  root, nodeDir, node: p, npm: posix.join(bin, "npm"), npmCli: posix.join(nodeDir, "lib", "node_modules", "npm", "bin", "npm-cli.js"), binDir,
43
44
  launcher: posix.join(binDir, "pantheon"), pkgDir,
44
45
  entry: posix.join(pkgDir, "bin", "pantheon.js"), cli: posix.join(pkgDir, "dist", "cli.js"),
46
+ prevDir: posix.join(runtime, "cli.prev"),
45
47
  };
46
48
  }
49
+ const shq = (p) => `'${p.replace(/'/g, `'\\''`)}'`;
50
+ export function launcherRepairLines(m, os = platform()) {
51
+ if (os === "win32") {
52
+ return [
53
+ "rem An upgrade cut off mid-swap leaves the previous version beside the runtime: put it back.",
54
+ 'if not exist "%~dp0..\\runtime\\node\\node_modules\\@join-pantheon\\cli\\" if exist "%~dp0..\\runtime\\cli.prev\\bin\\pantheon.js" move "%~dp0..\\runtime\\cli.prev" "%~dp0..\\runtime\\node\\node_modules\\@join-pantheon\\cli" >nul 2>&1',
55
+ ];
56
+ }
57
+ return [
58
+ "# An upgrade cut off mid-swap leaves the previous version beside the runtime: put it back.",
59
+ `[ -e ${shq(m.pkgDir)} ] || { [ -f ${shq(posix.join(m.prevDir, "bin", "pantheon.js"))} ] && mv ${shq(m.prevDir)} ${shq(m.pkgDir)} 2>/dev/null; }`,
60
+ ];
61
+ }
62
+ export function launcherWithRepair(current, m, os = platform()) {
63
+ if (current.includes("cli.prev"))
64
+ return null;
65
+ const nl = os === "win32" ? "\r\n" : "\n";
66
+ const lines = current.split(nl);
67
+ const at = os === "win32" ? lines.findIndex((l) => l.startsWith('"%~dp0..\\runtime\\node\\node.exe"')) : lines.findIndex((l) => l.startsWith("exec "));
68
+ if (at < 0)
69
+ return null;
70
+ lines.splice(at, 0, ...launcherRepairLines(m, os));
71
+ return lines.join(nl);
72
+ }
47
73
  export function childNodeFlags(execArgv = process.execArgv, layout = managedLayout()) {
48
74
  return execArgv.includes("--use-system-ca") || layout ? ["--use-system-ca"] : [];
49
75
  }
@@ -178,7 +204,7 @@ export function installerLine(relay, shell, args = []) {
178
204
  if (shell === "ps" && !args.length)
179
205
  return `irm ${base}/install.ps1 | iex`;
180
206
  const set = args.length ? `Set-Item Env:PANTHEON_ARGS '${args.join(" ")}'; ` : "";
181
- return `powershell -NoProfile -ExecutionPolicy Bypass -Command "${set}irm ${base}/install.ps1 | iex"`;
207
+ return `powershell -NoProfile -Command "${set}irm ${base}/install.ps1 | iex"`;
182
208
  }
183
209
  export function installerLineHere(relay, os = platform()) {
184
210
  return installerLine(relay, os === "win32" ? "ps" : "sh") ?? installerLine(HOSTED_INSTALLER_BASE, os === "win32" ? "ps" : "sh");
@@ -0,0 +1,501 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { busSocketPath, DBus, Notifications, portalOpenUri as busPortalOpenUri } from "./dbus.js";
7
+ import { CONFIG_DIR } from "./config.js";
8
+ import { hostEnv } from "./host-env.js";
9
+ import { childNodeFlags } from "./managed.js";
10
+ import { parseSystemdEnvironment } from "./service.js";
11
+ import { which } from "./which.js";
12
+ const HERE = dirname(fileURLToPath(import.meta.url));
13
+ export const LINUX_LIMITS = { summary: 80, line: 220, body: 600 };
14
+ export function noticeText(s, max, markup) {
15
+ const t = s.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g, "").trim();
16
+ const esc = (c) => (!markup ? c : c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c);
17
+ const pieces = Array.from(t, esc);
18
+ const whole = pieces.join("");
19
+ if (whole.length <= max)
20
+ return whole;
21
+ let out = "";
22
+ for (const p of pieces) {
23
+ if (out.length + p.length > max - 1)
24
+ break;
25
+ out += p;
26
+ }
27
+ return `${out.trimEnd()}…`;
28
+ }
29
+ export function linuxNoticeBody(n, markup) {
30
+ const lines = [];
31
+ const context = noticeText(n.context.replace(/\n/g, " "), LINUX_LIMITS.line, markup);
32
+ if (context)
33
+ lines.push(markup ? `<b>${context}</b>` : context);
34
+ const body = noticeText(n.body, LINUX_LIMITS.line * 2, markup);
35
+ if (body)
36
+ lines.push(body);
37
+ const who = noticeText(n.who.replace(/\n/g, " "), LINUX_LIMITS.line, markup);
38
+ if (who)
39
+ lines.push(markup ? `<i>${who}</i>` : who);
40
+ let out = lines.join("\n");
41
+ if (out.length > LINUX_LIMITS.body)
42
+ out = noticeText([n.context, n.body, n.who].filter(Boolean).join("\n"), LINUX_LIMITS.body, markup);
43
+ return out;
44
+ }
45
+ export function noticeActions(n) {
46
+ const out = [["default", "Open board"]];
47
+ if (n.kind === "decision" && n.details?.decision)
48
+ out.push(["done", "Done"]);
49
+ out.push(["board", "Open board"]);
50
+ return out;
51
+ }
52
+ export function noticeUrgency(n) {
53
+ return n.kind === "decision" ? 2 : 1;
54
+ }
55
+ export function toDesktopNotice(n, caps, replacesId = 0) {
56
+ const markup = caps.includes("body-markup");
57
+ return {
58
+ appName: "Pantheon",
59
+ summary: noticeText(n.heading.replace(/\n/g, " "), LINUX_LIMITS.summary, false),
60
+ body: caps.includes("body") || caps.length === 0 ? linuxNoticeBody(n, markup) : "",
61
+ actions: caps.includes("actions") ? noticeActions(n) : [],
62
+ urgency: noticeUrgency(n),
63
+ timeoutMs: n.ttl > 0 ? n.ttl * 1000 : 0,
64
+ replacesId,
65
+ hints: [
66
+ ["category", { sig: "s", value: n.kind === "decision" ? "im.received" : n.kind === "delivery" ? "transfer.complete" : n.kind === "worker_failed" ? "transfer.error" : "transfer.complete" }],
67
+ ...(n.sound ? [] : [["suppress-sound", { sig: "b", value: true }]]),
68
+ ],
69
+ };
70
+ }
71
+ export function notifySendArgs(n) {
72
+ const u = noticeUrgency(n);
73
+ return [
74
+ "--app-name=Pantheon",
75
+ `--urgency=${u === 2 ? "critical" : "normal"}`,
76
+ `--expire-time=${n.ttl > 0 ? n.ttl * 1000 : 0}`,
77
+ "--",
78
+ noticeText(n.heading.replace(/\n/g, " "), LINUX_LIMITS.summary, false),
79
+ linuxNoticeBody(n, false),
80
+ ];
81
+ }
82
+ export function sessionBusCandidates(env = process.env, exists = existsSync) {
83
+ const out = [];
84
+ const fromEnv = env.DBUS_SESSION_BUS_ADDRESS ? busSocketPath(env.DBUS_SESSION_BUS_ADDRESS, null) : null;
85
+ if (fromEnv && (fromEnv.startsWith("\0") || exists(fromEnv)))
86
+ out.push(fromEnv);
87
+ if (env.XDG_RUNTIME_DIR) {
88
+ const p = join(env.XDG_RUNTIME_DIR, "bus");
89
+ if (exists(p) && !out.includes(p))
90
+ out.push(p);
91
+ }
92
+ return out;
93
+ }
94
+ export function hudActionFor(n, action) {
95
+ if (action !== "done" || n.kind !== "decision" || !n.details?.decision)
96
+ return null;
97
+ return { action: "done", decision: n.details.decision, ...(n.details.act ? { act: n.details.act } : {}), ...(n.space ? { space: n.space } : {}) };
98
+ }
99
+ export function serverLabel(info) {
100
+ if (!info)
101
+ return "the notification server";
102
+ const NAMES = { "gnome-shell": "GNOME Shell", Plasma: "KDE Plasma", "Xfce Notify Daemon": "Xfce", mako: "mako", dunst: "dunst" };
103
+ const name = NAMES[info.name] ?? (info.name || info.vendor || "the notification server");
104
+ return info.version ? `${name} ${info.version}` : name;
105
+ }
106
+ const realOpen = async (path) => {
107
+ const bus = await DBus.connect(path, 3_000);
108
+ try {
109
+ return await Notifications.open(bus);
110
+ }
111
+ catch (e) {
112
+ bus.close();
113
+ throw e;
114
+ }
115
+ };
116
+ const realRun = (file, args, env, timeoutMs) => new Promise((done) => {
117
+ let out = "";
118
+ let child;
119
+ try {
120
+ child = spawn(file, args, { env, stdio: ["ignore", "pipe", "pipe"] });
121
+ }
122
+ catch {
123
+ done({ code: null, out: "" });
124
+ return;
125
+ }
126
+ const timer = setTimeout(() => { child.kill(); }, timeoutMs);
127
+ child.stdout?.on("data", (d) => { out += d.toString(); });
128
+ child.stderr?.on("data", (d) => { out += d.toString(); });
129
+ child.on("error", () => { clearTimeout(timer); done({ code: null, out }); });
130
+ child.on("close", (code) => { clearTimeout(timer); done({ code, out }); });
131
+ });
132
+ const thisCli = () => [process.execPath, ...childNodeFlags(), resolve(HERE, "cli.js")];
133
+ export class LinuxAlerts {
134
+ conn = null;
135
+ connecting = null;
136
+ shown = new Map();
137
+ byDecision = new Map();
138
+ actions = Promise.resolve();
139
+ saidNone = false;
140
+ retryAt = 0;
141
+ lastSaid = "";
142
+ say(line) { if (line !== this.lastSaid) {
143
+ this.lastSaid = line;
144
+ this.d.log(line);
145
+ } }
146
+ opening = new Set();
147
+ posting = new Set();
148
+ d;
149
+ constructor(deps = {}) {
150
+ const run = deps.run ?? realRun;
151
+ const cli = deps.cli ?? thisCli();
152
+ this.d = {
153
+ env: deps.env ?? process.env,
154
+ exists: deps.exists ?? existsSync,
155
+ open: deps.open ?? realOpen,
156
+ findNotifySend: deps.findNotifySend ?? (() => which("notify-send")),
157
+ run,
158
+ cli,
159
+ log: deps.log ?? ((l) => console.error(l)),
160
+ openBoard: deps.openBoard ?? ((env) => openBoardWith(cli, env)),
161
+ sessionEnv: deps.sessionEnv ?? realSessionEnv,
162
+ };
163
+ }
164
+ get live() { return this.shown.size; }
165
+ async connection() {
166
+ if (this.conn)
167
+ return this.conn;
168
+ if (this.connecting)
169
+ return this.connecting;
170
+ if (Date.now() < this.retryAt)
171
+ return null;
172
+ this.connecting = (async () => {
173
+ const errors = [];
174
+ for (const path of sessionBusCandidates(this.d.env, this.d.exists)) {
175
+ let n = null;
176
+ try {
177
+ n = await this.d.open(path);
178
+ const caps = await n.capabilities();
179
+ const info = await n.serverInfo().catch(() => null);
180
+ n.onAction((id, action, token) => this.onAction(id, action, token));
181
+ n.onClosed((id) => this.forget(id));
182
+ const conn = { n, caps, info, path };
183
+ n.onDisconnect(() => { if (this.conn === conn) {
184
+ this.conn = null;
185
+ this.shown.clear();
186
+ this.byDecision.clear();
187
+ } });
188
+ n.onServerChange?.(() => {
189
+ if (this.conn !== conn)
190
+ return;
191
+ if (this.shown.size)
192
+ this.d.log(`alerts: the notification server restarted — ${this.shown.size} earlier alert(s) no longer have working buttons`);
193
+ this.shown.clear();
194
+ this.byDecision.clear();
195
+ });
196
+ this.conn = conn;
197
+ this.say(`alerts: D-Bus notifications${caps.includes("actions") ? " with actions" : ", no buttons"} (${serverLabel(info)})`);
198
+ return this.conn;
199
+ }
200
+ catch (e) {
201
+ try {
202
+ n?.disconnect();
203
+ }
204
+ catch { }
205
+ errors.push(`${path.replace(/^\0/, "@")}: ${e.message.split("\n")[0]}`);
206
+ }
207
+ }
208
+ if (errors.length) {
209
+ this.retryAt = Date.now() + 60_000;
210
+ this.say(`alerts: could not use the session bus (${errors.join("; ")})`);
211
+ }
212
+ return null;
213
+ })().finally(() => { this.connecting = null; });
214
+ return this.connecting;
215
+ }
216
+ async show(notice, home = CONFIG_DIR) {
217
+ try {
218
+ const c = await this.connection();
219
+ if (c) {
220
+ const prev = notice.details?.decision ? this.byDecision.get(notice.details.decision) : undefined;
221
+ try {
222
+ const posting = c.n.notify(toDesktopNotice(notice, c.caps, prev && this.shown.has(prev) ? prev : 0)).then((id) => {
223
+ if (prev !== undefined && prev !== id)
224
+ this.shown.delete(prev);
225
+ if (c.caps.includes("actions")) {
226
+ this.shown.set(id, { notice, home });
227
+ if (notice.details?.decision)
228
+ this.byDecision.set(notice.details.decision, id);
229
+ }
230
+ return id;
231
+ });
232
+ this.posting.add(posting);
233
+ try {
234
+ await posting;
235
+ }
236
+ finally {
237
+ this.posting.delete(posting);
238
+ }
239
+ return true;
240
+ }
241
+ catch (e) {
242
+ this.d.log(`alerts: Notify failed (${e.message.split("\n")[0]})`);
243
+ try {
244
+ c.n.disconnect();
245
+ }
246
+ catch { }
247
+ if (this.conn === c) {
248
+ this.conn = null;
249
+ this.shown.clear();
250
+ this.byDecision.clear();
251
+ }
252
+ }
253
+ }
254
+ const ns = this.d.findNotifySend();
255
+ if (ns) {
256
+ const r = await this.d.run(ns, notifySendArgs(notice), this.d.env, 10_000);
257
+ if (r.code === 0)
258
+ return true;
259
+ this.d.log(`alerts: notify-send failed${r.out.trim() ? ` (${r.out.trim().split("\n")[0]})` : ""}`);
260
+ return false;
261
+ }
262
+ if (!this.saidNone) {
263
+ this.saidNone = true;
264
+ this.d.log("alerts: none — no desktop session bus here (SSH or headless?) and no notify-send; decisions wait on pantheon board --watch and pantheon brief");
265
+ }
266
+ return false;
267
+ }
268
+ catch (e) {
269
+ this.d.log(`alerts: could not show a notice (${e.message.split("\n")[0]})`);
270
+ return false;
271
+ }
272
+ }
273
+ forget(id) {
274
+ const s = this.shown.get(id);
275
+ this.shown.delete(id);
276
+ const dec = s?.notice.details?.decision;
277
+ if (dec && this.byDecision.get(dec) === id)
278
+ this.byDecision.delete(dec);
279
+ }
280
+ onAction(id, action, token) {
281
+ if (!this.shown.has(id) && !this.posting.size)
282
+ return this.actions;
283
+ const inflight = [...this.posting];
284
+ const step = async () => {
285
+ if (inflight.length)
286
+ await Promise.allSettled(inflight);
287
+ const s = this.shown.get(id);
288
+ if (!s)
289
+ return;
290
+ if (action === "done") {
291
+ const hud = hudActionFor(s.notice, action);
292
+ if (!hud)
293
+ return;
294
+ await this.runQueued(id, hud, s.home);
295
+ }
296
+ else if (action === "board" || action === "default") {
297
+ const display = this.d.env.DISPLAY || this.d.env.WAYLAND_DISPLAY ? {} : pickDisplay(this.d.sessionEnv());
298
+ const env = { ...this.d.env, ...display, ...(token ? { XDG_ACTIVATION_TOKEN: token, DESKTOP_STARTUP_ID: token } : {}) };
299
+ const opening = this.d.openBoard(env).catch(() => false).then(async (opened) => {
300
+ if (!opened)
301
+ await this.note("Open the board in a terminal", "Run pantheon board (or pantheon brief) to see everything that needs you.");
302
+ });
303
+ this.opening.add(opening);
304
+ void opening.finally(() => this.opening.delete(opening));
305
+ }
306
+ };
307
+ this.actions = this.actions.then(step).catch((e) => this.d.log(`alerts: a button failed (${e.message.split("\n")[0]})`));
308
+ return this.actions;
309
+ }
310
+ async runQueued(id, hud, home) {
311
+ const nonce = randomBytes(16).toString("hex");
312
+ writeHudAction(home, { ...hud, nonce });
313
+ const env = home === CONFIG_DIR ? this.d.env : { ...this.d.env, PANTHEON_HOME: home };
314
+ const [file, ...args] = this.d.cli;
315
+ const r = await this.d.run(file, [...args, "explain", "--queued", "--only-action", "--action-id", nonce], env, 60_000);
316
+ const lines = r.out.replace(/\u001b\[[0-9;]*m/g, "").trim().split("\n").filter(Boolean);
317
+ const took = lines.includes(`action ${nonce}`);
318
+ const said = lines.filter((l) => !/^action [0-9a-f]+$/.test(l)).pop() ?? "";
319
+ if (r.code === 0 && !took) {
320
+ this.d.log("alerts: Done was not sent (another click took the action first)");
321
+ await this.note("Not marked done", "Another click took that action first — open the board to check it.");
322
+ return;
323
+ }
324
+ if (r.code === 0) {
325
+ this.d.log(`alerts: Done → ${said || "sent"}`);
326
+ try {
327
+ await this.conn?.n.close(id);
328
+ }
329
+ catch { }
330
+ this.forget(id);
331
+ }
332
+ else {
333
+ this.d.log(`alerts: Done was not sent (${said || `exit ${r.code}`})`);
334
+ await this.note("Not marked done", said.replace(/^not sent:\s*/, "") || "Open the board to answer it there.");
335
+ }
336
+ }
337
+ async note(heading, body) {
338
+ const c = this.conn;
339
+ if (!c)
340
+ return;
341
+ await c.n.notify({ appName: "Pantheon", summary: noticeText(heading, LINUX_LIMITS.summary, false), body: noticeText(body, LINUX_LIMITS.line, c.caps.includes("body-markup")), urgency: 1, timeoutMs: 8_000 }).catch(() => { });
342
+ }
343
+ close() { try {
344
+ this.conn?.n.disconnect();
345
+ }
346
+ catch { } this.conn = null; this.shown.clear(); this.byDecision.clear(); }
347
+ }
348
+ export async function portalOpenUri(url, env = process.env, exists = existsSync) {
349
+ for (const path of sessionBusCandidates(env, exists)) {
350
+ let bus = null;
351
+ try {
352
+ bus = await DBus.connect(path, 3_000);
353
+ return await busPortalOpenUri(bus, url);
354
+ }
355
+ catch { }
356
+ finally {
357
+ try {
358
+ bus?.close();
359
+ }
360
+ catch { }
361
+ }
362
+ }
363
+ return false;
364
+ }
365
+ export function pickDisplay(env) {
366
+ const out = {};
367
+ for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE"])
368
+ if (env[k])
369
+ out[k] = env[k];
370
+ return out;
371
+ }
372
+ export function realSessionEnv(exec = (c, a) => execFileSync(c, a, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3_000 }), env = process.env, exists = existsSync, uid = typeof process.getuid === "function" ? process.getuid() : null) {
373
+ try {
374
+ const sd = pickDisplay(parseSystemdEnvironment(exec("systemctl", ["--user", "show-environment"])));
375
+ if (sd.DISPLAY || sd.WAYLAND_DISPLAY)
376
+ return sd;
377
+ }
378
+ catch { }
379
+ return logindDisplay(exec, env, exists, uid);
380
+ }
381
+ export function logindDisplay(exec, env, exists, uid) {
382
+ if (uid === null)
383
+ return {};
384
+ let list;
385
+ try {
386
+ list = exec("loginctl", ["list-sessions", "--no-legend"]);
387
+ }
388
+ catch {
389
+ return {};
390
+ }
391
+ for (const line of list.split("\n")) {
392
+ const [id, u] = line.trim().split(/\s+/);
393
+ if (!id || Number(u) !== uid || !/^[A-Za-z0-9_-]+$/.test(id))
394
+ continue;
395
+ let props;
396
+ try {
397
+ props = Object.fromEntries(exec("loginctl", ["show-session", id, "-p", "Type", "-p", "Remote", "-p", "Active", "-p", "Display", "-p", "State"])
398
+ .split("\n").map((l) => l.split("=", 2)).filter(([k]) => k));
399
+ }
400
+ catch {
401
+ continue;
402
+ }
403
+ if (props.Remote !== "no" || props.Active !== "yes" || (props.Type !== "x11" && props.Type !== "wayland"))
404
+ continue;
405
+ const out = { XDG_SESSION_TYPE: props.Type };
406
+ if (props.Display && /^:\d+(\.\d+)?$/.test(props.Display))
407
+ out.DISPLAY = props.Display;
408
+ if (props.Type === "wayland" && env.XDG_RUNTIME_DIR) {
409
+ for (const n of ["wayland-0", "wayland-1"])
410
+ if (exists(join(env.XDG_RUNTIME_DIR, n))) {
411
+ out.WAYLAND_DISPLAY = n;
412
+ break;
413
+ }
414
+ }
415
+ if (out.DISPLAY || out.WAYLAND_DISPLAY)
416
+ return out;
417
+ }
418
+ return {};
419
+ }
420
+ export function linuxDesktopReachable(env = process.env, sessionEnv = () => realSessionEnv(), host = () => hostEnv(env)) {
421
+ const h = host();
422
+ if (h.ephemeral)
423
+ return false;
424
+ if (h.desktop)
425
+ return true;
426
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY)
427
+ return false;
428
+ const s = sessionEnv();
429
+ return !!(s.DISPLAY || s.WAYLAND_DISPLAY);
430
+ }
431
+ export function writeHudAction(home, hud) {
432
+ const f = join(home, "bin", ".hud-action.json");
433
+ mkdirSync(dirname(f), { recursive: true });
434
+ const tmp = `${f}.${process.pid}.tmp`;
435
+ writeFileSync(tmp, JSON.stringify(hud), { mode: 0o600 });
436
+ renameSync(tmp, f);
437
+ return f;
438
+ }
439
+ function openBoardWith(cli, env) {
440
+ return new Promise((done) => {
441
+ const [file, ...args] = cli;
442
+ let settled = false;
443
+ const settle = (v) => { if (!settled) {
444
+ settled = true;
445
+ done(v);
446
+ } };
447
+ let child;
448
+ try {
449
+ child = spawn(file, [...args, "board", "--open"], { env, stdio: "ignore", detached: true });
450
+ }
451
+ catch {
452
+ settle(false);
453
+ return;
454
+ }
455
+ child.on("error", () => settle(false));
456
+ child.on("exit", (code) => settle(code === 0));
457
+ setTimeout(() => settle(true), 30_000).unref();
458
+ child.unref();
459
+ });
460
+ }
461
+ export async function probeLinuxAlerts(deps = {}) {
462
+ const env = deps.env ?? process.env;
463
+ const candidates = sessionBusCandidates(env, deps.exists ?? existsSync);
464
+ const open = deps.open ?? realOpen;
465
+ const errors = [];
466
+ for (const path of candidates) {
467
+ let n = null;
468
+ try {
469
+ n = await open(path);
470
+ const caps = await n.capabilities();
471
+ const info = await n.serverInfo().catch(() => null);
472
+ return caps.includes("actions")
473
+ ? { mode: "dbus-actions", line: `D-Bus notifications with actions (${serverLabel(info)})` }
474
+ : { mode: "dbus", line: `D-Bus notifications, no buttons — ${serverLabel(info)} does not offer actions; alerts still arrive, answer them on the board` };
475
+ }
476
+ catch (e) {
477
+ errors.push(e.message.split("\n")[0]);
478
+ }
479
+ finally {
480
+ try {
481
+ n?.disconnect();
482
+ }
483
+ catch { }
484
+ }
485
+ }
486
+ const ns = (deps.findNotifySend ?? (() => which("notify-send")))();
487
+ if (ns) {
488
+ return { mode: "notify-send", line: candidates.length
489
+ ? `notify-send, no buttons — the session bus did not answer Pantheon directly (${errors[0] ?? "no reply"})`
490
+ : "notify-send, no buttons — no session bus address in this environment, so alerts go through notify-send" };
491
+ }
492
+ return { mode: "none", line: candidates.length
493
+ ? `none — no notification server answered on the session bus (${errors[0] ?? "no reply"}); install one (dunst, mako) or use pantheon board --watch`
494
+ : "none — no desktop session bus (SSH or headless): use pantheon board --watch" };
495
+ }
496
+ let shared = null;
497
+ export function showLinuxNotice(notice, home) {
498
+ shared ??= new LinuxAlerts();
499
+ return shared.show(notice, home);
500
+ }
501
+ //# sourceMappingURL=notice-linux.js.map