@join-pantheon/cli 0.28.0 → 0.29.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.
@@ -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";
@@ -98,7 +102,12 @@ export function wireUp(opts, deps = {}) {
98
102
  }
99
103
  else if (opts.unattended) {
100
104
  const project = opts.unattended;
101
- const isRepo = sh(`git -C ${JSON.stringify(project)} rev-parse --is-inside-work-tree 2>/dev/null`) === "true";
105
+ const isRepo = (() => { try {
106
+ return execFileSync("git", ["-C", project, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim() === "true";
107
+ }
108
+ catch {
109
+ return false;
110
+ } })();
102
111
  if (!isRepo && how === "explicit") {
103
112
  tools.push({ tool: "worker", status: `not installed — ${project} is not a git repo` });
104
113
  warnings.push(`--unattended needs a git repo to work in; pass --project <a clone>. Nothing was installed.`);
@@ -126,7 +135,136 @@ export function wireUp(opts, deps = {}) {
126
135
  }
127
136
  return { tools, granted, warnings };
128
137
  }
129
- export function findReach(port) {
138
+ const TRANSLIT = {
139
+ а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "yo", ж: "zh", з: "z", и: "i", й: "y", к: "k", л: "l", м: "m",
140
+ н: "n", о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "kh", ц: "ts", ч: "ch", ш: "sh", щ: "shch",
141
+ ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya", є: "ye", і: "i", ї: "yi", ґ: "g", ў: "u",
142
+ α: "a", β: "v", γ: "g", δ: "d", ε: "e", ζ: "z", η: "i", θ: "th", ι: "i", κ: "k", λ: "l", μ: "m", ν: "n", ξ: "x",
143
+ ο: "o", π: "p", ρ: "r", σ: "s", ς: "s", τ: "t", υ: "y", φ: "f", χ: "ch", ψ: "ps", ω: "o",
144
+ ß: "ss", æ: "ae", ø: "o", đ: "d", ł: "l", þ: "th", œ: "oe",
145
+ };
146
+ export function slugName(raw, fallback) {
147
+ const s = raw.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "")
148
+ .replace(/./gu, (ch) => TRANSLIT[ch] ?? ch)
149
+ .replace(/[^a-z0-9._-]+/g, "-")
150
+ .replace(/-{2,}/g, "-")
151
+ .replace(/^[-._]+|[-._]+$/g, "");
152
+ return /[a-z0-9]/.test(s) ? s : fallback;
153
+ }
154
+ export function teamNameFor(dir, repo = repoInfo(dir)) {
155
+ const fromOrigin = repo?.origin ? (repo.origin.replace(/\.git$/i, "").split(/[/:]/).filter(Boolean).pop() ?? "") : "";
156
+ return slugName(fromOrigin || basename(repo?.root ?? dir), "team");
157
+ }
158
+ export function isLocalRelay(url) {
159
+ let host = "";
160
+ try {
161
+ host = new URL(url.replace(/^ws/i, "http")).hostname.replace(/^\[|\]$/g, "").toLowerCase();
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ return host === "localhost" || host === "::1" || host === "0.0.0.0" || host === "::" || /^127\./.test(host);
167
+ }
168
+ const hostOf = (url) => {
169
+ try {
170
+ return new URL(url.replace(/^ws/i, "http")).hostname.replace(/^\[|\]$/g, "").toLowerCase();
171
+ }
172
+ catch {
173
+ return "";
174
+ }
175
+ };
176
+ export async function relayPlace(url, localPort, deps = {}) {
177
+ if (isLocalRelay(url))
178
+ return "loopback";
179
+ const host = hostOf(url);
180
+ const hosted = new Set([hostOf(deps.hostedRelay ?? "wss://relay.joinpantheon.network"), hostOf(DEFAULT_RELAY)].filter(Boolean));
181
+ if (!host || hosted.has(host))
182
+ return "hosted";
183
+ const addrs = new Set((deps.localAddrs ?? localAddresses()).map((a) => a.toLowerCase()));
184
+ const name = (deps.hostname ?? hostname()).toLowerCase();
185
+ if (addrs.has(host) || host === name || host === `${name}.local`)
186
+ return "this-machine";
187
+ const resolved = await (deps.resolve ?? resolveHost)(host);
188
+ if (resolved?.some((a) => addrs.has(a.toLowerCase()) || /^127\./.test(a) || a === "::1"))
189
+ return "this-machine";
190
+ if ((deps.tunnelTo ?? tunnelRunningTo)(localPort) || (deps.hostedBefore ?? (() => false))(url))
191
+ return "tunnel";
192
+ return "remote";
193
+ }
194
+ async function resolveHost(host) {
195
+ try {
196
+ const all = await Promise.race([
197
+ lookup(host, { all: true }).then((r) => r.map((x) => x.address)),
198
+ new Promise((res) => setTimeout(() => res(null), 1500).unref()),
199
+ ]);
200
+ return all;
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ export function tunnelRunningTo(port, ps = () => sh("ps -Ao command= 2>/dev/null")) {
207
+ const out = ps() ?? "";
208
+ const re = new RegExp(`\\b(cloudflared|ngrok|tailscale\\s+(funnel|serve)|bore)\\b.*[:\\s]${port}\\b`);
209
+ return out.split("\n").some((l) => re.test(l));
210
+ }
211
+ const HOSTED_FOR = "hosted-for.json";
212
+ export function hostedBefore(dataDir, url) {
213
+ try {
214
+ return JSON.parse(readFileSync(pathJoin(dataDir, HOSTED_FOR), "utf8")).includes(url.replace(/\/+$/, ""));
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
220
+ export function rememberHosted(dataDir, url) {
221
+ try {
222
+ const f = pathJoin(dataDir, HOSTED_FOR);
223
+ let list = [];
224
+ try {
225
+ list = JSON.parse(readFileSync(f, "utf8"));
226
+ }
227
+ catch { }
228
+ const u = url.replace(/\/+$/, "");
229
+ if (list.includes(u))
230
+ return;
231
+ mkdirSync(dataDir, { recursive: true });
232
+ writeFileSync(f, JSON.stringify([...list, u]));
233
+ }
234
+ catch { }
235
+ }
236
+ export async function probeRelay(url, timeoutMs, fetchImpl = fetch) {
237
+ const health = url.replace(/^ws/i, "http").replace(/\/+$/, "") + "/health";
238
+ try {
239
+ const r = await fetchImpl(health, { signal: AbortSignal.timeout(timeoutMs) });
240
+ if (r.ok)
241
+ return { ok: true };
242
+ return { ok: false, error: new Error(`${health} answered HTTP ${r.status}`) };
243
+ }
244
+ catch (e) {
245
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
246
+ return { ok: false, error: Object.assign(new Error(`no answer from ${health} in ${Math.round(timeoutMs / 1000)}s`), { code: "ETIMEDOUT" }) };
247
+ }
248
+ const cause = e?.cause;
249
+ if (cause?.message && /fetch failed/.test(e?.message ?? "")) {
250
+ return { ok: false, error: Object.assign(new Error(`fetch failed: ${cause.message}`), { cause, ...(cause.code ? { code: cause.code } : {}) }) };
251
+ }
252
+ return { ok: false, error: e };
253
+ }
254
+ }
255
+ export function portFree(port) {
256
+ return new Promise((res) => {
257
+ const s = createServer();
258
+ s.once("error", (e) => res(e.code ?? e.message));
259
+ s.listen(port, "0.0.0.0", () => s.close(() => res(true)));
260
+ });
261
+ }
262
+ export function cloudflaredInstall(os = platform()) {
263
+ return os === "darwin" ? "brew install cloudflared"
264
+ : os === "win32" ? "winget install --id Cloudflare.cloudflared"
265
+ : "install cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)";
266
+ }
267
+ export function findReach(port, os = platform()) {
130
268
  const ts = sh("tailscale ip -4 2>/dev/null | head -1");
131
269
  if (ts && /^\d+\.\d+\.\d+\.\d+$/.test(ts)) {
132
270
  return { url: `ws://${ts}:${port}`, how: `tailscale (${ts})`, shareable: true };
@@ -143,33 +281,58 @@ export function findReach(port) {
143
281
  url: `ws://127.0.0.1:${port}`, how: "localhost", shareable: false,
144
282
  hint: `Only reachable on this machine. For a teammate elsewhere, easiest is Tailscale\n` +
145
283
  ` on both machines (then re-run \`pantheon start\`), or a tunnel:\n` +
146
- ` brew install cloudflared && cloudflared tunnel --url http://localhost:${port}`,
284
+ (os === "darwin" || os === "win32"
285
+ ? ` ${cloudflaredInstall(os)} && cloudflared tunnel --url http://localhost:${port}`
286
+ : ` ${cloudflaredInstall(os)}, then: cloudflared tunnel --url http://localhost:${port}`),
147
287
  };
148
288
  }
149
289
  export async function start(opts) {
150
290
  const warnings = [];
151
291
  const here = resolveFor(process.cwd());
152
- const priorCfg = here.cfg;
292
+ const priorCfg = here.cfg ?? identity();
153
293
  const repo = repoInfo(process.cwd());
154
294
  const signedInRelay = priorCfg?.account ? (priorCfg.relay ?? DEFAULT_RELAY) : undefined;
155
295
  const chosen = opts.relay ?? signedInRelay;
296
+ const place = chosen ? await relayPlace(chosen, opts.port, { ...opts.place, hostedBefore: (u) => hostedBefore(opts.dataDir, u) }) : null;
297
+ const hostHere = place === "loopback" || place === "this-machine" || place === "tunnel";
156
298
  const reach = chosen
157
- ? (/127\.0\.0\.1|localhost|\[::1\]/.test(chosen)
299
+ ? (place === "loopback"
158
300
  ? { url: chosen, how: "as given", shareable: false,
159
301
  hint: `That address is this machine only — a teammate cannot reach it.\n` +
160
302
  ` 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 })
303
+ : { 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
304
  : findReach(opts.port);
305
+ const nothing = (why) => ({ cfg: priorCfg ?? { relay: reach.url }, invite: null, claimed: false, reach, tools: [], granted: [], relay: null, warnings: [why] });
163
306
  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}`);
307
+ if (chosen && !hostHere) {
308
+ const probe = await probeRelay(reach.url, opts.remoteTimeoutMs ?? 10_000, opts.fetchImpl);
309
+ if (!probe.ok) {
310
+ return nothing(`cannot reach the relay at ${reach.url}: ${describeNetError(probe.error, reach.url)}` +
311
+ (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.` : ""));
312
+ }
313
+ opts.log(` relay ${reach.url} (${reach.how})`);
168
314
  }
169
315
  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})`);
316
+ const urlPort = chosen && place !== "tunnel" ? Number(new URL(chosen.replace(/^ws/i, "http")).port) : 0;
317
+ const listenPort = urlPort || opts.port;
318
+ const local = `ws://127.0.0.1:${listenPort}`;
319
+ const probe = await probeRelay(place === "tunnel" ? local : reach.url, 1500);
320
+ if (probe.ok) {
321
+ opts.log(` relay already running — nothing to start ${reach.url}`);
322
+ }
323
+ else {
324
+ const free = await portFree(listenPort);
325
+ if (free !== true) {
326
+ return nothing(free === "EADDRINUSE"
327
+ ? `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>`
328
+ : `the relay could not start on port ${listenPort}: ${free}`);
329
+ }
330
+ relay = new Relay({ port: listenPort, host: "0.0.0.0", dataDir: opts.dataDir, log: () => { } });
331
+ await relay.listen();
332
+ opts.log(` relay started on ${reach.url} (${reach.how})`);
333
+ }
334
+ if (chosen && place !== "loopback")
335
+ rememberHosted(opts.dataDir, chosen);
173
336
  }
174
337
  const existing = priorCfg;
175
338
  const hasOwnSpace = !!existing?.space && !!existing?.token;
@@ -180,9 +343,9 @@ export async function start(opts) {
180
343
  ? { ...existing, relay: reach.url, agent: detectAgent() }
181
344
  : {
182
345
  relay: reach.url,
183
- space: opts.space ?? (basename(process.cwd()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "team"),
346
+ space: opts.space ?? teamNameFor(process.cwd(), repo),
184
347
  token: randomBytes(16).toString("hex"),
185
- member: opts.me ?? (keepIdentity ? existing.member : (process.env.USER ?? "me").toLowerCase().replace(/[^a-z0-9._-]/g, "-")),
348
+ member: opts.me ?? (keepIdentity ? existing.member : slugName(process.env.USER ?? "me", "me")),
186
349
  agent: detectAgent(),
187
350
  ...(keepIdentity ? { publicKey: existing.publicKey, privateKey: existing.privateKey } : newKeypair()),
188
351
  ...(keepIdentity && existing.account ? { account: existing.account } : {}),
@@ -220,7 +383,8 @@ export async function start(opts) {
220
383
  warnings.push(`this repo was bound to another team (${replaced.space}); it now belongs to "${c.space}"`);
221
384
  }
222
385
  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);
386
+ const mayWake = opts.wake ? opts.wake === "anyone" : wakeDefault(loadProfile(c));
387
+ const inv = await client.makeInvite(mayWake, opts.ttlMinutes);
224
388
  invite = toInvite({ ...c, token: inv.url });
225
389
  claimed = true;
226
390
  }
@@ -265,7 +429,7 @@ export async function start(opts) {
265
429
  return { cfg, invite, claimed, reach, tools: wired.tools, granted: wired.granted, relay, warnings };
266
430
  }
267
431
  export async function join(opts) {
268
- const existing = opts.reset ? null : load();
432
+ const existing = opts.reset ? null : (load() ?? identity());
269
433
  const repo = repoInfo(process.cwd());
270
434
  const keys = existing?.publicKey && existing.privateKey
271
435
  ? { publicKey: existing.publicKey, privateKey: existing.privateKey }
@@ -277,7 +441,7 @@ export async function join(opts) {
277
441
  relay: opts.invite.relay,
278
442
  space: opts.invite.space,
279
443
  token: "",
280
- member: opts.me ?? existing?.member ?? (process.env.USER ?? "me").toLowerCase().replace(/[^a-z0-9._-]/g, "-"),
444
+ member: opts.me ?? existing?.member ?? slugName(process.env.USER ?? "me", "me"),
281
445
  agent: detectAgent(),
282
446
  ...keys,
283
447
  ...(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