@paidfor/mcp 0.5.0 → 0.7.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/init.js ADDED
@@ -0,0 +1,629 @@
1
+ /**
2
+ * `paidfor-mcp init`: the whole install in one command. It finds the MCP clients on this machine, offers a wallet (never
3
+ * made unprompted), offers to link that wallet to a paidfor account in the browser, writes the stdio `paidfor` server
4
+ * into each chosen client, checks the index answers, and says what is left to do. `paidfor-mcp link` is the link,
5
+ * register and config steps alone. Everything printed is safe for an agent to read and relay: no key, no team key, no
6
+ * device code. Everything the outside world touches comes in through InitDeps, so the tests drive it with a fake home,
7
+ * a fake server and fake client CLIs.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { hostname as osHostname } from "node:os";
11
+ import { isAbsolute, resolve } from "node:path";
12
+ import { createInterface } from "node:readline";
13
+ import { CLIENT_IDS, backupPath, cliCommands, detectClients, existingCliEnv, isClientId, maskedCommand, mergeEntry, pasteBlock, pathWhich, planJson, readIfExists, targetFor, writeConfigAtomic, } from "./clients.js";
14
+ import { pollLink, startLink } from "./link.js";
15
+ import { deletePending, readPending, writePending } from "./pending.js";
16
+ import { DEFAULT_BASE, VERSION } from "./server.js";
17
+ import { DEFAULT_DAILY_CAP_USD, DEFAULT_MAX_PER_CALL_USD, FUND_SENTENCE, RegisterError, defaultKeyFile, loadWallet, newWallet, registerWallet } from "./wallet.js";
18
+ /** The package as the configs pin it: this exact version, never latest. */
19
+ export const PKG = `@paidfor/mcp@${VERSION}`;
20
+ /** What init and link send as X-Paidfor-Client and as the link's `client`, so the funnel can be counted. */
21
+ export const INIT_CLIENT = `mcp-init/${VERSION}`;
22
+ const VERIFY_HOST = "api-xpay.com";
23
+ /** How long `link` in --yes mode checks a resumed link before it exits and leaves the record for the next run. */
24
+ export const RESUME_WINDOW_MS = 20_000;
25
+ /** Printed with any usage error. */
26
+ export const INIT_USAGE = "usage: paidfor-mcp init [--yes] [--client <id>[,<id>]] [--wallet|--no-wallet] [--link|--no-link] [--wait] [--key-file <path>] [--label <name>] [--dry-run]\n" +
27
+ " paidfor-mcp link [--yes] [--client <id>[,<id>]] [--wait] [--key-file <path>] [--label <name>] [--dry-run]\n" +
28
+ `clients: ${CLIENT_IDS.join(", ")}\n`;
29
+ /** A bad command line: exit 2 with the usage text. */
30
+ class UsageError extends Error {
31
+ }
32
+ /** Parses the init or link command line; throws UsageError on an unknown or misused flag. */
33
+ export function parseFlags(argv, mode) {
34
+ const f = { yes: false, dryRun: false, wait: false, clients: null, wallet: null, link: null, keyFile: null, label: null };
35
+ /** The value of a flag that takes one, inline (--flag=v) or as the next argument. */
36
+ const value = (i, name, inline) => {
37
+ if (inline !== undefined) {
38
+ if (!inline)
39
+ throw new UsageError(`${name} needs a value`);
40
+ return [inline, i];
41
+ }
42
+ const v = argv[i + 1];
43
+ if (v === undefined || v.startsWith("--"))
44
+ throw new UsageError(`${name} needs a value`);
45
+ return [v, i + 1];
46
+ };
47
+ for (let i = 0; i < argv.length; i++) {
48
+ const [name, inline] = argv[i].startsWith("--") && argv[i].includes("=") ? [argv[i].slice(0, argv[i].indexOf("=")), argv[i].slice(argv[i].indexOf("=") + 1)] : [argv[i], undefined];
49
+ /** A switch, which must not be given a value. */
50
+ const both = (flag) => {
51
+ if (inline !== undefined)
52
+ throw new UsageError(`${name} takes no value`);
53
+ return flag;
54
+ };
55
+ switch (name) {
56
+ case "--yes":
57
+ case "-y":
58
+ f.yes = both(true);
59
+ break;
60
+ case "--dry-run":
61
+ f.dryRun = both(true);
62
+ break;
63
+ case "--wait":
64
+ f.wait = both(true);
65
+ break;
66
+ case "--wallet":
67
+ case "--no-wallet":
68
+ if (mode === "link")
69
+ throw new UsageError(`${name} is for init; link always uses the wallet`);
70
+ f.wallet = both(name === "--wallet");
71
+ break;
72
+ case "--link":
73
+ case "--no-link":
74
+ if (mode === "link")
75
+ throw new UsageError(`${name} is for init`);
76
+ f.link = both(name === "--link");
77
+ break;
78
+ case "--client": {
79
+ const [v, j] = value(i, name, inline);
80
+ i = j;
81
+ const ids = v.split(",").map((s) => s.trim()).filter(Boolean);
82
+ for (const id of ids)
83
+ if (!isClientId(id))
84
+ throw new UsageError(`unknown client ${JSON.stringify(id)}; one of ${CLIENT_IDS.join(", ")}`);
85
+ f.clients = [...new Set([...(f.clients ?? []), ...ids])];
86
+ break;
87
+ }
88
+ case "--key-file": {
89
+ const [v, j] = value(i, name, inline);
90
+ i = j;
91
+ f.keyFile = v;
92
+ break;
93
+ }
94
+ case "--label": {
95
+ const [v, j] = value(i, name, inline);
96
+ i = j;
97
+ f.label = v;
98
+ break;
99
+ }
100
+ default:
101
+ throw new UsageError(`unknown option ${JSON.stringify(argv[i])}`);
102
+ }
103
+ }
104
+ return f;
105
+ }
106
+ /** A [Y/n] answer: empty means the default. */
107
+ const yesNo = (answer, dflt = true) => {
108
+ const a = answer.trim().toLowerCase();
109
+ if (!a)
110
+ return dflt;
111
+ return a === "y" || a === "yes";
112
+ };
113
+ /** Dollars as the questions say them: $5, $0.50. */
114
+ const usd = (n) => (Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`);
115
+ /** Runs `paidfor-mcp init` or `paidfor-mcp link`. Returns the exit code. */
116
+ export async function runInit(argv, d, mode = "init") {
117
+ let flags;
118
+ try {
119
+ flags = parseFlags(argv, mode);
120
+ }
121
+ catch (e) {
122
+ if (!(e instanceof UsageError))
123
+ throw e;
124
+ d.err(`paidfor-mcp: ${e.message}\n${INIT_USAGE}`);
125
+ return 2;
126
+ }
127
+ const baseUrl = (d.env.PAIDFOR_API_BASE ?? d.env.PAIDFOR_URL ?? DEFAULT_BASE).replace(/\/$/, "");
128
+ // Without a terminal there is nobody to answer: behave as --yes, never wait on stdin.
129
+ const ask = !flags.yes && d.interactive;
130
+ // Nobody can be shown a link while the command blocks in an agent's shell: print it, save it, exit; `link` finishes it.
131
+ const nonBlocking = !ask && !flags.wait;
132
+ const dry = flags.dryRun;
133
+ /** A path as shown: under the home it starts with ~. */
134
+ const tilde = (p) => (p.startsWith(d.home + "/") ? `~${p.slice(d.home.length)}` : p);
135
+ /** A path from the command line, made absolute against the working directory. */
136
+ const absPath = (p) => (isAbsolute(p) ? p : resolve(d.cwd, p));
137
+ /** One line to stdout, which an agent may relay: never a secret. */
138
+ const say = (s = "") => d.out(s + "\n");
139
+ /** One line to stderr: problems, never a secret. */
140
+ const warn = (s) => d.err(`paidfor-mcp: ${s}\n`);
141
+ /** Asks a [Y/n] question; yes by default. */
142
+ const confirm = async (q) => yesNo(await d.prompt(`${q} [Y/n] `));
143
+ const linkLater = `npx -y ${PKG} link`;
144
+ let failures = 0;
145
+ say(`paidfor ${mode === "init" ? "setup" : "link"} (${PKG})${dry ? ", dry run: nothing is written, no wallet is made, no link is started" : ""}`);
146
+ // 0. A link an earlier run saved, for this server and this wallet: one a --yes run did not wait for (`link` only), or
147
+ // one approved whose registration failed (`link` and init's link step), which carries the team key.
148
+ let resume = null;
149
+ const saved = await readPending(d.home);
150
+ if (saved && (mode === "link" || saved.team_key)) {
151
+ const rec = saved;
152
+ if (rec.base_url !== baseUrl)
153
+ warn(`not resuming the pending link: it was started with ${rec.base_url}, and this run uses ${baseUrl}`);
154
+ // The link's code expires; a key handed over on approval does not.
155
+ else if (!rec.team_key && Date.parse(rec.expires_at) <= d.now()) {
156
+ say("The link started earlier has expired; starting a new one.");
157
+ if (!dry)
158
+ await deletePending(d.home);
159
+ }
160
+ else if (flags.keyFile && absPath(flags.keyFile) !== rec.key_file)
161
+ warn(`not resuming the pending link: it is for the wallet in ${tilde(rec.key_file)}`);
162
+ else
163
+ resume = rec;
164
+ }
165
+ // 1. Clients.
166
+ const ctx = { home: d.home, platform: d.platform, env: d.env, which: d.which };
167
+ const found = await detectClients(ctx);
168
+ let chosen;
169
+ const named = flags.clients ?? (resume && mode === "link" ? resume.clients.filter(isClientId) : null);
170
+ if (named) {
171
+ chosen = [];
172
+ for (const id of named) {
173
+ const known = found.find((t) => t.id === id);
174
+ chosen.push(known ?? (await targetFor(id, ctx)).target);
175
+ }
176
+ }
177
+ else if (ask && found.length) {
178
+ say("\nMCP clients on this machine:");
179
+ found.forEach((t, i) => say(` ${i + 1}. ${t.name} (${t.id})`));
180
+ const a = (await d.prompt("Configure which? Numbers or ids, comma-separated; Enter for all, none to skip: ")).trim().toLowerCase();
181
+ if (!a || a === "all")
182
+ chosen = found;
183
+ else if (a === "none")
184
+ chosen = [];
185
+ else {
186
+ chosen = [];
187
+ for (const part of a.split(",").map((s) => s.trim()).filter(Boolean)) {
188
+ const t = /^\d+$/.test(part) ? found[Number(part) - 1] : found.find((x) => x.id === part);
189
+ if (!t) {
190
+ warn(`${JSON.stringify(part)} is not one of the clients listed; skipped`);
191
+ continue;
192
+ }
193
+ if (!chosen.includes(t))
194
+ chosen.push(t);
195
+ }
196
+ }
197
+ }
198
+ else {
199
+ chosen = found;
200
+ say(found.length ? `\nMCP clients found: ${found.map((t) => t.name).join(", ")}` : "\nNo MCP client found on this machine.");
201
+ }
202
+ // A config that cannot be parsed is dropped now, before any wallet or link, so a team key is never fetched for nowhere.
203
+ const usable = [];
204
+ for (const t of chosen) {
205
+ if (t.kind === "file") {
206
+ let text;
207
+ try {
208
+ text = await readIfExists(t.file);
209
+ }
210
+ catch (e) {
211
+ warn(`${t.name}: cannot read ${tilde(t.file)} (${e.code ?? "error"}); leaving it alone`);
212
+ failures++;
213
+ continue;
214
+ }
215
+ const p = planJson(text, t.key, { command: "npx", args: [], env: {} });
216
+ if (!p.ok) {
217
+ warn(`${t.name}: not touching ${tilde(t.file)}: ${p.reason}. Fix it, or add the paidfor block by hand`);
218
+ failures++;
219
+ continue;
220
+ }
221
+ }
222
+ else if (!(await d.which(t.bin))) {
223
+ warn(`${t.name}: \`${t.bin}\` is not on PATH; skipped`);
224
+ failures++;
225
+ continue;
226
+ }
227
+ usable.push(t);
228
+ }
229
+ chosen = usable;
230
+ // 2. Wallet. An agent never makes one unprompted: in --yes mode only with --wallet.
231
+ let keyFile = null;
232
+ let address = null;
233
+ const existingFile = flags.keyFile ? absPath(flags.keyFile) : ((mode === "link" ? resume?.key_file : undefined) ?? defaultKeyFile(d.home));
234
+ const existing = (await readIfExists(existingFile).catch(() => null)) !== null;
235
+ if (flags.keyFile && !existing) {
236
+ warn(`--key-file ${existingFile} cannot be read`);
237
+ return 1;
238
+ }
239
+ let wantWallet;
240
+ if (mode === "link") {
241
+ if (!existing) {
242
+ warn(`no wallet at ${tilde(existingFile)}: run \`npx -y ${PKG} init --wallet\` first, or pass --key-file`);
243
+ return 1;
244
+ }
245
+ wantWallet = true;
246
+ }
247
+ else if (flags.wallet !== null)
248
+ wantWallet = flags.wallet;
249
+ else if (flags.keyFile)
250
+ wantWallet = true; // naming a key file is asking for that wallet
251
+ else if (ask) {
252
+ const caps = `caps of ${usd(DEFAULT_MAX_PER_CALL_USD)} a call and ${usd(DEFAULT_DAILY_CAP_USD)} a day; you fund it`;
253
+ say("");
254
+ wantWallet = await confirm(existing ? `Let this agent pay x402 sellers? This uses the wallet at ${tilde(existingFile)} with ${caps}.` : `Let this agent pay x402 sellers? This makes a wallet on this machine with ${caps}.`);
255
+ }
256
+ else
257
+ wantWallet = false;
258
+ if (wantWallet) {
259
+ if (existing) {
260
+ try {
261
+ const w = await loadWallet({ PAIDFOR_WALLET_KEY_FILE: existingFile });
262
+ if (!w)
263
+ throw new Error("no wallet");
264
+ address = w.account.address;
265
+ keyFile = existingFile;
266
+ }
267
+ catch (e) {
268
+ // WalletConfigError messages name the file and the problem, never the key.
269
+ warn(`the wallet cannot be used: ${e instanceof Error && e.name === "WalletConfigError" ? e.message : "the key could not be loaded"}`);
270
+ return 1;
271
+ }
272
+ say(`\nWallet: ${address} (${tilde(keyFile)})`);
273
+ if (resume && (resume.address.toLowerCase() !== address.toLowerCase() || resume.key_file !== keyFile)) {
274
+ warn("not resuming the pending link: it was started for another wallet");
275
+ resume = null;
276
+ }
277
+ }
278
+ else if (dry) {
279
+ keyFile = existingFile;
280
+ resume = null;
281
+ say(`\nWallet: would make one at ${tilde(keyFile)} (mode 600)`);
282
+ }
283
+ else {
284
+ resume = null; // a record is for the wallet it was made with, never a new one
285
+ const w = await newWallet(d.home);
286
+ address = w.address;
287
+ keyFile = w.file;
288
+ say(`\nWallet made: ${address} (key in ${tilde(keyFile)}, mode 600; it never leaves this machine)`);
289
+ }
290
+ }
291
+ else if (mode === "init") {
292
+ say(`\nNo wallet: the agent gets the read tools only. Add one later with \`npx -y ${PKG} init --wallet\`.`);
293
+ }
294
+ // 3. Link to a paidfor account (only with a wallet, and only where the key has somewhere to go).
295
+ let teamKey = null;
296
+ let teamName = null;
297
+ let paused = null;
298
+ let linkWanted = false;
299
+ let pendingSaved = false;
300
+ /** Whether the approved key is safely in the pending record, so `link` can finish without a new approval. */
301
+ let approvalSaved = false;
302
+ const pendingStep = `1. Open the link above and approve it. 2. Then run: npx -y ${PKG} link`;
303
+ if (keyFile) {
304
+ if (mode === "link")
305
+ linkWanted = true;
306
+ else if (flags.link !== null)
307
+ linkWanted = flags.link;
308
+ else if (ask)
309
+ linkWanted = await confirm("Link this wallet to a paidfor account, so its spending follows that account's rules? You approve it in the browser.");
310
+ if (linkWanted && chosen.length === 0) {
311
+ say(`\nNot linking: no MCP client config was chosen to hold the account's key. Add paidfor to a client, then run \`${linkLater}\`.`);
312
+ linkWanted = false;
313
+ }
314
+ else if (linkWanted && dry) {
315
+ say(resume?.team_key ? "\nLink: would register the wallet again with the approval saved earlier" : resume ? `\nLink: would resume the link started earlier (code ${resume.user_code})` : `\nLink: would ask ${baseUrl} for a code to approve in the browser, then register ${address ?? "the new wallet"} to that account`);
316
+ }
317
+ else if (linkWanted && address) {
318
+ const label = flags.label ?? resume?.label ?? d.hostname;
319
+ /** Says the link did not happen and how to try again. */
320
+ const notLinked = (why) => say(`Not linked: ${why}. Continuing without an account; run \`${linkLater}\` to try again.`);
321
+ // The verify URL is shown; the device code never is, even if a server put it in the URL.
322
+ /** The verify URL to print, or a fallback when it would carry the device code. */
323
+ const shown = (st) => (st.verify_url.includes(st.device_code) ? `${baseUrl}/team/link?code=${encodeURIComponent(st.user_code)}` : st.verify_url);
324
+ /**
325
+ * Registers the wallet with a key a person approved, once or again. Success: the key may go into the configs and
326
+ * the record is spent. Failure: the key is kept in the record (never in a config) so `link` can retry without a
327
+ * new approval, unless paidfor refused the key itself (401: revoked), which drops it.
328
+ */
329
+ const register = async (st, key, name) => {
330
+ try {
331
+ const r = await registerWallet({ baseUrl, teamKey: key, label, env: { PAIDFOR_WALLET_KEY_FILE: keyFile }, fetchFn: d.fetch, link: st.device_code });
332
+ teamKey = key;
333
+ teamName = name;
334
+ paused = r.paused;
335
+ // The key is only in memory until the configs are written, so the record keeps it until then: if the
336
+ // person declines the write or a config fails, `link` finishes the job without a new approval.
337
+ try {
338
+ await writePending(d.home, { ...st, base_url: baseUrl, label, key_file: keyFile, address, clients: chosen.map((t) => t.id), team_key: key, team_name: name });
339
+ approvalSaved = true;
340
+ }
341
+ catch (we) {
342
+ // A record that already holds this approval (a retry) is still good: keep it. One without the key would be
343
+ // polled again and answered 410, so that one is dropped rather than left to mislead.
344
+ if (resume?.team_key === key)
345
+ approvalSaved = true;
346
+ else
347
+ await deletePending(d.home);
348
+ if (!approvalSaved)
349
+ warn(`the approval could not be saved (${we.code ?? "error"}); if the config step below does not finish, a new approval will be needed`);
350
+ }
351
+ say(`Wallet ${address} registered to ${name}${r.already ? " (it already was)" : ""}: ${r.paused ? "paused" : "live"}.`);
352
+ }
353
+ catch (e) {
354
+ const why = e instanceof Error ? e.message : String(e);
355
+ if (e instanceof RegisterError && e.status === 401) {
356
+ await deletePending(d.home);
357
+ say(`Not linked: paidfor refused the approved key (HTTP 401; it was revoked), so the saved approval is dropped. Run \`${linkLater}\` to start a new link.`);
358
+ return;
359
+ }
360
+ try {
361
+ await writePending(d.home, { ...st, base_url: baseUrl, label, key_file: keyFile, address, clients: chosen.map((t) => t.id), team_key: key, team_name: name });
362
+ say(`Not linked yet: registering the wallet failed (${why}). The approval is saved; run \`${linkLater}\` to register again without a new approval.`);
363
+ }
364
+ catch (we) {
365
+ notLinked(`registering the wallet failed (${why}), and the approval could not be saved (${we.code ?? "error"})`);
366
+ }
367
+ }
368
+ };
369
+ say("");
370
+ try {
371
+ let start;
372
+ if (resume?.team_key) {
373
+ start = { device_code: resume.device_code, user_code: resume.user_code, verify_url: resume.verify_url, expires_at: resume.expires_at, interval: resume.interval };
374
+ say("Resuming: the link was approved earlier; registering the wallet again.");
375
+ await register(start, resume.team_key, resume.team_name || "your account");
376
+ }
377
+ else if (resume) {
378
+ start = { device_code: resume.device_code, user_code: resume.user_code, verify_url: resume.verify_url, expires_at: resume.expires_at, interval: resume.interval };
379
+ say(`Resuming the link started earlier: ${shown(start)} (code ${start.user_code})`);
380
+ say(nonBlocking ? "Checking whether it is approved..." : "Waiting for the approval...");
381
+ }
382
+ else {
383
+ start = await startLink({ baseUrl, address, label, client: INIT_CLIENT, fetchFn: d.fetch });
384
+ say("Open this and approve:");
385
+ say(` ${shown(start)}`);
386
+ say(`Check the code matches: ${start.user_code}`);
387
+ if (ask && d.stdoutTTY)
388
+ d.openBrowser(shown(start));
389
+ }
390
+ const mins = Math.max(1, Math.round((Date.parse(start.expires_at) - d.now()) / 60_000));
391
+ if (resume?.team_key) {
392
+ // registered (or not) above; nothing to poll
393
+ }
394
+ else if (!resume && nonBlocking) {
395
+ try {
396
+ await writePending(d.home, { ...start, base_url: baseUrl, label, key_file: keyFile, address, clients: chosen.map((t) => t.id) });
397
+ pendingSaved = true;
398
+ say(`Not waiting for it here: the link is saved and expires in ${mins} minute${mins === 1 ? "" : "s"}.`);
399
+ }
400
+ catch (e) {
401
+ notLinked(`the link could not be saved (${e.code ?? "error"})`);
402
+ }
403
+ }
404
+ else {
405
+ if (!resume)
406
+ say(`Waiting for the approval (the code expires in ${mins} minute${mins === 1 ? "" : "s"})...`);
407
+ const end = await pollLink({ baseUrl, start, client: INIT_CLIENT, fetchFn: d.fetch, sleep: d.sleep, now: d.now, immediate: !!resume, ...(resume && nonBlocking ? { stopAt: d.now() + RESUME_WINDOW_MS } : {}) });
408
+ if (end.kind === "pending") {
409
+ say(`Not approved yet; approve at ${shown(start)} (code ${start.user_code}) and run this again.`);
410
+ return 0;
411
+ }
412
+ // Denied, expired and already used end the link for good. An approval keeps the record until the
413
+ // registration succeeds (register() deletes it then, or rewrites it with the key); an unreadable answer keeps it.
414
+ if (resume && (end.kind === "denied" || end.kind === "expired" || end.kind === "used"))
415
+ await deletePending(d.home);
416
+ if (end.kind !== "approved")
417
+ notLinked(end.message);
418
+ else {
419
+ say(`Approved for ${end.team_name}.`);
420
+ await register(start, end.team_key, end.team_name);
421
+ }
422
+ }
423
+ }
424
+ catch (e) {
425
+ notLinked(e instanceof Error ? e.message : String(e));
426
+ }
427
+ if (pendingSaved && mode === "link") {
428
+ say(`\nNext:\n ${pendingStep}`);
429
+ return 0;
430
+ }
431
+ }
432
+ }
433
+ // 4. Config.
434
+ const env = {};
435
+ if (keyFile) {
436
+ env.PAIDFOR_WALLET_KEY_FILE = keyFile;
437
+ env.PAIDFOR_MAX_PER_CALL_USD = DEFAULT_MAX_PER_CALL_USD.toFixed(2);
438
+ env.PAIDFOR_DAILY_CAP_USD = String(DEFAULT_DAILY_CAP_USD);
439
+ }
440
+ if (teamKey)
441
+ env.PAIDFOR_TEAM_KEY = teamKey;
442
+ const ours = { command: "npx", args: ["-y", PKG], env };
443
+ /** The env key names an entry will get, for the summary (never their values). */
444
+ const keyNames = (e) => {
445
+ const names = Object.keys(e.env);
446
+ if (dry && linkWanted)
447
+ names.push("PAIDFOR_TEAM_KEY (once the link is approved)");
448
+ return names.length ? `; env ${names.join(", ")}` : "; no env";
449
+ };
450
+ const planned = [];
451
+ for (const t of chosen) {
452
+ if (t.kind === "file") {
453
+ const p = planJson(await readIfExists(t.file).catch(() => null), t.key, ours);
454
+ if (!p.ok) {
455
+ warn(`${t.name}: not touching ${tilde(t.file)}: ${p.reason}`);
456
+ failures++;
457
+ continue;
458
+ }
459
+ const what = p.action === "create" ? `create it with` : p.action === "add" ? `add paidfor (keeping the ${p.others} other server${p.others === 1 ? "" : "s"}) as` : `replace the paidfor entry${p.wasHosted ? " (the hosted URL, which cannot pay)" : ""} with`;
460
+ const bak = p.action !== "create" && (await readIfExists(backupPath(t.file)).catch(() => null)) === null ? `; backup to ${tilde(backupPath(t.file))}` : "";
461
+ planned.push({ t, text: p.text, summary: `${t.name} (${tilde(t.file)}): ${what} npx -y ${PKG}${keyNames(p.entry)}${bak}` });
462
+ }
463
+ else {
464
+ const entry = mergeEntry({ env: await existingCliEnv(t) }, ours);
465
+ const how = t.id === "vscode" ? "code --add-mcp (user profile)" : `${t.bin} mcp add${t.id === "claude-code" ? " --scope user" : ""}, replacing any paidfor entry there,`;
466
+ planned.push({ t, entry, summary: `${t.name}: ${how} with npx -y ${PKG}${keyNames(entry)}` });
467
+ }
468
+ }
469
+ const written = [];
470
+ /** Whether the config writes were attempted: not a dry run, and not declined. */
471
+ let go = false;
472
+ /** What to tell a person whose approved key is not yet in every config. */
473
+ const finishLater = () => (approvalSaved ? `The approval is saved; run \`${linkLater}\` to finish without a new approval.` : `The approval could not be saved, so run \`${linkLater}\` and approve again.`);
474
+ if (planned.length) {
475
+ say(`\n${dry ? "Would change" : "Config changes"}:`);
476
+ for (const p of planned)
477
+ say(` ${p.summary}`);
478
+ go = !dry;
479
+ if (go && ask)
480
+ go = await confirm("Write these changes?");
481
+ if (!dry && !go) {
482
+ say("Nothing written.");
483
+ if (teamKey)
484
+ say(`The account's key is in no config yet. ${finishLater()}`);
485
+ }
486
+ if (go) {
487
+ for (const p of planned) {
488
+ try {
489
+ if (p.t.kind === "file" && "text" in p) {
490
+ const w = await writeConfigAtomic(p.t.file, p.text, { secret: !!teamKey || p.text.includes("PAIDFOR_TEAM_KEY") });
491
+ if (w.tightened && process.platform !== "win32")
492
+ say(` ${p.t.name}: the config now holds the account's key, so it was made readable by you only (mode 600).`);
493
+ }
494
+ else if (p.t.kind === "cli" && "entry" in p) {
495
+ const cmds = cliCommands(p.t, p.entry);
496
+ if (cmds.remove)
497
+ await d.exec(p.t.bin, cmds.remove).catch(() => undefined);
498
+ const r = await d.exec(p.t.bin, cmds.add);
499
+ // Never echo the command line or the child's output: either can hold the team key.
500
+ if (r.code !== 0)
501
+ throw new Error(`\`${p.t.bin}\` exited with ${r.code}`);
502
+ }
503
+ written.push(p.t);
504
+ say(` done: ${p.t.name}`);
505
+ }
506
+ catch (e) {
507
+ failures++;
508
+ warn(`${p.t.name}: could not write the config: ${e instanceof Error ? e.message.split(teamKey ?? "\u0000").join("***") : "error"}`);
509
+ }
510
+ }
511
+ }
512
+ else if (dry) {
513
+ for (const p of planned)
514
+ if (p.t.kind === "cli" && "entry" in p)
515
+ say(` would run: ${maskedCommand(p.t.bin, cliCommands(p.t, p.entry).add)}`);
516
+ }
517
+ }
518
+ // The approval's record is spent only once the key is in every config it was meant for.
519
+ if (teamKey && !dry) {
520
+ if (planned.length > 0 && written.length === planned.length)
521
+ await deletePending(d.home);
522
+ else if (go && written.length < planned.length)
523
+ say(`The account's key did not reach every client. ${finishLater()}`);
524
+ }
525
+ if (chosen.length === 0) {
526
+ say(`\nAdd this stdio server to your MCP client's config:`);
527
+ say(pasteBlock(ours));
528
+ }
529
+ // 5. Verify the index answers.
530
+ if (!dry) {
531
+ try {
532
+ const res = await d.fetch(`${baseUrl}/v1/check?url=${encodeURIComponent(VERIFY_HOST)}`, {
533
+ headers: { Accept: "application/json", "User-Agent": `paidfor-mcp/${VERSION}`, "X-Paidfor-Client": INIT_CLIENT },
534
+ signal: AbortSignal.timeout(10_000),
535
+ });
536
+ if (!res.ok)
537
+ throw new Error(`HTTP ${res.status}`);
538
+ const j = (await res.json());
539
+ say(`\nCheck: ${VERIFY_HOST} is rated ${j.verdict ?? "unknown"}${j.verdict === "avoid" ? ", as expected; the index answers" : "; expected avoid"}.`);
540
+ }
541
+ catch (e) {
542
+ warn(`could not check ${VERIFY_HOST} against ${baseUrl} (${e instanceof Error ? e.message : String(e)}); the config is written, the tools may still work`);
543
+ }
544
+ }
545
+ // 6. Next steps.
546
+ if (!dry) {
547
+ say("\nNext:");
548
+ if (pendingSaved)
549
+ say(` ${pendingStep}`);
550
+ for (const t of written)
551
+ say(` Restart ${t.name}: ${t.restart}.`);
552
+ if (address)
553
+ say(` Wallet ${address}: ${FUND_SENTENCE}.`);
554
+ if (teamKey) {
555
+ say(` Account: ${baseUrl}/team (${teamName}).`);
556
+ say(paused ? ` The wallet is paused: open ${baseUrl}/team, check its limits, untick Paused and save.` : " The wallet is live: it pays within the account's rules and the caps in the config.");
557
+ }
558
+ else if (keyFile && mode === "init" && !pendingSaved)
559
+ say(` To follow an account's rules later: \`${linkLater}\`.`);
560
+ }
561
+ return failures ? 1 : 0;
562
+ }
563
+ /**
564
+ * One argument for a cmd.exe command line. First quoted the way the Windows C runtime splits arguments (backslashes
565
+ * before a quote doubled, quotes escaped), then every character cmd.exe gives a meaning to is escaped with ^. The CLIs
566
+ * are .cmd shims, and a shim passes its arguments through cmd.exe a second time, so the ^ escaping is applied twice.
567
+ * The same rules as cross-spawn.
568
+ */
569
+ export function winArg(arg) {
570
+ let a = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
571
+ a = `"${a}"`;
572
+ const meta = /([()\][%!^"`<>&|;, *?])/g;
573
+ return a.replace(meta, "^$1").replace(meta, "^$1");
574
+ }
575
+ /** What to spawn on Windows for a CLI shim: cmd.exe with one verbatim, fully escaped command line. */
576
+ export function winSpawn(cmd, args, env) {
577
+ const line = [cmd.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1"), ...args.map(winArg)].join(" ");
578
+ return { file: env.ComSpec?.trim() || env.COMSPEC?.trim() || "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`] };
579
+ }
580
+ /** The real world: process env, this home, a TTY prompt, node's fetch, child processes. */
581
+ export function realDeps() {
582
+ const env = process.env;
583
+ const platform = process.platform;
584
+ return {
585
+ env,
586
+ home: env.HOME?.trim() || env.USERPROFILE?.trim() || process.cwd(),
587
+ platform,
588
+ cwd: process.cwd(),
589
+ interactive: !!process.stdin.isTTY,
590
+ stdoutTTY: !!process.stdout.isTTY,
591
+ out: (s) => void process.stdout.write(s),
592
+ err: (s) => void process.stderr.write(s),
593
+ prompt: (q) => new Promise((res) => {
594
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
595
+ rl.question(q, (a) => {
596
+ rl.close();
597
+ res(a);
598
+ });
599
+ }),
600
+ fetch: globalThis.fetch,
601
+ exec: (cmd, args) => new Promise((res) => {
602
+ // Never shell: true, so nothing in args is interpreted. On Windows the CLIs are .cmd shims that only cmd.exe
603
+ // can run, so the command line is built and escaped here (winSpawn) and passed through verbatim.
604
+ const win = platform === "win32" ? winSpawn(cmd, args, env) : null;
605
+ const child = win
606
+ ? spawn(win.file, win.args, { stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: true })
607
+ : spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
608
+ let stdout = "";
609
+ let stderr = "";
610
+ child.stdout.on("data", (b) => (stdout += b));
611
+ child.stderr.on("data", (b) => (stderr += b));
612
+ child.on("error", () => res({ code: 127, stdout, stderr }));
613
+ child.on("close", (code) => res({ code: code ?? 1, stdout, stderr }));
614
+ }),
615
+ which: pathWhich(env, platform),
616
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
617
+ now: Date.now,
618
+ openBrowser: (url) => {
619
+ const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
620
+ try {
621
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
622
+ }
623
+ catch {
624
+ /* best effort */
625
+ }
626
+ },
627
+ hostname: osHostname().split(".")[0] || "agent",
628
+ };
629
+ }
package/dist/link.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /** The answer to POST /v1/team/link; the device code is a secret. */
2
+ export type LinkStart = {
3
+ user_code: string;
4
+ device_code: string;
5
+ verify_url: string;
6
+ expires_at: string;
7
+ interval: number;
8
+ };
9
+ /** How a link ended. Only "approved" carries a key; every other end leaves the wallet unlinked. */
10
+ export type LinkEnd = {
11
+ kind: "approved";
12
+ team_key: string;
13
+ team_name: string;
14
+ } | {
15
+ kind: "denied" | "expired" | "used" | "failed";
16
+ message: string;
17
+ }
18
+ /** Only with `stopAt`: still waiting for the person when the window closed. */
19
+ | {
20
+ kind: "pending";
21
+ message: string;
22
+ };
23
+ /** Starts a link. Throws an Error whose message is safe to show (never a code or a key) when it cannot. */
24
+ export declare function startLink(o: {
25
+ baseUrl: string;
26
+ address: string;
27
+ label: string;
28
+ client: string;
29
+ fetchFn?: typeof fetch;
30
+ }): Promise<LinkStart>;
31
+ /**
32
+ * Polls until the link is approved, denied, expired or already used, or `expires_at` passes. A network error or a 5xx
33
+ * is tried again on the next tick; a 429 slows the polling down. `immediate` asks once before the first wait (a link
34
+ * resumed from an earlier run); `stopAt` (epoch ms) gives up with { kind: "pending" } when the next poll would be later.
35
+ */
36
+ export declare function pollLink(o: {
37
+ baseUrl: string;
38
+ start: LinkStart;
39
+ client: string;
40
+ fetchFn?: typeof fetch;
41
+ sleep?: (ms: number) => Promise<void>;
42
+ now?: () => number;
43
+ immediate?: boolean;
44
+ stopAt?: number;
45
+ }): Promise<LinkEnd>;