@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.
- package/dist/accounts.js +13 -2
- package/dist/agents.js +377 -9
- package/dist/cli.js +377 -192
- package/dist/client.js +73 -12
- package/dist/config.js +15 -0
- package/dist/doctor.js +13 -10
- package/dist/grants.js +16 -1
- package/dist/hooks-install.js +4 -2
- package/dist/host-env.js +51 -6
- package/dist/install.js +216 -16
- package/dist/kiro-files.js +23 -6
- package/dist/ledger.js +8 -2
- package/dist/machine.js +273 -54
- package/dist/mcp-supervisor.js +5 -0
- package/dist/mcp.js +33 -6
- package/dist/network.js +258 -57
- package/dist/pending.js +14 -7
- package/dist/quickstart.js +180 -21
- package/dist/refusal.js +29 -0
- package/dist/relay.js +686 -440
- package/dist/roster-file.js +19 -3
- package/dist/run-failure.js +18 -7
- package/dist/service.js +173 -40
- package/dist/setup-guide.js +21 -4
- package/dist/store.js +9 -3
- package/dist/which.js +66 -4
- package/dist/workdir.js +7 -1
- package/dist/worker.js +522 -101
- package/helper/macos/menubar.swift +11 -3
- package/package.json +1 -1
package/dist/quickstart.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
? (
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 ?? (
|
|
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"
|
|
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
|
|
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"
|
|
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 } : {}),
|
package/dist/refusal.js
ADDED
|
@@ -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
|