@join-pantheon/cli 0.28.0 → 0.29.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.
@@ -1,9 +1,12 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { basename } from "node:path";
4
- import { homedir } from "node:os";
3
+ import { createServer } from "node:net";
4
+ import { basename, join as pathJoin } from "node:path";
5
+ import { homedir, hostname, platform } from "node:os";
6
+ import { lookup } from "node:dns/promises";
7
+ import { describeNetError } from "./network.js";
5
8
  import { PantheonClient } from "./client.js";
6
- import { load, save, toInvite, resolveFor, repoInfo, bindRepo, DEFAULT_RELAY } from "./config.js";
9
+ import { load, identity, save, toInvite, resolveFor, repoInfo, bindRepo, DEFAULT_RELAY } from "./config.js";
7
10
  import { loadProfile, wakeDefault } from "./profile.js";
8
11
  import { newKeypair } from "./identity.js";
9
12
  import { installForUser } from "./install.js";
@@ -13,7 +16,8 @@ import { installService, servicesUnavailable } from "./service.js";
13
16
  import { ensureNativeNotifier } from "./notifier.js";
14
17
  import { ensureTrayBinary, writeTrayCommands } from "./tray.js";
15
18
  import { desktopUnavailable, ephemeralWarning, hostEnv } from "./host-env.js";
16
- import { existsSync } from "node:fs";
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { localAddresses } from "./machine.js";
17
21
  import { grant as grantCap, loadPolicy, policyPath, savePolicy } from "./worker-policy.js";
18
22
  import { Relay } from "./relay.js";
19
23
  import { which } from "./which.js";
@@ -126,7 +130,136 @@ export function wireUp(opts, deps = {}) {
126
130
  }
127
131
  return { tools, granted, warnings };
128
132
  }
