@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,335 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join, win32 } from "node:path";
5
+ export const URI_SCHEME = "pantheon";
6
+ export const URI_HOSTS = ["board", "done", "explain", "details"];
7
+ const FORMS = {
8
+ board: { required: [], optional: [] },
9
+ done: { required: ["decision", "k"], optional: ["act", "space"] },
10
+ explain: { required: ["decision", "k"], optional: ["act", "space"] },
11
+ details: { required: ["act", "k"], optional: ["decision", "space"] },
12
+ };
13
+ export const URI_VALUE = {
14
+ decision: /^o_[a-z0-9]{6,48}$/,
15
+ act: /^a_[A-Za-z0-9]{6,48}$/,
16
+ space: /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/,
17
+ k: /^[0-9a-f]{32}$/,
18
+ };
19
+ export const MAX_URI = 400;
20
+ function canonical(host, params) {
21
+ return `${host}?${Object.keys(params).filter((k) => k !== "k").sort().map((k) => `${k}=${params[k]}`).join("&")}`;
22
+ }
23
+ export function uriCode(key, host, params) {
24
+ return createHmac("sha256", key).update(canonical(host, params)).digest("hex").slice(0, 32);
25
+ }
26
+ export function buildUri(host, params, key) {
27
+ const form = FORMS[host];
28
+ const kept = {};
29
+ for (const name of [...form.required, ...form.optional]) {
30
+ if (name === "k")
31
+ continue;
32
+ const v = params[name];
33
+ if (v === undefined || v === "") {
34
+ if (form.required.includes(name))
35
+ return null;
36
+ continue;
37
+ }
38
+ if (!URI_VALUE[name].test(v)) {
39
+ if (form.required.includes(name))
40
+ return null;
41
+ continue;
42
+ }
43
+ kept[name] = v;
44
+ }
45
+ if (form.required.includes("k")) {
46
+ if (!key)
47
+ return null;
48
+ kept.k = uriCode(key, host, kept);
49
+ }
50
+ const q = Object.entries(kept).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
51
+ return `${URI_SCHEME}://${host}${q ? `?${q}` : ""}`;
52
+ }
53
+ export function parseUri(raw) {
54
+ if (typeof raw !== "string" || !raw)
55
+ return { ok: false, why: "no link given" };
56
+ if (raw.length > MAX_URI)
57
+ return { ok: false, why: `the link is ${raw.length} characters long; a Pantheon link is under ${MAX_URI}` };
58
+ if (/^pantheons?:\/\/[^/?#\s]+\/[^/?#\s]+#\S+$/i.test(raw))
59
+ return { ok: false, why: "this looks like a team invite, not an alert's link — to join, copy the link and run in a terminal: pantheon join \"<the link>\"" };
60
+ if (!/^[A-Za-z0-9:/?&=%._~-]+$/.test(raw))
61
+ return { ok: false, why: "the link holds characters a Pantheon link never has" };
62
+ const m = /^pantheon:\/\/([A-Za-z]+)\/?(?:\?(.*))?$/i.exec(raw);
63
+ if (!m)
64
+ return { ok: false, why: "not a Pantheon alert link (expected pantheon://board, done, explain or details)" };
65
+ const host = m[1].toLowerCase();
66
+ if (!URI_HOSTS.includes(host))
67
+ return { ok: false, why: `"${m[1]}" is not something a Pantheon link can do (board, done, explain or details)` };
68
+ const form = FORMS[host];
69
+ const params = {};
70
+ const query = m[2];
71
+ if (query !== undefined && query !== "") {
72
+ for (const part of query.split("&")) {
73
+ const eq = part.indexOf("=");
74
+ if (eq <= 0)
75
+ return { ok: false, why: "a parameter in the link has no value" };
76
+ const name = part.slice(0, eq);
77
+ if (!/^[a-z]{1,16}$/.test(name))
78
+ return { ok: false, why: "the link has a parameter Pantheon never writes" };
79
+ if (![...form.required, ...form.optional].includes(name))
80
+ return { ok: false, why: `"${name}" is not a parameter of pantheon://${host}` };
81
+ if (Object.prototype.hasOwnProperty.call(params, name))
82
+ return { ok: false, why: `"${name}" appears twice in the link` };
83
+ let value;
84
+ try {
85
+ value = decodeURIComponent(part.slice(eq + 1));
86
+ }
87
+ catch {
88
+ return { ok: false, why: `"${name}" is not encoded correctly` };
89
+ }
90
+ if (!URI_VALUE[name].test(value))
91
+ return { ok: false, why: `"${name}" is not in the form Pantheon writes it` };
92
+ params[name] = value;
93
+ }
94
+ }
95
+ else if (query === "")
96
+ return { ok: false, why: "the link ends in an empty query" };
97
+ for (const r of form.required)
98
+ if (!(r in params))
99
+ return { ok: false, why: `pantheon://${host} needs "${r}"` };
100
+ return { ok: true, host, params };
101
+ }
102
+ export function verifyUri(p, key) {
103
+ if (!FORMS[p.host].required.includes("k"))
104
+ return true;
105
+ if (!key || !p.params.k)
106
+ return false;
107
+ const want = Buffer.from(uriCode(key, p.host, p.params), "utf8");
108
+ const got = Buffer.from(p.params.k, "utf8");
109
+ return want.length === got.length && timingSafeEqual(want, got);
110
+ }
111
+ export function hudActionFor(p) {
112
+ const { decision, act, space } = p.params;
113
+ const extra = { ...(space ? { space } : {}) };
114
+ if (p.host === "done")
115
+ return { action: "done", decision: decision, ...(act ? { act } : {}), ...extra };
116
+ if (p.host === "explain")
117
+ return { action: "open", decision: decision, ...(act ? { act } : {}), ...extra };
118
+ if (p.host === "details")
119
+ return { action: "details", act: act, ...(decision ? { decision } : {}), ...extra };
120
+ return null;
121
+ }
122
+ export const uriKeyPath = (home) => join(home, "uri-key");
123
+ export function uriKey(home, create = true) {
124
+ const f = uriKeyPath(home);
125
+ const read = () => { try {
126
+ const k = readFileSync(f, "utf8").trim();
127
+ return /^[0-9a-f]{64}$/.test(k) ? k : null;
128
+ }
129
+ catch {
130
+ return null;
131
+ } };
132
+ const have = read();
133
+ if (have || !create)
134
+ return have;
135
+ try {
136
+ mkdirSync(home, { recursive: true });
137
+ writeFileSync(f, randomBytes(32).toString("hex"), { mode: 0o600, flag: "wx" });
138
+ }
139
+ catch {
140
+ if (!read()) {
141
+ try {
142
+ const tmp = `${f}.${randomBytes(6).toString("hex")}.tmp`;
143
+ writeFileSync(tmp, randomBytes(32).toString("hex"), { mode: 0o600, flag: "wx" });
144
+ renameSync(tmp, f);
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ }
151
+ return read();
152
+ }
153
+ export const hudActionPath = (home) => join(home, "bin", ".hud-action.json");
154
+ export function writeHudAction(home, action) {
155
+ const f = hudActionPath(home);
156
+ mkdirSync(join(home, "bin"), { recursive: true });
157
+ const tmp = `${f}.${randomBytes(8).toString("hex")}.tmp`;
158
+ try {
159
+ writeFileSync(tmp, JSON.stringify(action), { mode: 0o600, flag: "wx" });
160
+ renameSync(tmp, f);
161
+ }
162
+ catch (e) {
163
+ try {
164
+ rmSync(tmp, { force: true });
165
+ }
166
+ catch { }
167
+ throw e;
168
+ }
169
+ return f;
170
+ }
171
+ export function logUri(home, line, now = new Date()) {
172
+ try {
173
+ mkdirSync(join(home, "logs"), { recursive: true });
174
+ appendFileSync(join(home, "logs", "uri.log"), `${now.toISOString()} ${line}\n`, { mode: 0o600 });
175
+ }
176
+ catch { }
177
+ }
178
+ export function describeUri(p) {
179
+ const shown = Object.entries(p.params).filter(([k]) => k !== "k").map(([k, v]) => `${k}=${v}`).join(" ");
180
+ return `${p.host}${shown ? ` ${shown}` : ""}`;
181
+ }
182
+ export async function handleUri(args, d) {
183
+ const now = d.now ?? (() => new Date());
184
+ const refuse = async (why, logged) => {
185
+ d.print(`Pantheon did not act on this link: ${why}`);
186
+ logUri(d.home, `refused ${logged}: ${why}`, now());
187
+ await d.pause();
188
+ return 2;
189
+ };
190
+ if (args.length !== 1)
191
+ return refuse("it expects exactly one link", `(${args.length} arguments)`);
192
+ if (d.wokenRun)
193
+ return refuse("a woken run cannot act for the person — a person has to click it", "(inside a woken run)");
194
+ const p = parseUri(args[0]);
195
+ if (!p.ok)
196
+ return refuse(p.why, "(a link that is not one of Pantheon's)");
197
+ if (!verifyUri(p, uriKey(d.home, false))) {
198
+ return refuse("this link was not made by Pantheon on this machine (it acts only on links from its own alerts)", describeUri(p));
199
+ }
200
+ if (p.host === "board") {
201
+ if (d.openBoard) {
202
+ let ok = false;
203
+ try {
204
+ ok = await d.openBoard();
205
+ }
206
+ catch {
207
+ ok = false;
208
+ }
209
+ if (ok) {
210
+ logUri(d.home, "board: opened", now());
211
+ return 0;
212
+ }
213
+ logUri(d.home, "board: could not start; showing the brief instead", now());
214
+ }
215
+ else
216
+ logUri(d.home, "board: this build has none; showing the brief instead", now());
217
+ const code = await d.runBrief();
218
+ await d.pause();
219
+ return code;
220
+ }
221
+ const action = { ...hudActionFor(p), nonce: randomBytes(16).toString("hex") };
222
+ try {
223
+ writeHudAction(d.home, action);
224
+ }
225
+ catch (e) {
226
+ return refuse(`could not queue the action (${e.message.split("\n")[0]})`, describeUri(p));
227
+ }
228
+ logUri(d.home, `${describeUri(p)}: queued ${action.action}`, now());
229
+ const code = await d.runQueued(action.nonce);
230
+ logUri(d.home, `${describeUri(p)}: explain --queued exited ${code}`, now());
231
+ if (code !== 0 || p.host === "details")
232
+ await d.pause();
233
+ return code;
234
+ }
235
+ export const PROTOCOL_KEY = "HKCU\\Software\\Classes\\pantheon";
236
+ export const COMMAND_KEY = `${PROTOCOL_KEY}\\shell\\open\\command`;
237
+ export const PANTHEON_AUMID = "JoinPantheon.Pantheon";
238
+ export const AUMID_KEY = `HKCU\\Software\\Classes\\AppUserModelId\\${PANTHEON_AUMID}`;
239
+ export function handlerCommand(entry, home = null) {
240
+ const words = [entry.command, ...entry.args];
241
+ if (!words.length || /\.(cmd|bat)$/i.test(entry.command))
242
+ return null;
243
+ if ([...words, ...(home ? [home] : [])].some((w) => w.includes('"') || /[\r\n%]/.test(w)))
244
+ return null;
245
+ const q = (w) => (/^[A-Za-z0-9_.:\\/-]+$/.test(w) && !/[\\/]/.test(w) ? w : `"${w}"`);
246
+ return `${words.map(q).join(" ")} handle-uri${home ? ` --home "${home}"` : ""} "%1"`;
247
+ }
248
+ export function handlerEntry(entry, flags = []) {
249
+ const args = entry.args[entry.args.length - 1] === "mcp" ? entry.args.slice(0, -1) : entry.args;
250
+ return { command: entry.command, args: [...flags.filter((f) => !args.includes(f)), ...args] };
251
+ }
252
+ export function parseHandleUriArgs(args, isAbsolute = (p) => win32.isAbsolute(p) || p.startsWith("/"), normalize = (p) => p) {
253
+ if (args[0] !== "--home")
254
+ return { home: null, rest: [...args] };
255
+ if (args.length !== 3)
256
+ return { error: "it expects --home <folder> and then exactly one link" };
257
+ const dir = args[1];
258
+ if (!dir || /[\r\n"%]/.test(dir) || !isAbsolute(dir) || normalize(dir) !== dir || /(^|[\\/])\.\.([\\/]|$)/.test(dir))
259
+ return { error: "its --home is not a full path to a folder" };
260
+ return { home: dir, rest: [args[2]] };
261
+ }
262
+ export function regDefault(stdout) {
263
+ for (const line of stdout.split(/\r?\n/)) {
264
+ const m = /^\s+\S.*?\s{2,}REG_(?:EXPAND_)?SZ(?:\s{2,}(.*))?\s*$/.exec(line);
265
+ if (m)
266
+ return (m[1] ?? "").trimEnd();
267
+ }
268
+ return null;
269
+ }
270
+ const isOurs = (command) => !!command && / handle-uri(?: --home "[^"%\r\n]+")? "%1"$/.test(command);
271
+ export function registerUriHandler(command, io, icon) {
272
+ const now = io.reg(["query", COMMAND_KEY, "/ve"]);
273
+ const current = now.status === 0 ? regDefault(now.stdout) : null;
274
+ if (current !== null && !isOurs(current)) {
275
+ return { changed: false, ok: false, line: `the pantheon: links are registered to another program (${current}) — left alone, so alert buttons will not work here` };
276
+ }
277
+ if (current === command) {
278
+ const url = io.reg(["query", PROTOCOL_KEY, "/v", "URL Protocol"]);
279
+ if (url.status === 0)
280
+ return { changed: false, ok: true, line: "pantheon: links already open Pantheon" };
281
+ }
282
+ const adds = [
283
+ ["add", PROTOCOL_KEY, "/ve", "/d", "URL:Pantheon", "/f"],
284
+ ["add", PROTOCOL_KEY, "/v", "URL Protocol", "/d", "", "/f"],
285
+ ...(icon ? [["add", `${PROTOCOL_KEY}\\DefaultIcon`, "/ve", "/d", icon, "/f"]] : []),
286
+ ["add", COMMAND_KEY, "/ve", "/d", command, "/f"],
287
+ ["add", AUMID_KEY, "/v", "DisplayName", "/d", "Pantheon", "/f"],
288
+ ...(icon ? [["add", AUMID_KEY, "/v", "IconUri", "/d", icon, "/f"]] : []),
289
+ ];
290
+ for (const a of adds) {
291
+ const r = io.reg(a);
292
+ if (r.refused)
293
+ return { changed: false, ok: false, line: `pantheon: links not registered — ${r.refused}` };
294
+ if (r.status !== 0)
295
+ return { changed: true, ok: false, line: `could not register pantheon: links (reg ${a.slice(0, 2).join(" ")} exited ${r.status ?? "on a signal"})` };
296
+ }
297
+ return { changed: true, ok: true, line: `pantheon: links now open Pantheon (${PROTOCOL_KEY})` };
298
+ }
299
+ export function aumidRegistered(io) {
300
+ return io.reg(["query", AUMID_KEY, "/v", "DisplayName"]).status === 0;
301
+ }
302
+ export function unregisterUriHandler(io) {
303
+ const removed = [], left = [];
304
+ const now = io.reg(["query", COMMAND_KEY, "/ve"]);
305
+ const current = now.status === 0 ? regDefault(now.stdout) : null;
306
+ if (current !== null && !isOurs(current))
307
+ left.push(`the pantheon: link handler (${PROTOCOL_KEY}) — it belongs to another program, so it was left alone`);
308
+ else if (now.status === 0) {
309
+ const r = io.reg(["delete", PROTOCOL_KEY, "/f"]);
310
+ if (r.status === 0)
311
+ removed.push(`the pantheon: link handler (${PROTOCOL_KEY})`);
312
+ else
313
+ left.push(r.refused ? `the pantheon: link handler (${PROTOCOL_KEY}) — ${r.refused}` : `the pantheon: link handler (${PROTOCOL_KEY}) — remove it with: reg delete ${PROTOCOL_KEY} /f`);
314
+ }
315
+ if (io.reg(["query", AUMID_KEY]).status === 0) {
316
+ const r = io.reg(["delete", AUMID_KEY, "/f"]);
317
+ if (r.status === 0)
318
+ removed.push(`Pantheon's notification app id (${AUMID_KEY})`);
319
+ else
320
+ left.push(r.refused ? `Pantheon's notification app id — ${r.refused}` : `Pantheon's notification app id — remove it with: reg delete "${AUMID_KEY}" /f`);
321
+ }
322
+ return { removed, left };
323
+ }
324
+ export function realRegIo(borrowed, env = process.env) {
325
+ const exe = win32.join(env.SystemRoot || env.windir || "C:\\Windows", "System32", "reg.exe");
326
+ return {
327
+ reg: (args) => {
328
+ if (args[0] !== "query" && borrowed())
329
+ return { status: 1, stdout: "", refused: "this Pantheon home, HOME or USERPROFILE is not your own Windows profile (a test or a sandbox), so the registry was not changed" };
330
+ const r = spawnSync(exe, args, { encoding: "utf8", windowsHide: true, timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
331
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? "" };
332
+ },
333
+ };
334
+ }
335
+ //# sourceMappingURL=uri-handler.js.map