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.
Files changed (103) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-wb10efyq.js → chunk-445cevpm.js} +1 -1
  3. package/dist/{chunk-jt34g8st.js → chunk-4fferbxw.js} +247 -27
  4. package/dist/{chunk-67gq282t.js → chunk-53xv3fny.js} +725 -75
  5. package/dist/{chunk-44pnts98.js → chunk-563fnad9.js} +1 -1
  6. package/dist/{chunk-b457q0tm.js → chunk-6th9xv39.js} +11 -8
  7. package/dist/{chunk-ncbqa8jk.js → chunk-7mn38n4f.js} +1 -1
  8. package/dist/{chunk-as6pyf00.js → chunk-90g1cg9n.js} +1 -1
  9. package/dist/{chunk-9h5vawnm.js → chunk-aapsc9g0.js} +270 -66
  10. package/dist/{chunk-n1m1tjrk.js → chunk-d4tq62yr.js} +57 -16
  11. package/dist/{chunk-gtdpjqa3.js → chunk-d9mq5d46.js} +205 -49
  12. package/dist/{chunk-y127fe1a.js → chunk-g3ahp7b9.js} +11 -0
  13. package/dist/{chunk-xqycs43g.js → chunk-kmzzjv53.js} +83 -8
  14. package/dist/{chunk-4dcs8sbj.js → chunk-m51mq8vh.js} +43 -21
  15. package/dist/{chunk-tj57aqav.js → chunk-maqv34cs.js} +2 -2
  16. package/dist/chunk-nq57fdnq.js +106 -0
  17. package/dist/{chunk-h5q2y57m.js → chunk-ra86wjp5.js} +10 -4
  18. package/dist/{chunk-sd5fkxjj.js → chunk-s0h7c7da.js} +1 -1
  19. package/dist/chunk-sd640dt7.js +22 -0
  20. package/dist/{chunk-6d16c2fv.js → chunk-t6fw0wa9.js} +13 -6
  21. package/dist/{chunk-vvj9b1gc.js → chunk-wd7aracm.js} +188 -54
  22. package/dist/pinomad.js +15 -14
  23. package/package.json +13 -11
  24. package/web/assets/astro-DOly5024.js +1 -0
  25. package/web/assets/c-BIGW1oBm.js +1 -0
  26. package/web/assets/core-BHm2gdHL.js +4 -0
  27. package/web/assets/cpp-DddhVWhK.js +1 -0
  28. package/web/assets/csharp-B_TYUkTI.js +1 -0
  29. package/web/assets/css-Z72CF80r.js +1 -0
  30. package/web/assets/dart-CtKP3W-j.js +1 -0
  31. package/web/assets/diff-D97Zzqfu.js +1 -0
  32. package/web/assets/dockerfile-BcOcwvcX.js +1 -0
  33. package/web/assets/elixir-DkJLSxtU.js +1 -0
  34. package/web/assets/engine-javascript-BHPwbUfN.js +141 -0
  35. package/web/assets/engine-oniguruma-CQjQPVHy.js +1 -0
  36. package/web/assets/fish-BvzEVeQv.js +1 -0
  37. package/web/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  38. package/web/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  39. package/web/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  40. package/web/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  41. package/web/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  42. package/web/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  43. package/web/assets/go-C27-OAKa.js +1 -0
  44. package/web/assets/graphql-ChdNCCLP.js +1 -0
  45. package/web/assets/haskell-Df6bDoY_.js +1 -0
  46. package/web/assets/hcl-BWvSN4gD.js +1 -0
  47. package/web/assets/highlighted-diff-DrHvSLK9.js +175 -0
  48. package/web/assets/highlights-1aLwssz-.js +4 -0
  49. package/web/assets/highlights-themes-C1p4pFYS.js +1 -0
  50. package/web/assets/html-BlS82ilI.js +1 -0
  51. package/web/assets/index-A_AUJoO6.css +1 -0
  52. package/web/assets/index-BafYMKiN.js +396 -0
  53. package/web/assets/ini-BEwlwnbL.js +1 -0
  54. package/web/assets/java-CylS5w8V.js +1 -0
  55. package/web/assets/javascript-wDzz0qaB.js +1 -0
  56. package/web/assets/json-Cp-IABpG.js +1 -0
  57. package/web/assets/jsonc-Des-eS-w.js +1 -0
  58. package/web/assets/jsx-g9-lgVsj.js +1 -0
  59. package/web/assets/kotlin-BdnUsdx6.js +1 -0
  60. package/web/assets/less-B1dDrJ26.js +1 -0
  61. package/web/assets/lua-BaeVxFsk.js +1 -0
  62. package/web/assets/make-CHLpvVh8.js +1 -0
  63. package/web/assets/markdown-Cvjx9yec.js +1 -0
  64. package/web/assets/nix-CwoSXNpI.js +1 -0
  65. package/web/assets/objective-c-DXmwc3jG.js +1 -0
  66. package/web/assets/php-DoruYMF0.js +1 -0
  67. package/web/assets/pierre-dark-CyvmCCZW.js +1 -0
  68. package/web/assets/pierre-dark-VKo95ZgA.js +1 -0
  69. package/web/assets/pierre-light-480U9XYS.js +1 -0
  70. package/web/assets/pierre-light-BVSW-Xev.js +1 -0
  71. package/web/assets/postcss-DnzrXTVv.js +1 -0
  72. package/web/assets/powershell-Dd0kTxKP.js +1 -0
  73. package/web/assets/prisma-Vru482bI.js +1 -0
  74. package/web/assets/proto-C7zT0LnQ.js +1 -0
  75. package/web/assets/python-B6aJPvgy.js +1 -0
  76. package/web/assets/ruby-B43yHlf9.js +1 -0
  77. package/web/assets/rust-DPvrY3IQ.js +1 -0
  78. package/web/assets/scala-CqE71os6.js +1 -0
  79. package/web/assets/scss-Cw6XMfVs.js +1 -0
  80. package/web/assets/shellscript-Yzrsuije.js +1 -0
  81. package/web/assets/shiki-BwyCm3kZ.js +2 -0
  82. package/web/assets/shiki-langs-CMlALLSg.js +2 -0
  83. package/web/assets/shiki-wasm-CgFRq91U.js +1 -0
  84. package/web/assets/sql-DkvoVjww.js +1 -0
  85. package/web/assets/stream-tokenizer-BlgIhw0y.js +4 -0
  86. package/web/assets/svelte-DoIXUgJc.js +1 -0
  87. package/web/assets/swift-C2oV4EkX.js +1 -0
  88. package/web/assets/terraform-BETggiCN.js +1 -0
  89. package/web/assets/theming-themes-DDvoqEqT.js +1 -0
  90. package/web/assets/toml-vGWfd6FD.js +1 -0
  91. package/web/assets/tsx-COt5Ahok.js +1 -0
  92. package/web/assets/typescript-BPQ3VLAy.js +1 -0
  93. package/web/assets/vue-CwSeJimo.js +1 -0
  94. package/web/assets/xml-sdJ4AIDG.js +1 -0
  95. package/web/assets/xterm-view-6GBZ9nXN.css +32 -0
  96. package/web/assets/xterm-view-BnKOnF3C.js +38 -0
  97. package/web/assets/yaml-Buea-lGh.js +1 -0
  98. package/web/assets/zig-VOosw3JB.js +1 -0
  99. package/web/index.html +2 -2
  100. package/dist/chunk-etf7ef97.js +0 -55
  101. package/dist/chunk-greb3dp6.js +0 -22
  102. package/web/assets/index-BhHrKpoo.js +0 -395
  103. 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-gtdpjqa3.js";