129
- export function findReach(port) {
133
+ const TRANSLIT = {
134
+ а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "yo", ж: "zh", з: "z", и: "i", й: "y", к: "k", л: "l", м: "m",
135
+ н: "n", о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "kh", ц: "ts", ч: "ch", ш: "sh", щ: "shch",
136
+ ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya", є: "ye", і: "i", ї: "yi", ґ: "g", ў: "u",
137
+ α: "a", β: "v", γ: "g", δ: "d", ε: "e", ζ: "z", η: "i", θ: "th", ι: "i", κ: "k", λ: "l", μ: "m", ν: "n", ξ: "x",
138
+ ο: "o", π: "p", ρ: "r", σ: "s", ς: "s", τ: "t", υ: "y", φ: "f", χ: "ch", ψ: "ps", ω: "o",
139
+ ß: "ss", æ: "ae", ø: "o", đ: "d", ł: "l", þ: "th", œ: "oe",
140
+ };
141
+ export function slugName(raw, fallback) {
142
+ const s = raw.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "")
143
+ .replace(/./gu, (ch) => TRANSLIT[ch] ?? ch)
144
+ .replace(/[^a-z0-9._-]+/g, "-")
145
+ .replace(/-{2,}/g, "-")
146
+ .replace(/^[-._]+|[-._]+$/g, "");
147
+ return /[a-z0-9]/.test(s) ? s : fallback;
148
+ }
149
+ export function teamNameFor(dir, repo = repoInfo(dir)) {
150
+ const fromOrigin = repo?.origin ? (repo.origin.replace(/\.git$/i, "").split(/[/:]/).filter(Boolean).pop() ?? "") : "";
151
+ return slugName(fromOrigin || basename(repo?.root ?? dir), "team");
152
+ }
153
+ export function isLocalRelay(url) {
154
+ let host = "";
155
+ try {
156
+ host = new URL(url.replace(/^ws/i, "http")).hostname.replace(/^\[|\]$/g, "").toLowerCase();
157
+ }
158
+ catch {
159
+ return false;
160
+ }
161
+ return host === "localhost" || host === "::1" || host === "0.0.0.0" || host === "::" || /^127\./.test(host);
162
+ }
163
+ const hostOf = (url) => {
164
+ try {
165
+ return new URL(url.replace(/^ws/i, "http")).hostname.replace(/^\[|\]$/g, "").toLowerCase();
166
+ }
167
+ catch {
168
+ return "";
169
+ }
170
+ };
171
+ export async function relayPlace(url, localPort, deps = {}) {
172
+ if (isLocalRelay(url))
173
+ return "loopback";
174
+ const host = hostOf(url);
175
+ const hosted = new Set([hostOf(deps.hostedRelay ?? "wss://relay.joinpantheon.network"), hostOf(DEFAULT_RELAY)].filter(Boolean));
176
+ if (!host || hosted.has(host))
177
+ return "hosted";
178
+ const addrs = new Set((deps.localAddrs ?? localAddresses()).map((a) => a.toLowerCase()));
179
+ const name = (deps.hostname ?? hostname()).toLowerCase();
180
+ if (addrs.has(host) || host === name || host === `${name}.local`)
181
+ return "this-machine";
182
+ const resolved = await (deps.resolve ?? resolveHost)(host);
183
+ if (resolved?.some((a) => addrs.has(a.toLowerCase()) || /^127\./.test(a) || a === "::1"))
184
+ return "this-machine";
185
+ if ((deps.tunnelTo ?? tunnelRunningTo)(localPort) || (deps.hostedBefore ?? (() => false))(url))
186
+ return "tunnel";
187
+ return "remote";
188
+ }
189
+ async function resolveHost(host) {
190
+ try {
191
+ const all = await Promise.race([
192
+ lookup(host, { all: true }).then((r) => r.map((x) => x.address)),
193
+ new Promise((res) => setTimeout(() => res(null), 1500).unref()),
194
+ ]);
195
+ return all;
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ }
201
+ export function tunnelRunningTo(port, ps = () => sh("ps -Ao command= 2>/dev/null")) {
202
+ const out = ps() ?? "";
203
+ const re = new RegExp(`\\b(cloudflared|ngrok|tailscale\\s+(funnel|serve)|bore)\\b.*[:\\s]${port}\\b`);
204
+ return out.split("\n").some((l) => re.test(l));
205
+ }
206
+ const HOSTED_FOR = "hosted-for.json";
207
+ export function hostedBefore(dataDir, url) {
208
+ try {
209
+ return JSON.parse(readFileSync(pathJoin(dataDir, HOSTED_FOR), "utf8")).includes(url.replace(/\/+$/, ""));
210
+ }
211
+ catch {
212
+ return false;
213
+ }
214
+ }
215
+ export function rememberHosted(dataDir, url) {
216
+ try {
217
+ const f = pathJoin(dataDir, HOSTED_FOR);
218
+ let list = [];
219
+ try {
220
+ list = JSON.parse(readFileSync(f, "utf8"));
221
+ }
222
+ catch { }
223
+ const u = url.replace(/\/+$/, "");
224
+ if (list.includes(u))
225
+ return;
226
+ mkdirSync(dataDir, { recursive: true });
227
+ writeFileSync(f, JSON.stringify([...list, u]));
228
+ }
229
+ catch { }
230
+ }
231
+ export async function probeRelay(url, timeoutMs, fetchImpl = fetch) {
232
+ const health = url.replace(/^ws/i, "http").replace(/\/+$/, "") + "/health";
233
+ try {
234
+ const r = await fetchImpl(health, { signal: AbortSignal.timeout(timeoutMs) });
235
+ if (r.ok)
236
+ return { ok: true };
237
+ return { ok: false, error: new Error(`${health} answered HTTP ${r.status}`) };
238
+ }
239
+ catch (e) {
240
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
241
+ return { ok: false, error: Object.assign(new Error(`no answer from ${health} in ${Math.round(timeoutMs / 1000)}s`), { code: "ETIMEDOUT" }) };
242
+ }
243
+ const cause = e?.cause;
244
+ if (cause?.message && /fetch failed/.test(e?.message ?? "")) {
245
+ return { ok: false, error: Object.assign(new Error(`fetch failed: ${cause.message}`), { cause, ...(cause.code ? { code: cause.code } : {}) }) };
246
+ }
247
+ return { ok: false, error: e };
248
+ }
249
+ }
250
+ export function portFree(port) {
251
+ return new Promise((res) => {
252
+ const s = createServer();
253
+ s.once("error", (e) => res(e.code ?? e.message));
254
+ s.listen(port, "0.0.0.0", () => s.close(() => res(true)));
255
+ });
256
+ }
257
+ export function cloudflaredInstall(os = platform()) {
258
+ return os === "darwin" ? "brew install cloudflared"
259
+ : os === "win32" ? "winget install --id Cloudflare.cloudflared"
260
+ : "install cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)";
261
+ }
262
+ export function findReach(port, os = platform()) {
130
263
  const ts = sh("tailscale ip -4 2>/dev/null | head -1");
131
264
  if (ts && /^\d+\.\d+\.\d+\.\d+$/.test(ts)) {
132
265
  return { url: `ws://${ts}:${port}`, how: `tailscale (${ts})`, shareable: true };
@@ -143,33 +276,58 @@ export function findReach(port) {
143
276
  url: `ws://127.0.0.1:${port}`, how: "localhost", shareable: false,
144
277
  hint: `Only reachable on this machine. For a teammate elsewhere, easiest is Tailscale\n` +
145
278
  ` on both machines (then re-run \`pantheon start\`), or a tunnel:\n` +
146
- ` brew install cloudflared && cloudflared tunnel --url http://localhost:${port}`,
279
+ (os === "darwin" || os === "win32"
280
+ ? ` ${cloudflaredInstall(os)} && cloudflared tunnel --url http://localhost:${port}`
281
+ : ` ${cloudflaredInstall(os)}, then: cloudflared tunnel --url http://localhost:${port}`),
147
282
  };
148
283
  }
149
284
  export async function start(opts) {
150
285
  const warnings = [];
151
286
  const here = resolveFor(process.cwd());
152
- const priorCfg = here.cfg;
287
+ const priorCfg = here.cfg ?? identity();
153
288
  const repo = repoInfo(process.cwd());
154
289
  const signedInRelay = priorCfg?.account ? (priorCfg.relay ?? DEFAULT_RELAY) : undefined;
155
290
  const chosen = opts.relay ?? signedInRelay;
291
+ const place = chosen ? await relayPlace(chosen, opts.port, { ...opts.place, hostedBefore: (u) => hostedBefore(opts.dataDir, u) }) : null;
292
+ const hostHere = place === "loopback" || place === "this-machine" || place === "tunnel";
156
293
  const reach = chosen
157
- ? (/127\.0\.0\.1|localhost|\[::1\]/.test(chosen)
294
+ ? (place === "loopback"
158
295
  ? { url: chosen, how: "as given", shareable: false,
159
296
  hint: `That address is this machine only — a teammate cannot reach it.\n` +
160
297
  ` Use a tailnet address (tailscale ip -4) or a tunnel, then re-run pantheon start.` }
161
- : { url: chosen, how: opts.relay ? "as given" : "your signed-in relay", shareable: true })
298
+ : { url: chosen, how: place === "this-machine" ? "this machine's address" : place === "tunnel" ? "a tunnel to this machine" : opts.relay ? "as given" : "your signed-in relay", shareable: true })
162
299
  : findReach(opts.port);
300
+ const nothing = (why) => ({ cfg: priorCfg ?? { relay: reach.url }, invite: null, claimed: false, reach, tools: [], granted: [], relay: null, warnings: [why] });
163
301
  let relay = null;
164
- const health = reach.url.replace(/^ws/, "http") + "/health";
165
- const already = await fetch(health, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok).catch(() => false);
166
- if (already) {
167
- opts.log(` relay already running — nothing to start ${reach.url}`);
302
+ if (chosen && !hostHere) {
303
+ const probe = await probeRelay(reach.url, opts.remoteTimeoutMs ?? 10_000, opts.fetchImpl);
304
+ if (!probe.ok) {
305
+ return nothing(`cannot reach the relay at ${reach.url}: ${describeNetError(probe.error, reach.url)}` +
306
+ (place === "remote" ? `\n If that address is a tunnel to this machine, start the tunnel first (cloudflared tunnel --url http://localhost:${opts.port}) and run this again — Pantheon hosts the relay here when it sees the tunnel.` : ""));
307
+ }
308
+ opts.log(` relay ${reach.url} (${reach.how})`);
168
309
  }
169
310
  else {
170
- relay = new Relay({ port: opts.port, host: "0.0.0.0", dataDir: opts.dataDir, log: () => { } });
171
- await relay.listen();
172
- opts.log(` relay started on ${reach.url} (${reach.how})`);
311
+ const urlPort = chosen && place !== "tunnel" ? Number(new URL(chosen.replace(/^ws/i, "http")).port) : 0;
312
+ const listenPort = urlPort || opts.port;
313
+ const local = `ws://127.0.0.1:${listenPort}`;
314
+ const probe = await probeRelay(place === "tunnel" ? local : reach.url, 1500);
315
+ if (probe.ok) {
316
+ opts.log(` relay already running — nothing to start ${reach.url}`);
317
+ }
318
+ else {
319
+ const free = await portFree(listenPort);
320
+ if (free !== true) {
321
+ return nothing(free === "EADDRINUSE"
322
+ ? `port ${listenPort} is taken by another program — it did not answer as a Pantheon relay (${local}/health). Free it, or pick another port: pantheon start --port <n>`
323
+ : `the relay could not start on port ${listenPort}: ${free}`);
324
+ }
325
+ relay = new Relay({ port: listenPort, host: "0.0.0.0", dataDir: opts.dataDir, log: () => { } });
326
+ await relay.listen();
327
+ opts.log(` relay started on ${reach.url} (${reach.how})`);
328
+ }
329
+ if (chosen && place !== "loopback")
330
+ rememberHosted(opts.dataDir, chosen);
173
331
  }
174
332
  const existing = priorCfg;
175
333
  const hasOwnSpace = !!existing?.space && !!existing?.token;
@@ -180,9 +338,9 @@ export async function start(opts) {
180
338
  ? { ...existing, relay: reach.url, agent: detectAgent() }
181
339
  : {
182
340
  relay: reach.url,
183
- space: opts.space ?? (basename(process.cwd()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "team"),
341
+ space: opts.space ?? teamNameFor(process.cwd(), repo),
184
342
  token: randomBytes(16).toString("hex"),
185
- member: opts.me ?? (keepIdentity ? existing.member : (process.env.USER ?? "me").toLowerCase().replace(/[^a-z0-9._-]/g, "-")),
343
+ member: opts.me ?? (keepIdentity ? existing.member : slugName(process.env.USER ?? "me", "me")),
186
344
  agent: detectAgent(),
187
345
  ...(keepIdentity ? { publicKey: existing.publicKey, privateKey: existing.privateKey } : newKeypair()),
188
346
  ...(keepIdentity && existing.account ? { account: existing.account } : {}),
@@ -220,7 +378,8 @@ export async function start(opts) {
220
378
  warnings.push(`this repo was bound to another team (${replaced.space}); it now belongs to "${c.space}"`);
221
379
  }
222
380
  opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}${repo ? ` ${repo.origin ?? "local repo"} → bound to it` : ""}`);
223
- const inv = await client.makeInvite(wakeDefault(loadProfile(c)), opts.ttlMinutes);
381
+ const mayWake = opts.wake ? opts.wake === "anyone" : wakeDefault(loadProfile(c));
382
+ const inv = await client.makeInvite(mayWake, opts.ttlMinutes);
224
383
  invite = toInvite({ ...c, token: inv.url });
225
384
  claimed = true;
226
385
  }
@@ -265,7 +424,7 @@ export async function start(opts) {
265
424
  return { cfg, invite, claimed, reach, tools: wired.tools, granted: wired.granted, relay, warnings };
266
425
  }
267
426
  export async function join(opts) {
268
- const existing = opts.reset ? null : load();
427
+ const existing = opts.reset ? null : (load() ?? identity());
269
428
  const repo = repoInfo(process.cwd());
270
429
  const keys = existing?.publicKey && existing.privateKey
271
430
  ? { publicKey: existing.publicKey, privateKey: existing.privateKey }
@@ -277,7 +436,7 @@ export async function join(opts) {
277
436
  relay: opts.invite.relay,
278
437
  space: opts.invite.space,
279
438
  token: "",
280
- member: opts.me ?? existing?.member ?? (process.env.USER ?? "me").toLowerCase().replace(/[^a-z0-9._-]/g, "-"),
439
+ member: opts.me ?? existing?.member ?? slugName(process.env.USER ?? "me", "me"),
281
440
  agent: detectAgent(),
282
441
  ...keys,
283
442
  ...(existing?.account ? { account: existing.account } : {}),
@@ -0,0 +1,29 @@
1
+ const NET_CODE = /^(ECONN\w*|ENOTFOUND|ETIMEDOUT|EAI_\w+|EPROTO|EHOSTUNREACH|ENETUNREACH|EPIPE|ECONNRESET|UND_ERR_\w*|ERR_\w+)$/;
2
+ const FIX = {
3
+ NOT_SIGNED_IN: "sign in on this machine: pantheon login --email you@work.com (or pantheon signup), then try again",
4
+ KEY_MISMATCH: "a member of the team runs pantheon admit <you> --forget and sends you a new invite (or pick another name with --me)",
5
+ REVOKED: "ask a member of the team to let you back in: pantheon admit <you>",
6
+ PLAN_LIMIT: "the team's owner frees a seat (pantheon invite --revoke all cancels unused invites) or upgrades the plan",
7
+ BAD_TOKEN: "this machine's copy of the team token is wrong — rejoin with a fresh invite: pantheon join <invite>",
8
+ NO_SUCH_SPACE: "the team does not exist on this relay — check pantheon spaces, or join with a fresh invite",
9
+ SPACE_DELETED: "the team was deleted — remove it here with pantheon leave <team>",
10
+ REPO_MISMATCH: "run this from the team's own repo, or bind this one: pantheon use <team> --here",
11
+ BAD_INVITE: "ask for a fresh invite: pantheon invite",
12
+ };
13
+ export function refusalOf(e) {
14
+ const text = typeof e === "string" ? e : String(e?.message ?? e ?? "");
15
+ const m = /^([A-Z][A-Z0-9_]{2,}): ([\s\S]*)$/.exec(text.trim());
16
+ if (!m || NET_CODE.test(m[1]) || m[1] === "PENDING")
17
+ return null;
18
+ return { code: m[1], message: m[2].trim(), fix: FIX[m[1]] ?? null };
19
+ }
20
+ export function connectFailure(e, relay) {
21
+ const r = refusalOf(e);
22
+ if (r) {
23
+ const fix = r.fix && !/pantheon \w/.test(r.message) ? `\n ${r.fix}` : "";
24
+ return `the relay refused this machine (${r.code}): ${r.message}${fix}`;
25
+ }
26
+ const text = typeof e === "string" ? e : String(e?.message ?? e ?? "");
27
+ return `cannot reach the relay at ${relay} — ${text}`;
28
+ }
29
+ //# sourceMappingURL=refusal.js.map