@join-pantheon/cli 0.32.0 → 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/accounts.js +1 -1
- package/dist/board.js +1417 -0
- package/dist/claude-config.js +189 -0
- package/dist/cli.js +298 -58
- package/dist/command-state.js +2 -1
- package/dist/dbus.js +92 -13
- package/dist/doctor.js +36 -5
- package/dist/folder-guard.js +89 -0
- package/dist/git-safe.js +3 -3
- package/dist/host-env.js +47 -1
- package/dist/install.js +76 -10
- package/dist/installer.js +2 -1
- package/dist/ledger.js +2 -2
- package/dist/machine.js +38 -3
- package/dist/managed.js +27 -1
- package/dist/notice-linux.js +501 -0
- package/dist/notice-windows.js +323 -0
- package/dist/notifier.js +12 -3
- package/dist/quickstart.js +28 -11
- package/dist/relay.js +75 -9
- package/dist/service.js +455 -29
- package/dist/setup-guide.js +22 -5
- package/dist/store.js +18 -2
- package/dist/upgrade.js +249 -4
- package/dist/uri-handler.js +335 -0
- package/dist/win-host.js +969 -0
- package/dist/worker-policy.js +3 -0
- package/dist/worker.js +11 -11
- package/helper/macos/prebuilt/manifest.json +2 -2
- package/helper/macos/prebuilt/pantheon-hud +0 -0
- package/helper/windows/pantheon-host.cs +170 -0
- package/helper/windows/pantheon-notice.ps1 +127 -0
- package/helper/windows/pantheon-tray.ps1 +731 -0
- package/helper/windows/pantheon.ico +0 -0
- package/installer/install.ps1 +234 -18
- package/installer/install.sh +21 -0
- package/package.json +2 -2
package/dist/store.js
CHANGED
|
@@ -8,13 +8,27 @@ export function ensurePrivateDir(dir) {
|
|
|
8
8
|
}
|
|
9
9
|
catch { }
|
|
10
10
|
}
|
|
11
|
+
let waitForDisk = true;
|
|
12
|
+
let slowestWaitMs = 0;
|
|
13
|
+
export function setWaitForDisk(on) { waitForDisk = on; }
|
|
14
|
+
export function waitsForDisk() { return waitForDisk; }
|
|
15
|
+
export function takeSlowestDiskWait() { const w = slowestWaitMs; slowestWaitMs = 0; return w; }
|
|
16
|
+
export function syncFd(fd) {
|
|
17
|
+
if (!waitForDisk)
|
|
18
|
+
return;
|
|
19
|
+
const t = Date.now();
|
|
20
|
+
fsyncSync(fd);
|
|
21
|
+
const took = Date.now() - t;
|
|
22
|
+
if (took > slowestWaitMs)
|
|
23
|
+
slowestWaitMs = took;
|
|
24
|
+
}
|
|
11
25
|
export function writeFileAtomic(file, data, mode = 0o600) {
|
|
12
26
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
13
27
|
const tmp = `${file}.tmp`;
|
|
14
28
|
const fd = openSync(tmp, "w", mode);
|
|
15
29
|
try {
|
|
16
30
|
writeSync(fd, typeof data === "string" ? Buffer.from(data) : data);
|
|
17
|
-
|
|
31
|
+
syncFd(fd);
|
|
18
32
|
}
|
|
19
33
|
finally {
|
|
20
34
|
closeSync(fd);
|
|
@@ -42,10 +56,12 @@ export function renameRetrying(from, to, o = {}) {
|
|
|
42
56
|
}
|
|
43
57
|
}
|
|
44
58
|
export function fsyncDir(dir) {
|
|
59
|
+
if (!waitForDisk)
|
|
60
|
+
return;
|
|
45
61
|
let fd = null;
|
|
46
62
|
try {
|
|
47
63
|
fd = openSync(dir, "r");
|
|
48
|
-
|
|
64
|
+
syncFd(fd);
|
|
49
65
|
}
|
|
50
66
|
catch { }
|
|
51
67
|
finally {
|
package/dist/upgrade.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, posix, win32 } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { installForUser } from "./install.js";
|
|
@@ -7,7 +7,8 @@ import { npmSpawn } from "./which.js";
|
|
|
7
7
|
import { proxyEnvFor } from "./network.js";
|
|
8
8
|
import { relayProc, workerProc, notifierProc, menubarProc } from "./machine.js";
|
|
9
9
|
import { load } from "./config.js";
|
|
10
|
-
import {
|
|
10
|
+
import { compareVersions, INSTALLER_MIN_CLI, isVersion } from "./installer.js";
|
|
11
|
+
import { childNodeFlags, installerLineHere, launcherWithRepair, managedLayout, nodeSatisfies, nodeTooOldSentence, runtimeNodeVersion } from "./managed.js";
|
|
11
12
|
export function repoRoot() {
|
|
12
13
|
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
14
|
}
|
|
@@ -86,6 +87,7 @@ export function defaultDeps() {
|
|
|
86
87
|
return up;
|
|
87
88
|
},
|
|
88
89
|
npmUpgrade: (version) => npm(npmInstallArgs(version), { stdio: ["ignore", "pipe", "pipe"] }),
|
|
90
|
+
stagedUpgrade: (m, spec) => { stagedManagedUpgrade(m, spec); },
|
|
89
91
|
npmLatest: () => {
|
|
90
92
|
try {
|
|
91
93
|
return npm(["view", `${PKG_NAME}`, "version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim() || null;
|
|
@@ -113,13 +115,16 @@ export function defaultDeps() {
|
|
|
113
115
|
};
|
|
114
116
|
}
|
|
115
117
|
export function upgrade(deps = defaultDeps(), opts = {}) {
|
|
116
|
-
const spec = opts.version &&
|
|
118
|
+
const spec = opts.version && isVersion(opts.version) ? opts.version : "latest";
|
|
117
119
|
const { root } = deps;
|
|
118
120
|
const from = deps.version(root);
|
|
119
121
|
const steps = [];
|
|
120
122
|
const stop = (name, detail) => ({ root, from, to: from, steps: [...steps, { name, ok: false, detail }], ok: false, alreadyCurrent: false, restart: [] });
|
|
121
123
|
if (!deps.isGit(root)) {
|
|
122
124
|
const managed = deps.managed === undefined ? managedLayout() : deps.managed;
|
|
125
|
+
if (managed && spec !== "latest" && compareVersions(spec, INSTALLER_MIN_CLI) < 0) {
|
|
126
|
+
return stop("check", `${spec} is older than this install supports (${INSTALLER_MIN_CLI} or newer): it could not upgrade itself again. Nothing was changed.`);
|
|
127
|
+
}
|
|
123
128
|
if (managed && deps.engines) {
|
|
124
129
|
const range = deps.engines(spec);
|
|
125
130
|
const node = deps.runtimeNode ? deps.runtimeNode() : runtimeNodeVersion(managed);
|
|
@@ -129,10 +134,16 @@ export function upgrade(deps = defaultDeps(), opts = {}) {
|
|
|
129
134
|
}
|
|
130
135
|
}
|
|
131
136
|
try {
|
|
132
|
-
|
|
137
|
+
if (managed && deps.stagedUpgrade)
|
|
138
|
+
deps.stagedUpgrade(managed, spec);
|
|
139
|
+
else
|
|
140
|
+
deps.npmUpgrade(spec === "latest" ? undefined : spec);
|
|
133
141
|
steps.push({ name: "npm", ok: true, detail: `installed ${PKG_NAME}@${spec}` });
|
|
134
142
|
}
|
|
135
143
|
catch (e) {
|
|
144
|
+
if (e instanceof UpgradeBusy) {
|
|
145
|
+
return { root, from, to: from, steps: [...steps, { name: "npm", ok: true, detail: e.message }], ok: true, alreadyCurrent: true, restart: [] };
|
|
146
|
+
}
|
|
136
147
|
if (spec !== "latest" && /ETARGET|No matching version|notarget/i.test(String(e.stderr ?? "") + e.message)) {
|
|
137
148
|
return { root, from, to: from, steps: [...steps, { name: "npm", ok: true, detail: `${PKG_NAME}@${spec} is not on npm yet` }], ok: true, alreadyCurrent: true, restart: [] };
|
|
138
149
|
}
|
|
@@ -154,6 +165,9 @@ export function upgrade(deps = defaultDeps(), opts = {}) {
|
|
|
154
165
|
const restart = [...deps.running(), "your agent session"];
|
|
155
166
|
return { root, from, to, steps, ok: true, alreadyCurrent: false, restart };
|
|
156
167
|
}
|
|
168
|
+
if (opts.exact) {
|
|
169
|
+
return stop("check", `this is a git checkout, which upgrades by pulling commits, not to a version. Check out the release you want (git checkout v${spec}), or run \`pantheon upgrade\` for the latest commit.`);
|
|
170
|
+
}
|
|
157
171
|
const dirty = deps.dirty(root);
|
|
158
172
|
if (dirty.length) {
|
|
159
173
|
return stop("check", `the checkout has uncommitted changes (${dirty.slice(0, 4).join(", ")}${dirty.length > 4 ? ", …" : ""}). Commit or \`git stash\` them first — upgrade will not overwrite your work.`);
|
|
@@ -194,4 +208,235 @@ export function upgrade(deps = defaultDeps(), opts = {}) {
|
|
|
194
208
|
const restart = [...deps.running(), "your agent session"];
|
|
195
209
|
return { root, from, to, steps, ok: true, alreadyCurrent: false, restart };
|
|
196
210
|
}
|
|
211
|
+
const pidAlive = (pid) => {
|
|
212
|
+
if (!(pid > 0))
|
|
213
|
+
return false;
|
|
214
|
+
try {
|
|
215
|
+
process.kill(pid, 0);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
return e.code === "EPERM";
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const realSwapIo = (m) => ({
|
|
223
|
+
npm: (args) => {
|
|
224
|
+
const sp = npmSpawn(args, undefined, undefined, proxyEnvFor(), m);
|
|
225
|
+
execFileSync(sp.command, sp.args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 600_000, ...sp.options });
|
|
226
|
+
},
|
|
227
|
+
run: (cmd, args) => {
|
|
228
|
+
try {
|
|
229
|
+
return { status: 0, out: execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60_000 }) };
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
const x = e;
|
|
233
|
+
return { status: x.status ?? null, out: `${x.stdout ?? ""}${x.stderr ?? ""}` || x.message };
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
exists: (p) => existsSync(p),
|
|
237
|
+
rename: (a, b) => renameSync(a, b),
|
|
238
|
+
remove: (p) => rmSync(p, { recursive: true, force: true }),
|
|
239
|
+
read: (p) => readFileSync(p, "utf8"),
|
|
240
|
+
write: (p, text) => { const tmp = `${p}.new-${process.pid}`; writeFileSync(tmp, text, { mode: 0o755 }); renameSync(tmp, p); },
|
|
241
|
+
list: (dir) => { try {
|
|
242
|
+
return readdirSync(dir);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return [];
|
|
246
|
+
} },
|
|
247
|
+
unlock: (dir) => {
|
|
248
|
+
try {
|
|
249
|
+
if (Number(readFileSync(join(dir, "pid"), "utf8").trim()) === process.pid)
|
|
250
|
+
rmSync(dir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
catch { }
|
|
253
|
+
},
|
|
254
|
+
alive: (pid) => pidAlive(pid),
|
|
255
|
+
holdSignals: () => {
|
|
256
|
+
const hold = () => { };
|
|
257
|
+
const sigs = ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"].filter((x) => x !== "SIGBREAK" || process.platform === "win32");
|
|
258
|
+
for (const x of sigs)
|
|
259
|
+
process.on(x, hold);
|
|
260
|
+
return () => { for (const x of sigs)
|
|
261
|
+
process.off(x, hold); };
|
|
262
|
+
},
|
|
263
|
+
sleep: (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); },
|
|
264
|
+
lock: (dir) => {
|
|
265
|
+
for (let i = 0; i < 3; i++) {
|
|
266
|
+
try {
|
|
267
|
+
mkdirSync(dir);
|
|
268
|
+
writeFileSync(join(dir, "pid"), String(process.pid));
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
if (e.code !== "EEXIST")
|
|
273
|
+
throw e;
|
|
274
|
+
let pid = 0, age = 0;
|
|
275
|
+
try {
|
|
276
|
+
pid = Number(readFileSync(join(dir, "pid"), "utf8").trim());
|
|
277
|
+
}
|
|
278
|
+
catch { }
|
|
279
|
+
try {
|
|
280
|
+
age = Date.now() - statSync(dir).mtimeMs;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (pidAlive(pid) ? age < 20 * 60_000 : !pid && age < 10_000)
|
|
286
|
+
return false;
|
|
287
|
+
const gone = `${dir}.stale-${process.pid}`;
|
|
288
|
+
try {
|
|
289
|
+
renameSync(dir, gone);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
rmSync(gone, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
export const realSwapIoForTest = realSwapIo;
|
|
301
|
+
export class UpgradeBusy extends Error {
|
|
302
|
+
constructor() { super("another pantheon upgrade is running on this machine; nothing was changed, and the next check tries again"); }
|
|
303
|
+
}
|
|
304
|
+
export function stagedManagedUpgrade(m, spec, io = realSwapIo(m)) {
|
|
305
|
+
const win = /\.exe$/i.test(m.node);
|
|
306
|
+
const P = win ? win32 : posix;
|
|
307
|
+
const runtime = P.dirname(m.nodeDir);
|
|
308
|
+
const staging = P.join(runtime, `.upgrade-${process.pid}`);
|
|
309
|
+
const stagedPkg = win ? P.join(staging, "node_modules", "@join-pantheon", "cli") : P.join(staging, "lib", "node_modules", "@join-pantheon", "cli");
|
|
310
|
+
const prev = m.prevDir;
|
|
311
|
+
const failed = P.join(runtime, "cli.failed");
|
|
312
|
+
const startsAs = (entry) => {
|
|
313
|
+
const r = io.run(m.node, ["--use-system-ca", entry, "--version"]);
|
|
314
|
+
const line = r.out.replace(/\u001b\[[0-9;]*m/g, "").trim().split(/\r?\n/).pop()?.trim() ?? "";
|
|
315
|
+
return r.status === 0 && /^\d+\.\d+\.\d+/.test(line) ? line.replace(/^pantheon\s+/, "") : null;
|
|
316
|
+
};
|
|
317
|
+
const rename = (a, b, waitMs = 1000) => {
|
|
318
|
+
for (let i = 0; i < 6; i++) {
|
|
319
|
+
try {
|
|
320
|
+
io.rename(a, b);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
if (i < 5)
|
|
325
|
+
io.sleep(waitMs);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
};
|
|
330
|
+
const ready = `${staging}-ready`;
|
|
331
|
+
const whole = (d) => ["package.json", P.join("bin", "pantheon.js"), P.join("dist", "cli.js")].every((f) => io.exists(P.join(d, f)));
|
|
332
|
+
const inPlace = () => whole(m.pkgDir);
|
|
333
|
+
const tidy = (p) => { try {
|
|
334
|
+
io.remove(p);
|
|
335
|
+
}
|
|
336
|
+
catch { } };
|
|
337
|
+
const held = (fn) => { const release = io.holdSignals(); try {
|
|
338
|
+
return fn();
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
release();
|
|
342
|
+
} };
|
|
343
|
+
const repairHint = () => io.exists(P.join(prev, "bin", "pantheon.js")) ? `The pantheon command puts ${prev} back the next time it runs; or run the installer again` : "Run the installer again to repair it";
|
|
344
|
+
const unnest = (inside) => {
|
|
345
|
+
const nested = P.join(inside, "cli.prev");
|
|
346
|
+
if (!io.exists(nested))
|
|
347
|
+
return;
|
|
348
|
+
if (io.exists(prev))
|
|
349
|
+
tidy(nested);
|
|
350
|
+
else
|
|
351
|
+
rename(nested, prev, 200);
|
|
352
|
+
};
|
|
353
|
+
const lock = P.join(runtime, ".upgrade.lock");
|
|
354
|
+
if (!io.lock(lock))
|
|
355
|
+
throw new UpgradeBusy();
|
|
356
|
+
try {
|
|
357
|
+
if (!io.exists(m.pkgDir)) {
|
|
358
|
+
const from = [prev, ...io.list(runtime).filter((n) => /^\.upgrade-\d+-ready$/.test(n)).map((n) => P.join(runtime, n)), P.join(failed, "cli.prev")]
|
|
359
|
+
.find(whole);
|
|
360
|
+
if (from)
|
|
361
|
+
rename(from, m.pkgDir);
|
|
362
|
+
}
|
|
363
|
+
if (!inPlace()) {
|
|
364
|
+
throw new Error(`the pantheon in place (${m.pkgDir}) is missing and could not be put back yet (a file is in use); nothing was changed. ${repairHint()}`);
|
|
365
|
+
}
|
|
366
|
+
unnest(m.pkgDir);
|
|
367
|
+
unnest(failed);
|
|
368
|
+
if (whole(prev) && !startsAs(m.entry) && startsAs(P.join(prev, "bin", "pantheon.js"))) {
|
|
369
|
+
held(() => { if (rename(m.pkgDir, failed, 500))
|
|
370
|
+
rename(prev, m.pkgDir, 500); });
|
|
371
|
+
if (!inPlace())
|
|
372
|
+
throw new Error(`the pantheon in place did not start, and the previous version could not be put back yet (a file is in use); nothing else was changed. ${repairHint()}`);
|
|
373
|
+
}
|
|
374
|
+
for (const n of io.list(runtime)) {
|
|
375
|
+
const pid = /^\.upgrade-(\d+)(?:-ready)?$/.exec(n)?.[1];
|
|
376
|
+
if ((pid && (Number(pid) === process.pid || !io.alive(Number(pid)))) || (n === "cli.failed" && !io.exists(P.join(failed, "cli.prev"))))
|
|
377
|
+
tidy(P.join(runtime, n));
|
|
378
|
+
}
|
|
379
|
+
if (!win) {
|
|
380
|
+
try {
|
|
381
|
+
const healed = launcherWithRepair(io.read(m.launcher), m, "linux");
|
|
382
|
+
if (healed !== null)
|
|
383
|
+
io.write(m.launcher, healed);
|
|
384
|
+
}
|
|
385
|
+
catch { }
|
|
386
|
+
}
|
|
387
|
+
io.npm(["install", "-g", "--prefix", staging, "--cache", P.join(runtime, "npm-cache"), "--fetch-timeout=60000", "--fetch-retries=2",
|
|
388
|
+
`${PKG_NAME}@${spec}`, "--no-audit", "--no-fund", "--loglevel=error"]);
|
|
389
|
+
let version = "";
|
|
390
|
+
try {
|
|
391
|
+
version = JSON.parse(io.read(P.join(stagedPkg, "package.json"))).version ?? "";
|
|
392
|
+
}
|
|
393
|
+
catch { }
|
|
394
|
+
if (!version || (spec !== "latest" && version !== spec))
|
|
395
|
+
throw new Error(`npm installed ${version || "nothing readable"} into the staging folder, not ${spec}; nothing was changed`);
|
|
396
|
+
const staged = startsAs(P.join(stagedPkg, "bin", "pantheon.js"));
|
|
397
|
+
if (staged !== version)
|
|
398
|
+
throw new Error(`the new pantheon ${version} did not start (it answered ${staged ?? "nothing"}); the working version was kept`);
|
|
399
|
+
if (!rename(stagedPkg, ready))
|
|
400
|
+
throw new Error("the new pantheon's files are still held by another program (a virus scan); nothing was changed, and the next check tries again");
|
|
401
|
+
if (!inPlace())
|
|
402
|
+
throw new Error(`the pantheon in place (${m.pkgDir}) went missing during the upgrade; nothing was swapped. ${repairHint()}`);
|
|
403
|
+
tidy(prev);
|
|
404
|
+
if (io.exists(prev)) {
|
|
405
|
+
tidy(ready);
|
|
406
|
+
throw new Error(`the kept previous version (${prev}) could not be removed to make room (a file is in use); nothing was changed, and the next check tries again`);
|
|
407
|
+
}
|
|
408
|
+
held(() => {
|
|
409
|
+
if (io.exists(m.pkgDir) && !rename(m.pkgDir, prev, 500)) {
|
|
410
|
+
throw new Error(`could not move the working pantheon aside (a file in ${m.pkgDir} is in use); it was kept, and the next check tries again`);
|
|
411
|
+
}
|
|
412
|
+
if (!rename(ready, m.pkgDir, 500)) {
|
|
413
|
+
if (!io.exists(m.pkgDir) && io.exists(prev))
|
|
414
|
+
rename(prev, m.pkgDir, 500);
|
|
415
|
+
throw new Error(inPlace()
|
|
416
|
+
? `could not put the new pantheon in place (a file is in use); the working version was put back`
|
|
417
|
+
: `could not put the new pantheon in place, nor the working one back: ${m.pkgDir} is missing. The pantheon command puts ${prev} back the next time it runs; or run the installer again`);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
unnest(m.pkgDir);
|
|
421
|
+
const placed = startsAs(m.entry);
|
|
422
|
+
if (placed !== version) {
|
|
423
|
+
const back = whole(prev) && held(() => rename(m.pkgDir, failed, 500) && (rename(prev, m.pkgDir, 500) || inPlace()));
|
|
424
|
+
if (back)
|
|
425
|
+
tidy(failed);
|
|
426
|
+
throw new Error(back
|
|
427
|
+
? `pantheon ${version} did not start once in place (it answered ${placed ?? "nothing"}); the previous version was put back`
|
|
428
|
+
: `pantheon ${version} did not start once in place, and the previous version could not be put back yet. ${inPlace() ? `It is kept at ${prev}; the next upgrade puts it back, or run the installer again` : repairHint()}`);
|
|
429
|
+
}
|
|
430
|
+
return { version };
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
tidy(staging);
|
|
434
|
+
if (inPlace())
|
|
435
|
+
tidy(ready);
|
|
436
|
+
try {
|
|
437
|
+
io.unlock(lock);
|
|
438
|
+
}
|
|
439
|
+
catch { }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
197
442
|
//# sourceMappingURL=upgrade.js.map
|
|
@@ -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
|