pinomad 0.11.0 → 0.13.0
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/README.md +1 -1
- package/dist/{chunk-wb10efyq.js → chunk-445cevpm.js} +1 -1
- package/dist/{chunk-jt34g8st.js → chunk-4fferbxw.js} +247 -27
- package/dist/{chunk-67gq282t.js → chunk-53xv3fny.js} +725 -75
- package/dist/{chunk-44pnts98.js → chunk-563fnad9.js} +1 -1
- package/dist/{chunk-b457q0tm.js → chunk-6th9xv39.js} +11 -8
- package/dist/{chunk-ncbqa8jk.js → chunk-7mn38n4f.js} +1 -1
- package/dist/{chunk-as6pyf00.js → chunk-90g1cg9n.js} +1 -1
- package/dist/{chunk-9h5vawnm.js → chunk-aapsc9g0.js} +270 -66
- package/dist/{chunk-n1m1tjrk.js → chunk-d4tq62yr.js} +57 -16
- package/dist/{chunk-gtdpjqa3.js → chunk-d9mq5d46.js} +205 -49
- package/dist/{chunk-y127fe1a.js → chunk-g3ahp7b9.js} +11 -0
- package/dist/{chunk-xqycs43g.js → chunk-kmzzjv53.js} +83 -8
- package/dist/{chunk-4dcs8sbj.js → chunk-m51mq8vh.js} +43 -21
- package/dist/{chunk-tj57aqav.js → chunk-maqv34cs.js} +2 -2
- package/dist/chunk-nq57fdnq.js +106 -0
- package/dist/{chunk-h5q2y57m.js → chunk-ra86wjp5.js} +10 -4
- package/dist/{chunk-sd5fkxjj.js → chunk-s0h7c7da.js} +1 -1
- package/dist/chunk-sd640dt7.js +22 -0
- package/dist/{chunk-6d16c2fv.js → chunk-t6fw0wa9.js} +13 -6
- package/dist/{chunk-vvj9b1gc.js → chunk-wd7aracm.js} +188 -54
- package/dist/pinomad.js +15 -14
- package/package.json +13 -11
- package/web/assets/astro-DOly5024.js +1 -0
- package/web/assets/c-BIGW1oBm.js +1 -0
- package/web/assets/core-BHm2gdHL.js +4 -0
- package/web/assets/cpp-DddhVWhK.js +1 -0
- package/web/assets/csharp-B_TYUkTI.js +1 -0
- package/web/assets/css-Z72CF80r.js +1 -0
- package/web/assets/dart-CtKP3W-j.js +1 -0
- package/web/assets/diff-D97Zzqfu.js +1 -0
- package/web/assets/dockerfile-BcOcwvcX.js +1 -0
- package/web/assets/elixir-DkJLSxtU.js +1 -0
- package/web/assets/engine-javascript-BHPwbUfN.js +141 -0
- package/web/assets/engine-oniguruma-CQjQPVHy.js +1 -0
- package/web/assets/fish-BvzEVeQv.js +1 -0
- package/web/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/assets/go-C27-OAKa.js +1 -0
- package/web/assets/graphql-ChdNCCLP.js +1 -0
- package/web/assets/haskell-Df6bDoY_.js +1 -0
- package/web/assets/hcl-BWvSN4gD.js +1 -0
- package/web/assets/highlighted-diff-DrHvSLK9.js +175 -0
- package/web/assets/highlights-1aLwssz-.js +4 -0
- package/web/assets/highlights-themes-C1p4pFYS.js +1 -0
- package/web/assets/html-BlS82ilI.js +1 -0
- package/web/assets/index-A_AUJoO6.css +1 -0
- package/web/assets/index-BafYMKiN.js +396 -0
- package/web/assets/ini-BEwlwnbL.js +1 -0
- package/web/assets/java-CylS5w8V.js +1 -0
- package/web/assets/javascript-wDzz0qaB.js +1 -0
- package/web/assets/json-Cp-IABpG.js +1 -0
- package/web/assets/jsonc-Des-eS-w.js +1 -0
- package/web/assets/jsx-g9-lgVsj.js +1 -0
- package/web/assets/kotlin-BdnUsdx6.js +1 -0
- package/web/assets/less-B1dDrJ26.js +1 -0
- package/web/assets/lua-BaeVxFsk.js +1 -0
- package/web/assets/make-CHLpvVh8.js +1 -0
- package/web/assets/markdown-Cvjx9yec.js +1 -0
- package/web/assets/nix-CwoSXNpI.js +1 -0
- package/web/assets/objective-c-DXmwc3jG.js +1 -0
- package/web/assets/php-DoruYMF0.js +1 -0
- package/web/assets/pierre-dark-CyvmCCZW.js +1 -0
- package/web/assets/pierre-dark-VKo95ZgA.js +1 -0
- package/web/assets/pierre-light-480U9XYS.js +1 -0
- package/web/assets/pierre-light-BVSW-Xev.js +1 -0
- package/web/assets/postcss-DnzrXTVv.js +1 -0
- package/web/assets/powershell-Dd0kTxKP.js +1 -0
- package/web/assets/prisma-Vru482bI.js +1 -0
- package/web/assets/proto-C7zT0LnQ.js +1 -0
- package/web/assets/python-B6aJPvgy.js +1 -0
- package/web/assets/ruby-B43yHlf9.js +1 -0
- package/web/assets/rust-DPvrY3IQ.js +1 -0
- package/web/assets/scala-CqE71os6.js +1 -0
- package/web/assets/scss-Cw6XMfVs.js +1 -0
- package/web/assets/shellscript-Yzrsuije.js +1 -0
- package/web/assets/shiki-BwyCm3kZ.js +2 -0
- package/web/assets/shiki-langs-CMlALLSg.js +2 -0
- package/web/assets/shiki-wasm-CgFRq91U.js +1 -0
- package/web/assets/sql-DkvoVjww.js +1 -0
- package/web/assets/stream-tokenizer-BlgIhw0y.js +4 -0
- package/web/assets/svelte-DoIXUgJc.js +1 -0
- package/web/assets/swift-C2oV4EkX.js +1 -0
- package/web/assets/terraform-BETggiCN.js +1 -0
- package/web/assets/theming-themes-DDvoqEqT.js +1 -0
- package/web/assets/toml-vGWfd6FD.js +1 -0
- package/web/assets/tsx-COt5Ahok.js +1 -0
- package/web/assets/typescript-BPQ3VLAy.js +1 -0
- package/web/assets/vue-CwSeJimo.js +1 -0
- package/web/assets/xml-sdJ4AIDG.js +1 -0
- package/web/assets/xterm-view-6GBZ9nXN.css +32 -0
- package/web/assets/xterm-view-BnKOnF3C.js +38 -0
- package/web/assets/yaml-Buea-lGh.js +1 -0
- package/web/assets/zig-VOosw3JB.js +1 -0
- package/web/index.html +2 -2
- package/dist/chunk-etf7ef97.js +0 -55
- package/dist/chunk-greb3dp6.js +0 -22
- package/web/assets/index-BhHrKpoo.js +0 -395
- package/web/assets/index-DoyHMPwf.css +0 -1
|
@@ -3,35 +3,39 @@ import {
|
|
|
3
3
|
joinServer,
|
|
4
4
|
openServiceSlot,
|
|
5
5
|
writeService
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import"./chunk-
|
|
8
|
-
import"./chunk-
|
|
6
|
+
} from "./chunk-d9mq5d46.js";
|
|
7
|
+
import"./chunk-7mn38n4f.js";
|
|
8
|
+
import"./chunk-563fnad9.js";
|
|
9
9
|
import"./chunk-t91zt5f0.js";
|
|
10
10
|
import {
|
|
11
11
|
normalizeOrigin,
|
|
12
|
+
readRemovalNotice,
|
|
12
13
|
readServerConfig
|
|
13
|
-
} from "./chunk-
|
|
14
|
-
import"./chunk-
|
|
14
|
+
} from "./chunk-nq57fdnq.js";
|
|
15
|
+
import"./chunk-g3ahp7b9.js";
|
|
15
16
|
import {
|
|
17
|
+
LAUNCHER_EXECUTABLE,
|
|
16
18
|
argValue,
|
|
19
|
+
isLauncher,
|
|
17
20
|
serviceManager,
|
|
18
21
|
withoutArg,
|
|
19
22
|
withoutRelayArgs
|
|
20
|
-
} from "./chunk-
|
|
21
|
-
import"./chunk-
|
|
23
|
+
} from "./chunk-wd7aracm.js";
|
|
24
|
+
import"./chunk-4fferbxw.js";
|
|
22
25
|
import"./chunk-kzbzacz9.js";
|
|
23
|
-
import"./chunk-
|
|
26
|
+
import"./chunk-kmzzjv53.js";
|
|
24
27
|
import"./chunk-pqe7q1s7.js";
|
|
25
28
|
import {
|
|
26
29
|
DEFAULT_DATA_DIR
|
|
27
30
|
} from "./chunk-ngf1zatm.js";
|
|
28
31
|
import {
|
|
29
32
|
commandHint
|
|
30
|
-
} from "./chunk-
|
|
33
|
+
} from "./chunk-s0h7c7da.js";
|
|
31
34
|
import"./chunk-df32avw6.js";
|
|
32
35
|
|
|
33
36
|
// apps/host/src/cli/up.ts
|
|
34
|
-
import {
|
|
37
|
+
import { existsSync } from "node:fs";
|
|
38
|
+
import { isAbsolute, resolve } from "node:path";
|
|
35
39
|
import { createInterface } from "node:readline/promises";
|
|
36
40
|
import { parseArgs } from "node:util";
|
|
37
41
|
var OFFICIAL_SERVER_ORIGIN = "https://app.pinomad.si";
|
|
@@ -44,6 +48,9 @@ function resolveReach(flags, current) {
|
|
|
44
48
|
return { server: flags.server ?? current.server, lan: flags.lan ?? current.lan };
|
|
45
49
|
return { server: flags.server ?? !flags.lan, lan: flags.lan ?? !flags.server };
|
|
46
50
|
}
|
|
51
|
+
var lastRemovedOrigin = async (dataDir) => (await readRemovalNotice(dataDir).catch(() => {
|
|
52
|
+
return;
|
|
53
|
+
}))?.origin;
|
|
47
54
|
async function currentReach(dataDir, deps) {
|
|
48
55
|
const slot = await openServiceSlot(dataDir, deps?.manager ?? serviceManager());
|
|
49
56
|
const config = await readServerConfig(dataDir);
|
|
@@ -73,7 +80,7 @@ async function upHost(args, deps) {
|
|
|
73
80
|
const manager = deps?.manager ?? serviceManager();
|
|
74
81
|
const slot = await openServiceSlot(dataDir, manager);
|
|
75
82
|
const config = await readServerConfig(dataDir);
|
|
76
|
-
const origin = normalizeOrigin(args.origin ?? config?.origin ?? OFFICIAL_SERVER_ORIGIN);
|
|
83
|
+
const origin = normalizeOrigin(args.origin ?? config?.origin ?? await lastRemovedOrigin(dataDir) ?? OFFICIAL_SERVER_ORIGIN);
|
|
77
84
|
if (reach.server && config !== undefined && normalizeOrigin(config.origin) !== origin) {
|
|
78
85
|
throw new Error(`this host is joined to ${config.origin} — run \`${commandHint("leave")}\` first to join ${origin}, ` + `or leave out --origin to stay`);
|
|
79
86
|
}
|
|
@@ -81,7 +88,14 @@ async function upHost(args, deps) {
|
|
|
81
88
|
const lan = (hostArgs) => withLan(hostArgs, lanPort);
|
|
82
89
|
const notes = [];
|
|
83
90
|
if (reach.server && config === undefined) {
|
|
84
|
-
const joined = await joinServer({
|
|
91
|
+
const joined = await joinServer({
|
|
92
|
+
origin,
|
|
93
|
+
dataDir,
|
|
94
|
+
code: args.code,
|
|
95
|
+
...args.token === undefined ? {} : { token: args.token },
|
|
96
|
+
...args.launcher === undefined ? {} : { launcher: args.launcher },
|
|
97
|
+
editHostArgs: lan
|
|
98
|
+
}, { ...deps, manager });
|
|
85
99
|
return {
|
|
86
100
|
joined: { origin: joined.origin, username: joined.username, fresh: true },
|
|
87
101
|
...lanPort === undefined ? {} : { lanPort },
|
|
@@ -90,7 +104,7 @@ async function upHost(args, deps) {
|
|
|
90
104
|
};
|
|
91
105
|
}
|
|
92
106
|
const edit = reach.server ? (hostArgs) => lan(withoutRelayArgs(hostArgs)) : lan;
|
|
93
|
-
const service = await writeService(slot, edit, { changed: reach.server });
|
|
107
|
+
const service = await writeService(slot, edit, { changed: reach.server, launcher: args.launcher });
|
|
94
108
|
if (!reach.server && config !== undefined) {
|
|
95
109
|
notes.push(`still joined to ${config.origin} — run \`${commandHint("leave")}\` to leave it`);
|
|
96
110
|
}
|
|
@@ -101,6 +115,12 @@ async function upHost(args, deps) {
|
|
|
101
115
|
notes
|
|
102
116
|
};
|
|
103
117
|
}
|
|
118
|
+
async function readAllStdin() {
|
|
119
|
+
let text = "";
|
|
120
|
+
for await (const chunk of process.stdin)
|
|
121
|
+
text += String(chunk);
|
|
122
|
+
return text;
|
|
123
|
+
}
|
|
104
124
|
async function askOnTerminal(question) {
|
|
105
125
|
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
106
126
|
try {
|
|
@@ -135,7 +155,8 @@ var FLAGS = `choose with flags:
|
|
|
135
155
|
--server [--origin URL] through a PiNomad server (default ${OFFICIAL_SERVER_ORIGIN})
|
|
136
156
|
--lan [--remote-port N] only on the local network and tailnet (default port ${DEFAULT_REMOTE_PORT})
|
|
137
157
|
--server --lan both
|
|
138
|
-
--yes keep what this host has, or the server on a new host
|
|
158
|
+
--yes keep what this host has, or the server on a new host
|
|
159
|
+
--token-stdin join with a one-time token read from stdin, without a code`;
|
|
139
160
|
async function main(argv, deps) {
|
|
140
161
|
const { values } = parseArgs({
|
|
141
162
|
args: [...argv],
|
|
@@ -145,6 +166,9 @@ async function main(argv, deps) {
|
|
|
145
166
|
lan: { type: "boolean" },
|
|
146
167
|
yes: { type: "boolean", short: "y" },
|
|
147
168
|
origin: { type: "string" },
|
|
169
|
+
code: { type: "boolean" },
|
|
170
|
+
"token-stdin": { type: "boolean" },
|
|
171
|
+
launcher: { type: "string" },
|
|
148
172
|
"remote-port": { type: "string" },
|
|
149
173
|
"data-dir": { type: "string" }
|
|
150
174
|
}
|
|
@@ -152,7 +176,21 @@ async function main(argv, deps) {
|
|
|
152
176
|
const log = deps?.log ?? ((line) => console.log(line));
|
|
153
177
|
try {
|
|
154
178
|
const dataDir = resolve(values["data-dir"] ?? DEFAULT_DATA_DIR);
|
|
155
|
-
const
|
|
179
|
+
const launcher = values.launcher;
|
|
180
|
+
if (launcher !== undefined && process.platform !== "darwin")
|
|
181
|
+
throw new Error("--launcher is for the Mac App's service only");
|
|
182
|
+
if (launcher !== undefined && !(isAbsolute(launcher) && isLauncher(launcher))) {
|
|
183
|
+
throw new Error(`--launcher ${launcher}: not a path to the launcher inside an app (….app/Contents/MacOS/${LAUNCHER_EXECUTABLE})`);
|
|
184
|
+
}
|
|
185
|
+
if (launcher !== undefined && !existsSync(launcher))
|
|
186
|
+
throw new Error(`--launcher ${launcher}: no such file`);
|
|
187
|
+
let token;
|
|
188
|
+
if (values["token-stdin"] === true) {
|
|
189
|
+
token = (await (deps?.readStdin ?? readAllStdin)()).trim();
|
|
190
|
+
if (token === "")
|
|
191
|
+
throw new Error("--token-stdin: no join token on stdin");
|
|
192
|
+
}
|
|
193
|
+
const origin = normalizeOrigin(values.origin ?? (await readServerConfig(dataDir))?.origin ?? await lastRemovedOrigin(dataDir) ?? OFFICIAL_SERVER_ORIGIN);
|
|
156
194
|
const current = await currentReach(dataDir, deps);
|
|
157
195
|
let reach = resolveReach({ server: values.server, lan: values.lan }, current);
|
|
158
196
|
if (reach === undefined) {
|
|
@@ -176,7 +214,10 @@ async function main(argv, deps) {
|
|
|
176
214
|
dataDir,
|
|
177
215
|
origin,
|
|
178
216
|
reach,
|
|
179
|
-
...values["remote-port"] === undefined ? {} : { remotePort: values["remote-port"] }
|
|
217
|
+
...values["remote-port"] === undefined ? {} : { remotePort: values["remote-port"] },
|
|
218
|
+
...values.code === true ? { code: true } : {},
|
|
219
|
+
...token === undefined ? {} : { token },
|
|
220
|
+
...launcher === undefined ? {} : { launcher }
|
|
180
221
|
}, deps);
|
|
181
222
|
if (outcome.joined !== undefined) {
|
|
182
223
|
const who = outcome.joined.username ?? "an account";
|
|
@@ -3,28 +3,28 @@ import {
|
|
|
3
3
|
isSecureServerOrigin,
|
|
4
4
|
probeServerAccounts,
|
|
5
5
|
runServerOperation
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-7mn38n4f.js";
|
|
7
7
|
import {
|
|
8
8
|
constantValue,
|
|
9
9
|
openHostSettings
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-563fnad9.js";
|
|
11
11
|
import {
|
|
12
12
|
loadHostKey,
|
|
13
13
|
loadRelayKey
|
|
14
14
|
} from "./chunk-t91zt5f0.js";
|
|
15
15
|
import {
|
|
16
16
|
writeServerConfig
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-nq57fdnq.js";
|
|
18
18
|
import {
|
|
19
19
|
relayHostId
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-g3ahp7b9.js";
|
|
21
21
|
import {
|
|
22
22
|
argValue,
|
|
23
23
|
installSpec,
|
|
24
24
|
restartService,
|
|
25
25
|
serviceManager,
|
|
26
26
|
withoutRelayArgs
|
|
27
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-wd7aracm.js";
|
|
28
28
|
import {
|
|
29
29
|
cleanHostName,
|
|
30
30
|
normalizeHostName
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
import {
|
|
36
36
|
commandHint,
|
|
37
37
|
hostEntry
|
|
38
|
-
} from "./chunk-
|
|
38
|
+
} from "./chunk-s0h7c7da.js";
|
|
39
39
|
|
|
40
40
|
// apps/host/src/cli/join.ts
|
|
41
41
|
import { mkdir } from "node:fs/promises";
|
|
@@ -45,7 +45,10 @@ import { parseArgs } from "node:util";
|
|
|
45
45
|
|
|
46
46
|
// apps/host/src/cli/host-link.ts
|
|
47
47
|
import { spawn } from "node:child_process";
|
|
48
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
49
|
+
import { createServer } from "node:http";
|
|
48
50
|
import { arch, platform } from "node:os";
|
|
51
|
+
var CODE_EXPIRED = "the code expired before anyone confirmed it — run the command again for a new one";
|
|
49
52
|
var SLOW_DOWN_STEP_MS = 5000;
|
|
50
53
|
var REQUEST_TIMEOUT_MS = 15000;
|
|
51
54
|
var EXPIRY_GRACE_MS = 2 * 60000;
|
|
@@ -58,18 +61,28 @@ function browserCommand(os, env) {
|
|
|
58
61
|
return "xdg-open";
|
|
59
62
|
return;
|
|
60
63
|
}
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
var OPENER_SETTLE_MS = 2000;
|
|
65
|
+
function openInBrowser(url, command = browserCommand(process.platform, process.env), settleMs = OPENER_SETTLE_MS) {
|
|
63
66
|
if (command === undefined)
|
|
64
|
-
return false;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
return Promise.resolve(false);
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
let timer;
|
|
70
|
+
const settle = (opened) => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
resolve(opened);
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const child = spawn(command, [url], { detached: true, stdio: "ignore" });
|
|
76
|
+
child.once("spawn", () => {
|
|
77
|
+
child.unref();
|
|
78
|
+
timer = setTimeout(() => settle(true), settleMs);
|
|
79
|
+
});
|
|
80
|
+
child.once("error", () => settle(false));
|
|
81
|
+
child.once("exit", (code) => settle(code === 0));
|
|
82
|
+
} catch {
|
|
83
|
+
settle(false);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
73
86
|
}
|
|
74
87
|
function hostOsLabel() {
|
|
75
88
|
const os = platform();
|
|
@@ -78,6 +91,29 @@ function hostOsLabel() {
|
|
|
78
91
|
}
|
|
79
92
|
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
80
93
|
var errorCode = (body) => isRecord(body) && typeof body.error === "string" ? body.error : undefined;
|
|
94
|
+
var escapeHtml = (text) => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
95
|
+
function answerPage(response, status, text) {
|
|
96
|
+
response.writeHead(status, {
|
|
97
|
+
"content-type": "text/html; charset=utf-8",
|
|
98
|
+
"cache-control": "no-store",
|
|
99
|
+
"referrer-policy": "no-referrer",
|
|
100
|
+
"content-security-policy": "default-src 'none'",
|
|
101
|
+
connection: "close"
|
|
102
|
+
});
|
|
103
|
+
response.end(`<!doctype html><meta charset="utf-8"><title>PiNomad</title><p>${escapeHtml(text)}</p>
|
|
104
|
+
`);
|
|
105
|
+
}
|
|
106
|
+
function pkcePair() {
|
|
107
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
108
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
109
|
+
}
|
|
110
|
+
var codeStart = (body) => isRecord(body) && typeof body.deviceCode === "string" && typeof body.userCode === "string" && typeof body.verifyUrl === "string" && typeof body.interval === "number" && typeof body.expiresAt === "number" && isSecureServerOrigin(body.verifyUrl) ? {
|
|
111
|
+
deviceCode: body.deviceCode,
|
|
112
|
+
userCode: body.userCode,
|
|
113
|
+
verifyUrl: body.verifyUrl,
|
|
114
|
+
expiresAt: body.expiresAt,
|
|
115
|
+
interval: body.interval
|
|
116
|
+
} : undefined;
|
|
81
117
|
async function requestJoinToken(args, deps) {
|
|
82
118
|
const fetchImpl = deps?.fetch ?? fetch;
|
|
83
119
|
const sleep = deps?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
@@ -103,13 +139,13 @@ async function requestJoinToken(args, deps) {
|
|
|
103
139
|
signal: controller.signal
|
|
104
140
|
});
|
|
105
141
|
const text = await response.text();
|
|
106
|
-
let
|
|
142
|
+
let body;
|
|
107
143
|
try {
|
|
108
|
-
|
|
144
|
+
body = JSON.parse(text);
|
|
109
145
|
} catch {
|
|
110
|
-
|
|
146
|
+
body = undefined;
|
|
111
147
|
}
|
|
112
|
-
return { status: response.status, body
|
|
148
|
+
return { status: response.status, body };
|
|
113
149
|
})().catch(() => {
|
|
114
150
|
return;
|
|
115
151
|
});
|
|
@@ -119,41 +155,144 @@ async function requestJoinToken(args, deps) {
|
|
|
119
155
|
clearTimeout(timer);
|
|
120
156
|
}
|
|
121
157
|
};
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
158
|
+
const startBody = { hostId: args.hostId, name: args.name, os: args.os ?? hostOsLabel() };
|
|
159
|
+
const openBrowser = async (url) => Promise.resolve((deps?.openBrowser ?? openInBrowser)(url)).catch(() => false);
|
|
160
|
+
const start = async (payload) => {
|
|
161
|
+
const started2 = await post("/api/host-link/start", payload);
|
|
162
|
+
if (started2 === undefined)
|
|
163
|
+
throw new Error(`cannot reach ${origin} — check the address and that the server is up`);
|
|
164
|
+
if (started2.status === 200)
|
|
165
|
+
return started2.body;
|
|
166
|
+
const code = errorCode(started2.body);
|
|
167
|
+
if (started2.status === 404) {
|
|
129
168
|
throw new Error(`${origin} can't confirm hosts in the browser yet — create a join token under "Add a host" and run the join command with it`);
|
|
130
169
|
}
|
|
131
170
|
if (code === "rate_limited")
|
|
132
171
|
throw new Error("too many join requests from this address — wait a few minutes and try again");
|
|
133
172
|
if (code === "busy")
|
|
134
173
|
throw new Error(`${origin} has too many hosts waiting to join right now — try again in a few minutes`);
|
|
135
|
-
throw new Error(`${origin} refused to start the join: ${code ?? `HTTP ${
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
174
|
+
throw new Error(`${origin} refused to start the join: ${code ?? `HTTP ${started2.status}`}`);
|
|
175
|
+
};
|
|
176
|
+
const unexpected = () => new Error(`${origin} answered the join request with something unexpected`);
|
|
177
|
+
const viaLoopback = async () => {
|
|
178
|
+
const { verifier, challenge } = pkcePair();
|
|
179
|
+
const state = randomBytes(16).toString("base64url");
|
|
180
|
+
let settle;
|
|
181
|
+
const callback = new Promise((resolve) => {
|
|
182
|
+
settle = resolve;
|
|
183
|
+
});
|
|
184
|
+
const listener = createServer((request, response) => {
|
|
185
|
+
const url = URL.parse(request.url ?? "/", "http://127.0.0.1");
|
|
186
|
+
if (url === null) {
|
|
187
|
+
answerPage(response, 400, "Bad request.");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (request.method !== "GET" || url.pathname !== "/callback" || url.searchParams.get("state") !== state) {
|
|
191
|
+
answerPage(response, 404, "Not found.");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (settle === undefined) {
|
|
195
|
+
answerPage(response, 409, "This join was already answered. You can close this page.");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const deliver = settle;
|
|
199
|
+
settle = undefined;
|
|
200
|
+
const closed = new Promise((resolve) => response.once("close", () => resolve()));
|
|
201
|
+
deliver({
|
|
202
|
+
params: url.searchParams,
|
|
203
|
+
reply: async (status, text) => {
|
|
204
|
+
answerPage(response, status, text);
|
|
205
|
+
await closed;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
await new Promise((resolve, reject) => {
|
|
210
|
+
listener.once("error", reject);
|
|
211
|
+
listener.listen(0, "127.0.0.1", () => resolve());
|
|
212
|
+
});
|
|
213
|
+
try {
|
|
214
|
+
const { port } = listener.address();
|
|
215
|
+
const body = await start({
|
|
216
|
+
...startBody,
|
|
217
|
+
redirectUri: `http://127.0.0.1:${port}/callback`,
|
|
218
|
+
codeChallenge: challenge,
|
|
219
|
+
state
|
|
220
|
+
});
|
|
221
|
+
if (!isRecord(body) || typeof body.requestId !== "string") {
|
|
222
|
+
const fallback = codeStart(body);
|
|
223
|
+
if (fallback === undefined)
|
|
224
|
+
throw unexpected();
|
|
225
|
+
return { fallback };
|
|
226
|
+
}
|
|
227
|
+
if (typeof body.verifyUrl !== "string" || typeof body.expiresAt !== "number" || !isSecureServerOrigin(body.verifyUrl)) {
|
|
228
|
+
throw unexpected();
|
|
229
|
+
}
|
|
230
|
+
const { verifyUrl: verifyUrl2, expiresAt: expiresAt2 } = body;
|
|
231
|
+
if (!await openBrowser(verifyUrl2)) {
|
|
232
|
+
browserOpens = false;
|
|
233
|
+
return { fallback: undefined };
|
|
234
|
+
}
|
|
235
|
+
const minutes2 = Math.max(1, Math.round((expiresAt2 - now()) / 60000));
|
|
236
|
+
log(`Opened ${verifyUrl2} in your browser to add this host to your account on ${origin}.`);
|
|
237
|
+
log(`Sign in there and confirm it on this computer — only if you started this on ${args.name} (host id ${args.hostId}).`);
|
|
238
|
+
log(`To confirm from another device instead, press Ctrl-C and run the command again with --code.`);
|
|
239
|
+
log(`Waiting for confirmation (the request expires in ${minutes2} min)…`);
|
|
240
|
+
const wait = deps?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms).unref()));
|
|
241
|
+
const answer = await Promise.race([
|
|
242
|
+
callback,
|
|
243
|
+
wait(Math.max(0, expiresAt2 + EXPIRY_GRACE_MS - now())).then(() => {
|
|
244
|
+
return;
|
|
245
|
+
})
|
|
246
|
+
]);
|
|
247
|
+
if (answer === undefined)
|
|
248
|
+
throw new Error("the request expired before anyone confirmed it — run the command again");
|
|
249
|
+
const { params, reply } = answer;
|
|
250
|
+
if (params.get("error") === "access_denied") {
|
|
251
|
+
await reply(200, `Denied: ${args.name} was not added. You can close this page.`);
|
|
252
|
+
throw new Error(`the join was denied on ${origin}`);
|
|
253
|
+
}
|
|
254
|
+
const code = params.get("code");
|
|
255
|
+
const exchanged = code === null ? undefined : await post("/api/host-link/token", { code, codeVerifier: verifier });
|
|
256
|
+
const token = exchanged?.status === 200 && isRecord(exchanged.body) ? exchanged.body.token : undefined;
|
|
257
|
+
if (typeof token !== "string") {
|
|
258
|
+
await reply(502, "The join did not finish. Go back to the terminal and run the command again.");
|
|
259
|
+
throw new Error(`${origin} did not hand over the join token: ${errorCode(exchanged?.body) ?? (exchanged === undefined ? "no answer" : `HTTP ${exchanged.status}`)} — run the command again`);
|
|
260
|
+
}
|
|
261
|
+
await reply(200, `Confirmed: ${args.name} is joining your account. You can close this page.`);
|
|
262
|
+
log("Confirmed.");
|
|
263
|
+
return { token };
|
|
264
|
+
} finally {
|
|
265
|
+
listener.close();
|
|
266
|
+
listener.closeAllConnections();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
let browserOpens = args.code !== true;
|
|
270
|
+
let started;
|
|
271
|
+
if (args.code !== true && (deps?.loopback ?? browserCommand(process.platform, process.env) !== undefined)) {
|
|
272
|
+
const loopback = await viaLoopback();
|
|
273
|
+
if ("token" in loopback)
|
|
274
|
+
return loopback.token;
|
|
275
|
+
started = loopback.fallback;
|
|
139
276
|
}
|
|
140
|
-
|
|
141
|
-
|
|
277
|
+
started ??= codeStart(await start(startBody));
|
|
278
|
+
if (started === undefined)
|
|
279
|
+
throw unexpected();
|
|
280
|
+
const { deviceCode, userCode, verifyUrl, expiresAt } = started;
|
|
281
|
+
let intervalMs = Math.max(1, started.interval) * 1000;
|
|
142
282
|
const minutes = Math.max(1, Math.round((expiresAt - now()) / 60000));
|
|
143
283
|
log(`To add this host to your account on ${origin}:`);
|
|
144
284
|
log(` 1. Open ${verifyUrl} and sign in.`);
|
|
145
285
|
log(` 2. Enter this code: ${userCode}`);
|
|
146
286
|
log("");
|
|
147
287
|
log(`Only confirm it if you started this on ${args.name} (host id ${args.hostId}).`);
|
|
148
|
-
const opened = await
|
|
288
|
+
const opened = browserOpens && await openBrowser(verifyUrl);
|
|
149
289
|
log(`${opened ? "Opened the page in your browser. " : ""}Waiting for confirmation (the code expires in ${minutes} min)…`);
|
|
150
290
|
for (;; ) {
|
|
151
291
|
await sleep(intervalMs);
|
|
152
292
|
const response = await post("/api/host-link/poll", { deviceCode });
|
|
153
293
|
if (response === undefined || response.status >= 500) {
|
|
154
|
-
if (now() > expiresAt + EXPIRY_GRACE_MS)
|
|
155
|
-
throw new Error(
|
|
156
|
-
}
|
|
294
|
+
if (now() > expiresAt + EXPIRY_GRACE_MS)
|
|
295
|
+
throw new Error(CODE_EXPIRED);
|
|
157
296
|
continue;
|
|
158
297
|
}
|
|
159
298
|
const answer = response.body;
|
|
@@ -165,7 +304,7 @@ async function requestJoinToken(args, deps) {
|
|
|
165
304
|
if (code === "access_denied")
|
|
166
305
|
throw new Error(`the join was denied on ${origin}`);
|
|
167
306
|
if (code === "expired_token")
|
|
168
|
-
throw new Error(
|
|
307
|
+
throw new Error(CODE_EXPIRED);
|
|
169
308
|
if (code === "slow_down") {
|
|
170
309
|
const asked = isRecord(answer) && typeof answer.interval === "number" ? answer.interval * 1000 : 0;
|
|
171
310
|
intervalMs = Math.max(intervalMs + SLOW_DOWN_STEP_MS, asked);
|
|
@@ -176,18 +315,30 @@ async function requestJoinToken(args, deps) {
|
|
|
176
315
|
}
|
|
177
316
|
|
|
178
317
|
// apps/host/src/cli/service-slot.ts
|
|
318
|
+
import { existsSync } from "node:fs";
|
|
179
319
|
import { homedir } from "node:os";
|
|
180
320
|
import { resolve } from "node:path";
|
|
181
321
|
async function openServiceSlot(dataDir, manager) {
|
|
182
|
-
const state = await manager.state().catch(() => ({ installed: false, running: false, detail: "unknown" }));
|
|
322
|
+
const state = await manager.state().catch(() => ({ installed: false, stopped: false, running: false, detail: "unknown" }));
|
|
183
323
|
const previous = state.installed ? await manager.read() : undefined;
|
|
184
324
|
if (previous !== undefined && resolve(previous.dataDir) !== dataDir) {
|
|
185
325
|
throw new Error(`the installed service uses a different data dir (${previous.dataDir}); ` + `run this again with --data-dir ${previous.dataDir} or uninstall the service first`);
|
|
186
326
|
}
|
|
187
327
|
return { dataDir, manager, state, previous };
|
|
188
328
|
}
|
|
329
|
+
function launcherFor(given, previous) {
|
|
330
|
+
if (given !== undefined)
|
|
331
|
+
return given;
|
|
332
|
+
const installed = previous?.launcher;
|
|
333
|
+
return installed !== undefined && existsSync(installed) ? installed : undefined;
|
|
334
|
+
}
|
|
189
335
|
async function writeService(slot, edit, options) {
|
|
190
336
|
const { dataDir, manager, previous } = slot;
|
|
337
|
+
const launcher = launcherFor(options.launcher, previous);
|
|
338
|
+
const withLauncher = (spec2) => {
|
|
339
|
+
const { launcher: _, ...rest } = spec2;
|
|
340
|
+
return launcher === undefined ? rest : { ...rest, launcher };
|
|
341
|
+
};
|
|
191
342
|
const fresh = installSpec(["--data-dir", dataDir, ...edit([])], {
|
|
192
343
|
execPath: process.execPath,
|
|
193
344
|
env: process.env,
|
|
@@ -197,11 +348,11 @@ async function writeService(slot, edit, options) {
|
|
|
197
348
|
entryArgs: hostEntry.args
|
|
198
349
|
});
|
|
199
350
|
if (!slot.state.installed) {
|
|
200
|
-
await manager.install(fresh);
|
|
351
|
+
await manager.install(withLauncher(fresh));
|
|
201
352
|
return "installed";
|
|
202
353
|
}
|
|
203
|
-
const spec = previous === undefined ? fresh : { ...previous, hostArgs: edit(previous.hostArgs) };
|
|
204
|
-
const same = previous !== undefined && spec.hostArgs.join("\x00") === previous.hostArgs.join("\x00");
|
|
354
|
+
const spec = withLauncher(previous === undefined ? fresh : { ...previous, hostArgs: edit(previous.hostArgs) });
|
|
355
|
+
const same = previous !== undefined && spec.hostArgs.join("\x00") === previous.hostArgs.join("\x00") && spec.launcher === previous.launcher;
|
|
205
356
|
if (same && !options.changed && slot.state.running)
|
|
206
357
|
return "unchanged";
|
|
207
358
|
const port = argValue(spec.hostArgs, "--port");
|
|
@@ -249,7 +400,7 @@ async function joinServer(args, deps) {
|
|
|
249
400
|
const current = settings.value;
|
|
250
401
|
const storedName = current.custom.includes("hostName") ? current.hostName : undefined;
|
|
251
402
|
const name = normalizeHostName(args.name ?? storedName ?? deps?.hostname ?? hostname());
|
|
252
|
-
const joinToken = args.token ?? await requestJoinToken({ origin, hostId, name }, deps);
|
|
403
|
+
const joinToken = args.token ?? await requestJoinToken({ origin, hostId, name, code: args.code }, deps);
|
|
253
404
|
const reply = await runServerOperation({
|
|
254
405
|
origin,
|
|
255
406
|
signingKey,
|
|
@@ -270,10 +421,13 @@ async function joinServer(args, deps) {
|
|
|
270
421
|
if (args.name !== undefined)
|
|
271
422
|
await settings.update({ hostName: args.name });
|
|
272
423
|
const edit = args.editHostArgs ?? ((hostArgs) => hostArgs);
|
|
273
|
-
service = await writeService(slot, (hostArgs) => edit(withoutRelayArgs(hostArgs)), {
|
|
424
|
+
service = await writeService(slot, (hostArgs) => edit(withoutRelayArgs(hostArgs)), {
|
|
425
|
+
changed: true,
|
|
426
|
+
launcher: args.launcher
|
|
427
|
+
});
|
|
274
428
|
} catch (error) {
|
|
275
429
|
throw new Error(`joined ${username} at ${origin}, but local setup failed: ${message(error)}
|
|
276
|
-
` + (args.token === undefined ? `run the command again and confirm
|
|
430
|
+
` + (args.token === undefined ? `run the command again and confirm it again (re-joining the same host is safe)` : `generate a fresh join token and run the command again (re-joining the same host is safe)`));
|
|
277
431
|
}
|
|
278
432
|
return { hostId, username, origin, service };
|
|
279
433
|
}
|
|
@@ -281,18 +435,20 @@ async function main(argv, deps) {
|
|
|
281
435
|
const { values, positionals } = parseArgs({
|
|
282
436
|
args: [...argv],
|
|
283
437
|
allowPositionals: true,
|
|
284
|
-
options: { "data-dir": { type: "string" }, name: { type: "string" } }
|
|
438
|
+
options: { "data-dir": { type: "string" }, name: { type: "string" }, code: { type: "boolean" } }
|
|
285
439
|
});
|
|
286
440
|
const [origin, token] = positionals;
|
|
287
441
|
if (origin === undefined || positionals.length > 2) {
|
|
288
|
-
console.error(`usage: ${commandHint("join")} <server-origin> [join-token] [--data-dir DIR] [--name NAME]`);
|
|
289
|
-
console.error(" without a join token, confirm
|
|
442
|
+
console.error(`usage: ${commandHint("join")} <server-origin> [join-token] [--code] [--data-dir DIR] [--name NAME]`);
|
|
443
|
+
console.error(" without a join token, confirm this host in the browser where you're signed in;");
|
|
444
|
+
console.error(" --code prints a code to confirm instead, from this or another device");
|
|
290
445
|
process.exit(1);
|
|
291
446
|
}
|
|
292
447
|
try {
|
|
293
448
|
const outcome = await joinServer({
|
|
294
449
|
origin,
|
|
295
450
|
...token === undefined ? {} : { token },
|
|
451
|
+
...values.code === true ? { code: true } : {},
|
|
296
452
|
dataDir: values["data-dir"] ?? DEFAULT_DATA_DIR,
|
|
297
453
|
...values.name === undefined ? {} : { name: values.name }
|
|
298
454
|
}, deps);
|
|
@@ -15,6 +15,7 @@ var RELAY_CLOSE = {
|
|
|
15
15
|
handshakeTimeout: 4608,
|
|
16
16
|
replaced: 4609,
|
|
17
17
|
acceptTimeout: 4610,
|
|
18
|
+
hostRemoved: 4611,
|
|
18
19
|
tooManyConnections: 4629,
|
|
19
20
|
trafficLimit: 4630
|
|
20
21
|
};
|
|
@@ -467,6 +468,14 @@ function startRelayLink(options) {
|
|
|
467
468
|
}
|
|
468
469
|
if (stopped)
|
|
469
470
|
return;
|
|
471
|
+
if (code === RELAY_CLOSE.hostRemoved) {
|
|
472
|
+
stopped = true;
|
|
473
|
+
setState({
|
|
474
|
+
status: "removed",
|
|
475
|
+
reason: `relay ${origin}: the account removed this host (${hostId}); join it again with \`pinomad up\` (${code})`
|
|
476
|
+
});
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
470
479
|
const rejection = REJECTION_REASONS[code];
|
|
471
480
|
if (rejection !== undefined) {
|
|
472
481
|
const reason = `relay ${origin}: ${rejection(hostId)} (${code})`;
|
|
@@ -494,6 +503,8 @@ function startRelayLink(options) {
|
|
|
494
503
|
origin,
|
|
495
504
|
state: () => state,
|
|
496
505
|
sendPush(request) {
|
|
506
|
+
if (state.status === "removed")
|
|
507
|
+
return Promise.resolve({ result: "unavailable", reason: "the account removed this host" });
|
|
497
508
|
const socket = control;
|
|
498
509
|
if (stopped || socket === undefined || socket.readyState !== WebSocket.OPEN || state.status !== "registered") {
|
|
499
510
|
return Promise.resolve({ result: "retry", reason: "control disconnected" });
|