7
- import"./chunk-ncbqa8jk.js";
8
- import"./chunk-44pnts98.js";
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-etf7ef97.js";
14
- import"./chunk-y127fe1a.js";
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-vvj9b1gc.js";
21
- import"./chunk-jt34g8st.js";
23
+ } from "./chunk-wd7aracm.js";
24
+ import"./chunk-4fferbxw.js";
22
25
  import"./chunk-kzbzacz9.js";
23
- import"./chunk-xqycs43g.js";
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-sd5fkxjj.js";
33
+ } from "./chunk-s0h7c7da.js";
31
34
  import"./chunk-df32avw6.js";
32
35
 
33
36
  // apps/host/src/cli/up.ts
34
- import { resolve } from "node:path";
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({ origin, dataDir, editHostArgs: lan }, { ...deps, manager });
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 origin = normalizeOrigin(values.origin ?? (await readServerConfig(dataDir))?.origin ?? OFFICIAL_SERVER_ORIGIN);
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-ncbqa8jk.js";
6
+ } from "./chunk-7mn38n4f.js";
7
7
  import {
8
8
  constantValue,
9
9
  openHostSettings
10
- } from "./chunk-44pnts98.js";
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-etf7ef97.js";
17
+ } from "./chunk-nq57fdnq.js";
18
18
  import {
19
19
  relayHostId
20
- } from "./chunk-y127fe1a.js";
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-vvj9b1gc.js";
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-sd5fkxjj.js";
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
- function openInBrowser(url) {
62
- const command = browserCommand(process.platform, process.env);
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
- try {
66
- const child = spawn(command, [url], { detached: true, stdio: "ignore" });
67
- child.on("error", () => {});
68
- child.unref();
69
- return true;
70
- } catch {
71
- return false;
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 body2;
142
+ let body;
107
143
  try {
108
- body2 = JSON.parse(text);
144
+ body = JSON.parse(text);
109
145
  } catch {
110
- body2 = undefined;
146
+ body = undefined;
111
147
  }
112
- return { status: response.status, body: body2 };
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 started = await post("/api/host-link/start", { hostId: args.hostId, name: args.name, os: args.os ?? hostOsLabel() });
123
- if (started === undefined)
124
- throw new Error(`cannot reach ${origin} — check the address and that the server is up`);
125
- const { body } = started;
126
- if (started.status !== 200) {
127
- const code = errorCode(body);
128
- if (started.status === 404) {
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 ${started.status}`}`);
136
- }
137
- if (!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)) {
138
- throw new Error(`${origin} answered the join request with something unexpected`);
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
- const { deviceCode, userCode, verifyUrl, expiresAt } = body;
141
- let intervalMs = Math.max(1, body.interval) * 1000;
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 Promise.resolve((deps?.openBrowser ?? openInBrowser)(verifyUrl)).catch(() => false);
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(`the code expired before anyone confirmed it — run the command again for a new one`);
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(`the code expired before anyone confirmed it — run the command again for a new one`);
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)), { changed: true });
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 the new code (re-joining the same host is safe)` : `generate a fresh join token and run the command again (re-joining the same host is safe)`));
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 the printed code in the browser where you're signed in");
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" });