@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.
@@ -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