@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.
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { CONFIG_PATH } from "./config.js";
4
4
  import { trustWrite } from "./home-guard.js";
5
5
  import { parseJsonText } from "./text-file.js";
6
+ import { compareVersions, isVersion } from "./installer.js";
6
7
  export const WAKE_NO_MS = 30 * 24 * 60 * 60 * 1_000;
7
8
  export const wakeKey = (team, member) => `${team}|${member}`;
8
9
  export function mayAskWake(p, teams, member, now = Date.now()) {
@@ -311,6 +312,8 @@ export function needsSelfUpgrade(localPkg, relayPkg, lastAttempt, now = Date.now
311
312
  return lastAttempt === null || now - lastAttempt >= UPGRADE_RETRY_MS;
312
313
  }
313
314
  export function isNewer(a, b) {
315
+ if (isVersion(a) && isVersion(b))
316
+ return compareVersions(a, b) > 0;
314
317
  const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
315
318
  const pb = b.split(".").map((x) => parseInt(x, 10) || 0);
316
319
  for (let i = 0; i < 3; i++) {
package/dist/worker.js CHANGED
@@ -61,7 +61,7 @@ function configuredSshCommand(cwd) {
61
61
  return hit;
62
62
  let set = false;
63
63
  try {
64
- set = execFileSync("git", safeGitArgs(cwd, ["-C", cwd, "config", "--get", "core.sshCommand"]), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim().length > 0;
64
+ set = execFileSync("git", safeGitArgs(cwd, ["-C", cwd, "config", "--get", "core.sshCommand"]), { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim().length > 0;
65
65
  }
66
66
  catch { }
67
67
  sshCommandSeen.set(cwd, set);
@@ -78,7 +78,7 @@ export function agentBaseEnv(base, proxy) {
78
78
  const GIT_TIMEOUT_MS = 60_000;
79
79
  export const WORKTREE_ADD_TIMEOUT_MS = 15 * 60_000;
80
80
  export const FETCH_TIMEOUT_MS = 90_000;
81
- const git = (cwd, ...args) => execFileSync("git", safeGitArgs(cwd, args), { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL" }).trim();
81
+ const git = (cwd, ...args) => execFileSync("git", safeGitArgs(cwd, args), { cwd, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL" }).trim();
82
82
  const gitVersions = new Map();
83
83
  export function gitVersion() {
84
84
  const key = process.env.PATH ?? "";
@@ -86,7 +86,7 @@ export function gitVersion() {
86
86
  return gitVersions.get(key);
87
87
  let v = null;
88
88
  try {
89
- const m = /(\d+)\.(\d+)/.exec(execFileSync("git", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }));
89
+ const m = /(\d+)\.(\d+)/.exec(execFileSync("git", ["--version"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }));
90
90
  if (m)
91
91
  v = [Number(m[1]), Number(m[2])];
92
92
  }
@@ -101,7 +101,7 @@ export function fetchOrigin(project, timeoutMs = FETCH_TIMEOUT_MS) {
101
101
  return new Promise((res) => {
102
102
  let child;
103
103
  try {
104
- child = procSpawn("git", safeGitArgs(project, ["fetch", "--quiet", "origin"]), { cwd: project, env: gitEnv(process.env, proxyEnvFor(), project), stdio: ["ignore", "ignore", "pipe"] });
104
+ child = procSpawn("git", safeGitArgs(project, ["fetch", "--quiet", "origin"]), { cwd: project, env: gitEnv(process.env, proxyEnvFor(), project), windowsHide: true, stdio: ["ignore", "ignore", "pipe"] });
105
105
  }
106
106
  catch (e) {
107
107
  res({ ok: false, why: e.message });
@@ -117,7 +117,7 @@ export function fetchOrigin(project, timeoutMs = FETCH_TIMEOUT_MS) {
117
117
  }
118
118
  function fetchOriginSync(project) {
119
119
  try {
120
- execFileSync("git", safeGitArgs(project, ["fetch", "--quiet", "origin"]), { cwd: project, stdio: ["ignore", "ignore", "pipe"], env: gitEnv(process.env, proxyEnvFor(), project), timeout: FETCH_TIMEOUT_MS, killSignal: "SIGKILL" });
120
+ execFileSync("git", safeGitArgs(project, ["fetch", "--quiet", "origin"]), { cwd: project, windowsHide: true, stdio: ["ignore", "ignore", "pipe"], env: gitEnv(process.env, proxyEnvFor(), project), timeout: FETCH_TIMEOUT_MS, killSignal: "SIGKILL" });
121
121
  return { ok: true };
122
122
  }
123
123
  catch (e) {
@@ -140,7 +140,7 @@ const isRepo = (cwd) => {
140
140
  export const worktreeLabel = (wt) => `${basename(dirname(wt))}/${basename(wt)}`;
141
141
  const LINK_SCAN_BUDGET = 1_000_000;
142
142
  const GIT_SCAN_MAXBUFFER = 1 << 28;
143
- const gitScan = (cwd, args) => execFileSync("git", safeGitArgs(cwd, args), { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: GIT_SCAN_MAXBUFFER });
143
+ const gitScan = (cwd, args) => execFileSync("git", safeGitArgs(cwd, args), { cwd, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: GIT_SCAN_MAXBUFFER });
144
144
  const ENV_DIRS = new Set([".venv", "venv", "env", ".tox", "__pypackages__", ".direnv"]);
145
145
  const AGENT_FILE = /^(CLAUDE.*\.md|AGENTS?.*\.md|GEMINI\.md|CONVENTIONS\.md|\.mcp\.json|\.cursorrules|\.windsurfrules|\.clinerules)$/i;
146
146
  const AGENT_DIRS = new Set([".claude", ".cursor", ".github", ".kiro", ".codex", ".vscode", ".agents", ".gemini", ".junie", ".amazonq", ".continue", ".clinerules", ".windsurf"]);
@@ -430,7 +430,7 @@ export function prepareWorktree(project, scratch, goal, log, branch = `pantheon/
430
430
  if ("done" in p)
431
431
  return p.done;
432
432
  try {
433
- execFileSync("git", safeGitArgs(project, p.add), { cwd: project, stdio: ["ignore", "ignore", "pipe"], env: gitCallEnv(p.add, gitEnv(process.env, proxyEnvFor(), project)), timeout: opts.addTimeoutMs ?? WORKTREE_ADD_TIMEOUT_MS, killSignal: "SIGKILL" });
433
+ execFileSync("git", safeGitArgs(project, p.add), { cwd: project, windowsHide: true, stdio: ["ignore", "ignore", "pipe"], env: gitCallEnv(p.add, gitEnv(process.env, proxyEnvFor(), project)), timeout: opts.addTimeoutMs ?? WORKTREE_ADD_TIMEOUT_MS, killSignal: "SIGKILL" });
434
434
  }
435
435
  catch (e) {
436
436
  const err = e;
@@ -451,7 +451,7 @@ export async function prepareWorktreeAsync(project, scratch, goal, log, branch =
451
451
  const why = await new Promise((res) => {
452
452
  let child;
453
453
  try {
454
- child = procSpawn("git", safeGitArgs(project, p.add), { cwd: project, env: gitCallEnv(p.add, gitEnv(process.env, proxyEnvFor(), project)), stdio: ["ignore", "ignore", "pipe"] });
454
+ child = procSpawn("git", safeGitArgs(project, p.add), { cwd: project, env: gitCallEnv(p.add, gitEnv(process.env, proxyEnvFor(), project)), windowsHide: true, stdio: ["ignore", "ignore", "pipe"] });
455
455
  }
456
456
  catch (e) {
457
457
  res(e.message);
@@ -589,7 +589,7 @@ export function gitOut(cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
589
589
  return new Promise((res) => {
590
590
  let child;
591
591
  try {
592
- child = procSpawn("git", argv, { cwd, env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), stdio: ["ignore", "pipe", "ignore"] });
592
+ child = procSpawn("git", argv, { cwd, env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
593
593
  }
594
594
  catch {
595
595
  res(null);
@@ -680,7 +680,7 @@ export function massDeleteGuardEnv(runHome, worktree, allow, env = process.env)
680
680
  }
681
681
  let orig = "";
682
682
  try {
683
- const out = execFileSync("git", repoHooksArgs(worktree), { cwd: worktree, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(["rev-parse"], gitEnv(process.env, proxyEnvFor(), worktree)), timeout: GIT_TIMEOUT_MS }).trim();
683
+ const out = execFileSync("git", repoHooksArgs(worktree), { cwd: worktree, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(["rev-parse"], gitEnv(process.env, proxyEnvFor(), worktree)), timeout: GIT_TIMEOUT_MS }).trim();
684
684
  orig = resolve(worktree, out);
685
685
  }
686
686
  catch (e) {
@@ -2690,7 +2690,7 @@ export class Worker {
2690
2690
  detail = await new Promise((res) => {
2691
2691
  let child;
2692
2692
  try {
2693
- child = procSpawn(sp.command, sp.args, { cwd: worktree, stdio: ["ignore", "pipe", "pipe"], env, ...sp.options, detached: process.platform !== "win32" });
2693
+ child = procSpawn(sp.command, sp.args, { cwd: worktree, stdio: ["ignore", "pipe", "pipe"], env, ...sp.options, windowsHide: true, detached: process.platform !== "win32" });
2694
2694
  }
2695
2695
  catch (e) {
2696
2696
  res(`could not start "${command}": ${e.message}`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "format": 1,
3
- "built_at": "2026-10-03T18:03:55.381Z",
3
+ "built_at": "2026-10-05T04:55:37.913Z",
4
4
  "swiftc": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)",
5
5
  "helpers": {
6
6
  "pantheon-tray": {
@@ -17,7 +17,7 @@
17
17
  "pantheon-hud": {
18
18
  "source": "main.swift",
19
19
  "source_sha256": "bcf55a19b658d1f09a9d117c76030393ab08a2569062dd7413f884ca05f3a0eb",
20
- "sha256": "191f3f18e8212e11ad4438bdbef87a5cc533f5ae2fae208801db01c9a265193b",
20
+ "sha256": "0414b397a533940e3a5c324add6250e1ed71919e67e462d11dcb2a1d0c2f9b41",
21
21
  "archs": [
22
22
  "arm64",
23
23
  "x86_64"
Binary file
@@ -0,0 +1,170 @@
1
+ // pantheon-host.exe: starts Pantheon's background host on Windows with no
2
+ // window, and keeps it running.
3
+ //
4
+ // Task Scheduler starts this at logon (the "Pantheon-<your SID>" task, current
5
+ // user, interactive, no elevation). It is a GUI-subsystem program
6
+ // (/target:winexe), so Windows never gives it a console: nothing flashes on
7
+ // screen at logon, as powershell.exe -WindowStyle Hidden or node.exe started
8
+ // directly would.
9
+ //
10
+ // It does one thing: start the host with CreateNoWindow and wait for it. The
11
+ // host is `node.exe [flags] cli.js host`, the supervisor that runs the worker,
12
+ // the notifier and the tray (src/win-host.ts). Exit 0 means it was asked to
13
+ // stop, and this exits too. Exit 75 means "start me again now" (it saw a new
14
+ // Pantheon version on disk). Anything else is a crash: started again after
15
+ // 2 s, doubling to 60 s, back to 2 s after ten healthy minutes.
16
+ //
17
+ // WHAT it starts is read again before every start, from the launch file
18
+ // (--launch, one argument per line, written by `pantheon service install` and
19
+ // by every upgrade), so a host that exits 75 comes back on whatever Node and
20
+ // CLI the new version named, never on the argv this shim was registered with.
21
+ // The argv after "--" is the fallback when that file is missing or names a
22
+ // program that is not there. When neither program exists (an installer
23
+ // swapping the runtime folder, a Node upgrade in progress) it waits and looks
24
+ // again, with the same backoff; only after --missing-grace seconds (default
25
+ // ten minutes) of nothing to start does it conclude Pantheon was removed, and
26
+ // exit 3.
27
+ //
28
+ // Each start of this shim is one session (PANTHEON_HOST_SESSION, a new GUID):
29
+ // the host uses it to tell a restart (same session) from a new logon.
30
+ //
31
+ // Compiled on the person's machine by the .NET Framework's own csc.exe (part of
32
+ // every Windows 10 and 11), from this file as shipped in the npm package.
33
+ //
34
+ // pantheon-host.exe [--env NAME=VALUE]... [--launch FILE] [--missing-grace SECONDS] -- <program> [args...]
35
+ using System;
36
+ using System.Collections.Generic;
37
+ using System.Diagnostics;
38
+ using System.IO;
39
+ using System.Text;
40
+ using System.Threading;
41
+
42
+ [assembly: System.Reflection.AssemblyTitle("Pantheon background host")]
43
+ [assembly: System.Reflection.AssemblyDescription("Keeps Pantheon's worker, alerts and tray running while you are logged on")]
44
+ [assembly: System.Reflection.AssemblyProduct("Pantheon")]
45
+ [assembly: System.Reflection.AssemblyVersion("1.1.0.0")]
46
+
47
+ static class PantheonHost
48
+ {
49
+ const int RestartNow = 75;
50
+ const int BaseDelayMs = 2000;
51
+ const int MaxDelayMs = 60000;
52
+
53
+ static int Main(string[] args)
54
+ {
55
+ var env = new List<KeyValuePair<string, string>>();
56
+ string launchFile = null;
57
+ int graceSeconds = 600;
58
+ int i = 0;
59
+ for (; i < args.Length; i++)
60
+ {
61
+ if (args[i] == "--") { i++; break; }
62
+ if (args[i] == "--env" && i + 1 < args.Length)
63
+ {
64
+ string kv = args[++i];
65
+ int eq = kv.IndexOf('=');
66
+ if (eq > 0) env.Add(new KeyValuePair<string, string>(kv.Substring(0, eq), kv.Substring(eq + 1)));
67
+ continue;
68
+ }
69
+ if (args[i] == "--launch" && i + 1 < args.Length) { launchFile = args[++i]; continue; }
70
+ if (args[i] == "--missing-grace" && i + 1 < args.Length)
71
+ {
72
+ if (!int.TryParse(args[++i], out graceSeconds) || graceSeconds < 0) return 2;
73
+ continue;
74
+ }
75
+ return 2;
76
+ }
77
+ if (i >= args.Length) return 2;
78
+ var baked = new List<string>();
79
+ for (int j = i; j < args.Length; j++) baked.Add(args[j]);
80
+
81
+ string session = Guid.NewGuid().ToString("N");
82
+ int delay = BaseDelayMs;
83
+ DateTime missingSince = DateTime.MinValue;
84
+ while (true)
85
+ {
86
+ List<string> argv = ReadLaunch(launchFile);
87
+ if (argv == null || !File.Exists(argv[0])) argv = baked;
88
+ if (!File.Exists(argv[0]))
89
+ {
90
+ // Nothing to start right now: wait for it to come back, and give up
91
+ // only once it has been gone longer than any install or upgrade takes.
92
+ if (missingSince == DateTime.MinValue) missingSince = DateTime.UtcNow;
93
+ if ((DateTime.UtcNow - missingSince).TotalSeconds >= graceSeconds) return 3;
94
+ Thread.Sleep(delay);
95
+ delay = Math.Min(delay * 2, MaxDelayMs);
96
+ continue;
97
+ }
98
+ missingSince = DateTime.MinValue;
99
+ DateTime started = DateTime.UtcNow;
100
+ int code;
101
+ try
102
+ {
103
+ var rest = new StringBuilder();
104
+ for (int j = 1; j < argv.Count; j++)
105
+ {
106
+ if (rest.Length > 0) rest.Append(' ');
107
+ rest.Append(Quote(argv[j]));
108
+ }
109
+ var psi = new ProcessStartInfo(argv[0], rest.ToString());
110
+ psi.UseShellExecute = false;
111
+ psi.CreateNoWindow = true;
112
+ psi.WindowStyle = ProcessWindowStyle.Hidden;
113
+ psi.WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
114
+ foreach (var kv in env) psi.EnvironmentVariables[kv.Key] = kv.Value;
115
+ psi.EnvironmentVariables["PANTHEON_HOST_SESSION"] = session;
116
+ using (Process p = Process.Start(psi))
117
+ {
118
+ p.WaitForExit();
119
+ code = p.ExitCode;
120
+ }
121
+ }
122
+ catch (Exception)
123
+ {
124
+ code = 1;
125
+ }
126
+ if (code == 0) return 0;
127
+ if (code == RestartNow) { delay = BaseDelayMs; continue; }
128
+ if ((DateTime.UtcNow - started).TotalMinutes >= 10) delay = BaseDelayMs;
129
+ Thread.Sleep(delay);
130
+ delay = Math.Min(delay * 2, MaxDelayMs);
131
+ }
132
+ }
133
+
134
+ // The launch file: one argument per line (UTF-8), the program first. null
135
+ // when there is none, or it cannot be read, or it names nothing.
136
+ static List<string> ReadLaunch(string file)
137
+ {
138
+ if (file == null) return null;
139
+ try
140
+ {
141
+ if (!File.Exists(file)) return null;
142
+ var argv = new List<string>();
143
+ foreach (string line in File.ReadAllLines(file, Encoding.UTF8))
144
+ {
145
+ string a = line.TrimEnd('\r');
146
+ if (a.Length > 0) argv.Add(a);
147
+ }
148
+ return argv.Count > 0 ? argv : null;
149
+ }
150
+ catch (Exception) { return null; }
151
+ }
152
+
153
+ // One argument as CommandLineToArgvW reads it back (the MSVC rules node.exe uses).
154
+ static string Quote(string a)
155
+ {
156
+ if (a.Length > 0 && a.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) return a;
157
+ var sb = new StringBuilder("\"");
158
+ int backslashes = 0;
159
+ foreach (char c in a)
160
+ {
161
+ if (c == '\\') { backslashes++; continue; }
162
+ if (c == '"') { sb.Append('\\', backslashes * 2 + 1); sb.Append('"'); }
163
+ else { sb.Append('\\', backslashes); sb.Append(c); }
164
+ backslashes = 0;
165
+ }
166
+ sb.Append('\\', backslashes * 2);
167
+ sb.Append('"');
168
+ return sb.ToString();
169
+ }
170
+ }
@@ -0,0 +1,127 @@
1
+ <#
2
+ Pantheon's desktop alerts on Windows: a toast, or a tray balloon where toasts
3
+ cannot be shown. One fixed file, the same bytes for every alert (src/notice-windows.ts).
4
+
5
+ Two ways it runs:
6
+
7
+ 1. Loaded by the tray (helper/windows/pantheon-tray.ps1), which defines the
8
+ functions below and shows every alert from its own, long-running process.
9
+ The notifier drops each alert as a file in <home>\host\notices; the tray
10
+ picks it up, shows it, and deletes it. No process is started per alert.
11
+
12
+ 2. When no tray is running (quit, or not started yet), the notifier starts
13
+ Windows PowerShell for one alert:
14
+ powershell -NoProfile -NonInteractive -Command
15
+ "if (<not FullLanguage>) { exit 64 }; & ([scriptblock]::Create([IO.File]::ReadAllText('<this file>'))) -Mode toast"
16
+ hidden by the way it is started (CREATE_NO_WINDOW), never -WindowStyle
17
+ Hidden or an encoded command. The alert travels in environment variables
18
+ (base64), never on the command line:
19
+ PANTHEON_TOAST_XML the toast (UTF-8)
20
+ PANTHEON_TOAST_TAG a tag that lets a reminder replace its alert
21
+ PANTHEON_TOAST_AUMIDS the app ids to try, ";"-separated
22
+ PANTHEON_BALLOON_TITLE / _TEXT / _ICON for -Mode balloon
23
+ -Mode toast exits 0 shown, 3 no toast API here, 4 toasts off or refused
24
+ for every app id. -Mode balloon shows a balloon for twenty seconds and
25
+ says in <home>\logs\alerts.log whether it could.
26
+
27
+ Like the tray it is run as a script block, so it never relies on $PSScriptRoot.
28
+ This file is kept ASCII (Windows PowerShell reads a .ps1 without a BOM in the
29
+ system code page).
30
+ #>
31
+ param([string]$Mode = '')
32
+
33
+ # One toast: the XML, tried under each app id in turn. Returns @{ code; said }.
34
+ function global:Show-PantheonToast([string]$Xml, [string]$Tag, [string[]]$Aumids) {
35
+ $said = New-Object System.Collections.Generic.List[string]
36
+ try {
37
+ [void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
38
+ [void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
39
+ $doc = [Windows.Data.Xml.Dom.XmlDocument]::new()
40
+ $doc.LoadXml($Xml)
41
+ } catch {
42
+ $said.Add('no-toast-api ' + $_.Exception.Message)
43
+ return @{ code = 3; said = $said }
44
+ }
45
+ $ids = @($Aumids | Where-Object { $_ })
46
+ for ($i = 0; $i -lt $ids.Count; $i++) {
47
+ $id = $ids[$i]
48
+ $last = ($i -eq $ids.Count - 1)
49
+ try {
50
+ $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($id)
51
+ if ([string]$notifier.Setting -ne 'Enabled') { $said.Add('off ' + $id + ' ' + $notifier.Setting); continue }
52
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($doc)
53
+ if ($Tag) { $toast.Tag = $Tag; $toast.Group = 'pantheon' }
54
+ $notifier.Show($toast)
55
+ # Pantheon's own app id is used only when Windows then has the toast in
56
+ # its history for that id: a build that ignores the registration falls
57
+ # through to the next id instead of swallowing the alert.
58
+ if (-not $last -and $Tag) {
59
+ Start-Sleep -Milliseconds 400
60
+ $seen = $false
61
+ foreach ($h in [Windows.UI.Notifications.ToastNotificationManager]::History.GetHistory($id)) { if ($h.Tag -eq $Tag) { $seen = $true } }
62
+ if (-not $seen) {
63
+ try { [Windows.UI.Notifications.ToastNotificationManager]::History.Remove($Tag, 'pantheon', $id) } catch { }
64
+ $said.Add('unseen ' + $id)
65
+ continue
66
+ }
67
+ }
68
+ $said.Add('shown ' + $id)
69
+ return @{ code = 0; said = $said }
70
+ } catch { $said.Add('failed ' + $id + ' ' + $_.Exception.Message) }
71
+ }
72
+ return @{ code = 4; said = $said }
73
+ }
74
+
75
+ function global:ConvertFrom-PantheonB64([string]$s) {
76
+ if (-not $s) { return '' }
77
+ return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s))
78
+ }
79
+
80
+ function global:Write-PantheonAlertLog([string]$line) {
81
+ try {
82
+ $h = [string]$env:PANTHEON_HOME
83
+ if (-not $h) { $h = Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pantheon' }
84
+ $dir = Join-Path $h 'logs'
85
+ if (-not (Test-Path -LiteralPath $dir)) { [void](New-Item -ItemType Directory -Path $dir -Force) }
86
+ Add-Content -LiteralPath (Join-Path $dir 'alerts.log') -Value ((Get-Date).ToString('o') + ' ' + $line) -Encoding UTF8
87
+ } catch { }
88
+ }
89
+
90
+ if ($Mode -eq 'toast') {
91
+ $ErrorActionPreference = 'Stop'
92
+ $xml = ''
93
+ try { $xml = ConvertFrom-PantheonB64 ([string]$env:PANTHEON_TOAST_XML) } catch { [Console]::Out.WriteLine('bad-input ' + $_.Exception.Message); exit 3 }
94
+ $r = Show-PantheonToast $xml ([string]$env:PANTHEON_TOAST_TAG) (([string]$env:PANTHEON_TOAST_AUMIDS) -split ';')
95
+ foreach ($l in $r.said) { [Console]::Out.WriteLine($l) }
96
+ exit $r.code
97
+ }
98
+
99
+ if ($Mode -eq 'balloon') {
100
+ # A balloon of its own, for twenty seconds; a click opens the board through
101
+ # the same pantheon: link. What happened is logged here, where it is known.
102
+ try {
103
+ Add-Type -AssemblyName System.Windows.Forms
104
+ Add-Type -AssemblyName System.Drawing
105
+ $title = ConvertFrom-PantheonB64 ([string]$env:PANTHEON_BALLOON_TITLE)
106
+ $text = ConvertFrom-PantheonB64 ([string]$env:PANTHEON_BALLOON_TEXT)
107
+ $tray = New-Object System.Windows.Forms.NotifyIcon
108
+ $ico = [string]$env:PANTHEON_BALLOON_ICON
109
+ if ($ico -and (Test-Path -LiteralPath $ico)) { $tray.Icon = New-Object System.Drawing.Icon -ArgumentList $ico } else { $tray.Icon = [System.Drawing.SystemIcons]::Information }
110
+ $tray.Text = 'Pantheon'
111
+ $global:PantheonBalloonClicked = $false
112
+ $tray.add_BalloonTipClicked({ $global:PantheonBalloonClicked = $true })
113
+ $tray.add_Click({ $global:PantheonBalloonClicked = $true })
114
+ $tray.Visible = $true
115
+ $tray.ShowBalloonTip(15000, $title, $text, [System.Windows.Forms.ToolTipIcon]::Info)
116
+ Write-PantheonAlertLog 'balloon: shown'
117
+ $until = (Get-Date).AddSeconds(20)
118
+ while ((Get-Date) -lt $until -and -not $global:PantheonBalloonClicked) { [System.Windows.Forms.Application]::DoEvents(); Start-Sleep -Milliseconds 100 }
119
+ if ($global:PantheonBalloonClicked) { try { Start-Process 'pantheon://board' } catch { } }
120
+ $tray.Visible = $false
121
+ $tray.Dispose()
122
+ exit 0
123
+ } catch {
124
+ Write-PantheonAlertLog ('balloon: not shown (' + $_.Exception.Message + ')')
125
+ exit 5
126
+ }
127
+ }