pombus 0.0.0-stage → 0.1.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/bin.js +157 -0
- package/dist/bin.test.js +33 -0
- package/dist/client.js +142 -0
- package/dist/clients.js +187 -0
- package/dist/doctor.js +34 -0
- package/dist/i18n.js +141 -0
- package/dist/i18n.test.js +78 -0
- package/dist/init.js +154 -0
- package/dist/installer.test.js +344 -0
- package/dist/scope.js +118 -0
- package/dist/security.test.js +111 -0
- package/dist/server.js +94 -0
- package/dist/testutil.js +117 -0
- package/dist/tools.js +955 -0
- package/dist/tools.test.js +380 -0
- package/package.json +30 -6
- package/README.md +0 -3
package/dist/bin.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `pombus` CLI entrypoint. Three subcommands:
|
|
3
|
+
// - `pombus mcp` — the stdio MCP server a client invokes (F4).
|
|
4
|
+
// - `pombus init` — the installer: read key, (opt) create inbox, write MCP config (F5).
|
|
5
|
+
// - `pombus doctor` — validate the key and report its scope (F5).
|
|
6
|
+
// Flag parsing is a tiny manual argv walk (ponytail: no commander/yargs). `--help` prints usage.
|
|
7
|
+
//
|
|
8
|
+
// parseArgs is exported and pure; main() runs ONLY when this file is the process entrypoint (guarded
|
|
9
|
+
// at the bottom), so a test can import parseArgs without the CLI firing.
|
|
10
|
+
import { createInterface } from "node:readline";
|
|
11
|
+
import { argv } from "node:process";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { CoreError, readApiKey } from "./client.js";
|
|
14
|
+
import { runMcpServer } from "./server.js";
|
|
15
|
+
import { runInit, InitAbort } from "./init.js";
|
|
16
|
+
import { runDoctor } from "./doctor.js";
|
|
17
|
+
import { humanCodeForBoot, humanMessage, resolveLocale } from "./i18n.js";
|
|
18
|
+
const VALID_LOCALES = ["en-US", "pt-BR", "es-419"];
|
|
19
|
+
/** Parse argv (the slice AFTER `node bin.js`). Unknown flags are ignored rather than fatal — a thin
|
|
20
|
+
* installer should not reject a future client's extra flag. `--locale`/`--client` take the next arg
|
|
21
|
+
* or an `=value`. An invalid locale is dropped (falls back to env detection). */
|
|
22
|
+
export function parseArgs(argv) {
|
|
23
|
+
const out = {
|
|
24
|
+
cmd: undefined,
|
|
25
|
+
createInbox: false,
|
|
26
|
+
print: false,
|
|
27
|
+
help: false,
|
|
28
|
+
locale: undefined,
|
|
29
|
+
client: undefined,
|
|
30
|
+
};
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const a = argv[i];
|
|
33
|
+
if (!out.cmd && !a.startsWith("-")) {
|
|
34
|
+
out.cmd = a;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (a === "--create-inbox")
|
|
38
|
+
out.createInbox = true;
|
|
39
|
+
else if (a === "--print" || a === "--dry-run")
|
|
40
|
+
out.print = true;
|
|
41
|
+
else if (a === "--help" || a === "-h")
|
|
42
|
+
out.help = true;
|
|
43
|
+
else if (a === "--locale" || a.startsWith("--locale=")) {
|
|
44
|
+
const v = a.includes("=") ? a.slice(a.indexOf("=") + 1) : argv[++i];
|
|
45
|
+
if (v && VALID_LOCALES.includes(v))
|
|
46
|
+
out.locale = v;
|
|
47
|
+
}
|
|
48
|
+
else if (a === "--client" || a.startsWith("--client=")) {
|
|
49
|
+
const v = a.includes("=") ? a.slice(a.indexOf("=") + 1) : argv[++i];
|
|
50
|
+
if (v)
|
|
51
|
+
out.client = v;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
const USAGE = [
|
|
57
|
+
"usage: pombus <command> [options]",
|
|
58
|
+
"",
|
|
59
|
+
"commands:",
|
|
60
|
+
" mcp run the stdio MCP server (invoked by your client)",
|
|
61
|
+
" init write the Pombus MCP config into your client",
|
|
62
|
+
" doctor check your API key and report its scope",
|
|
63
|
+
"",
|
|
64
|
+
"init options:",
|
|
65
|
+
" --create-inbox create a new inbox and bind the config to it",
|
|
66
|
+
" --print print the config block instead of writing it",
|
|
67
|
+
" --client <id> target one client (claude-code|cursor|gemini|codex)",
|
|
68
|
+
" --locale <loc> en-US | pt-BR | es-419",
|
|
69
|
+
].join("\n");
|
|
70
|
+
async function main() {
|
|
71
|
+
const args = parseArgs(process.argv.slice(2));
|
|
72
|
+
if (args.help || !args.cmd) {
|
|
73
|
+
process.stdout.write(USAGE + "\n");
|
|
74
|
+
process.exitCode = args.cmd ? 0 : 2;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (args.cmd === "mcp") {
|
|
78
|
+
await runMcpServer();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (args.cmd === "init") {
|
|
82
|
+
await runInit({
|
|
83
|
+
apiKey: readApiKey() ?? undefined,
|
|
84
|
+
createInbox: args.createInbox,
|
|
85
|
+
print: args.print,
|
|
86
|
+
...(args.client !== undefined ? { client: args.client } : {}),
|
|
87
|
+
...(args.locale !== undefined ? { locale: args.locale } : {}),
|
|
88
|
+
home: undefined, // defaults to os.homedir() inside clients.ts
|
|
89
|
+
promptKey: promptKeyHidden,
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (args.cmd === "doctor") {
|
|
94
|
+
const res = await runDoctor({
|
|
95
|
+
apiKey: readApiKey() ?? undefined,
|
|
96
|
+
...(args.locale !== undefined ? { locale: args.locale } : {}),
|
|
97
|
+
});
|
|
98
|
+
process.exitCode = res.ok ? 0 : 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
process.stderr.write(`unknown command: ${args.cmd}\n` + USAGE + "\n");
|
|
102
|
+
process.exitCode = 2;
|
|
103
|
+
}
|
|
104
|
+
/** Read a key from the TTY without echoing it. readline writes the prompt; we mute the output stream
|
|
105
|
+
* while the user types so the secret never lands in the terminal scrollback. The prompt text itself
|
|
106
|
+
* is written before muting so the user still sees it. Falls back to a normal (echoing) read when
|
|
107
|
+
* stdin is not a TTY (piped input) — there is nothing to hide there. */
|
|
108
|
+
async function promptKeyHidden() {
|
|
109
|
+
const locale = resolveLocale();
|
|
110
|
+
const prompt = humanMessage("init_prompt_key", locale);
|
|
111
|
+
const input = process.stdin;
|
|
112
|
+
const output = process.stdout;
|
|
113
|
+
const rl = createInterface({ input, output, terminal: true });
|
|
114
|
+
// Mute: replace the interface's write of typed characters with nothing, but keep the initial prompt.
|
|
115
|
+
output.write(prompt);
|
|
116
|
+
const rlAny = rl;
|
|
117
|
+
const originalWrite = rlAny._writeToOutput;
|
|
118
|
+
rlAny._writeToOutput = () => {
|
|
119
|
+
/* swallow keystroke echo */
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
const answer = await new Promise((res) => rl.question("", res));
|
|
123
|
+
output.write("\n");
|
|
124
|
+
return answer;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
if (originalWrite)
|
|
128
|
+
rlAny._writeToOutput = originalWrite;
|
|
129
|
+
rl.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** True when this module is the process entrypoint (run as `node bin.js` / the `pombus` bin), as
|
|
133
|
+
* opposed to being imported by a test. Compares the resolved module path to argv[1]. */
|
|
134
|
+
function isEntrypoint() {
|
|
135
|
+
const invoked = argv[1];
|
|
136
|
+
if (!invoked)
|
|
137
|
+
return false;
|
|
138
|
+
try {
|
|
139
|
+
return fileURLToPath(import.meta.url) === invoked;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (isEntrypoint()) {
|
|
146
|
+
main().catch((e) => {
|
|
147
|
+
// Last-resort guard: never print the key or a stack that could carry one. Localize the REAL cause
|
|
148
|
+
// when it is a CoreError (a human ran us); a clean InitAbort already printed its own line.
|
|
149
|
+
if (e instanceof InitAbort) {
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const code = e instanceof CoreError ? humanCodeForBoot(e.code) : "unknown";
|
|
154
|
+
process.stderr.write(humanMessage(code) + "\n");
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
});
|
|
157
|
+
}
|
package/dist/bin.test.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseArgs } from "./bin.js";
|
|
4
|
+
test("parseArgs: command + flags", () => {
|
|
5
|
+
const a = parseArgs(["init", "--create-inbox", "--print"]);
|
|
6
|
+
assert.equal(a.cmd, "init");
|
|
7
|
+
assert.equal(a.createInbox, true);
|
|
8
|
+
assert.equal(a.print, true);
|
|
9
|
+
});
|
|
10
|
+
test("parseArgs: --dry-run is an alias for --print", () => {
|
|
11
|
+
assert.equal(parseArgs(["init", "--dry-run"]).print, true);
|
|
12
|
+
});
|
|
13
|
+
test("parseArgs: --locale with a space or an = ", () => {
|
|
14
|
+
assert.equal(parseArgs(["init", "--locale", "pt-BR"]).locale, "pt-BR");
|
|
15
|
+
assert.equal(parseArgs(["init", "--locale=es-419"]).locale, "es-419");
|
|
16
|
+
});
|
|
17
|
+
test("parseArgs: an invalid locale is dropped (falls back to env detection)", () => {
|
|
18
|
+
assert.equal(parseArgs(["init", "--locale", "klingon"]).locale, undefined);
|
|
19
|
+
});
|
|
20
|
+
test("parseArgs: --client selects one target", () => {
|
|
21
|
+
assert.equal(parseArgs(["init", "--client", "cursor"]).client, "cursor");
|
|
22
|
+
assert.equal(parseArgs(["init", "--client=codex"]).client, "codex");
|
|
23
|
+
});
|
|
24
|
+
test("parseArgs: --help, and no command", () => {
|
|
25
|
+
assert.equal(parseArgs(["--help"]).help, true);
|
|
26
|
+
assert.equal(parseArgs(["doctor", "-h"]).help, true);
|
|
27
|
+
assert.equal(parseArgs([]).cmd, undefined);
|
|
28
|
+
});
|
|
29
|
+
test("parseArgs: an unknown flag is ignored, not fatal", () => {
|
|
30
|
+
const a = parseArgs(["init", "--some-future-flag", "--print"]);
|
|
31
|
+
assert.equal(a.cmd, "init");
|
|
32
|
+
assert.equal(a.print, true);
|
|
33
|
+
});
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// HTTP client for the Pombus REST core. The MCP server is a pure client: it never opens SQLite,
|
|
2
|
+
// never parses MIME. It holds the API key, signs every request with a Bearer header, and translates
|
|
3
|
+
// the core's HTTP contract into a shape the tool layer can hand back to the model.
|
|
4
|
+
//
|
|
5
|
+
// Security invariants enforced here (audited by pombus-sec):
|
|
6
|
+
// - the key lives ONLY in the Authorization header; it is never placed in a URL, an argv, a log
|
|
7
|
+
// line, or a thrown error message.
|
|
8
|
+
// - an auth/permission failure (401/403) and a missing resource (404) collapse to ONE opaque
|
|
9
|
+
// error for the model (`not_found`), so the server never confirms an inbox exists to a caller
|
|
10
|
+
// that cannot reach it — the same anti-enumeration rule the core applies.
|
|
11
|
+
// - network/transport errors report the failure TYPE only, never the request URL (which carries
|
|
12
|
+
// query values) or the key.
|
|
13
|
+
const DEFAULT_BASE = "https://mail-api.pombus.com/v0";
|
|
14
|
+
/** A failure the tool layer surfaces to the model. `code` is stable and locale-free; it maps to an
|
|
15
|
+
* i18n message only when a human sees it. Never carries the key, the URL, or a server detail that
|
|
16
|
+
* would distinguish "forbidden" from "not found". */
|
|
17
|
+
export class CoreError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "CoreError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Resolve the core base URL from an explicit option or the environment, else the production default.
|
|
26
|
+
* Trailing slash trimmed so path joins never double up. */
|
|
27
|
+
export function resolveBaseUrl(explicit) {
|
|
28
|
+
const raw = (explicit ?? process.env["POMBUS_API_BASE"] ?? DEFAULT_BASE).trim();
|
|
29
|
+
return raw.replace(/\/+$/, "");
|
|
30
|
+
}
|
|
31
|
+
/** Read the API key from the environment. Returns null when absent — the caller decides how to fail
|
|
32
|
+
* (the server refuses to start without it). The value is never logged by this module. */
|
|
33
|
+
export function readApiKey() {
|
|
34
|
+
const k = (process.env["POMBUS_API_KEY"] ?? "").trim();
|
|
35
|
+
return k.length > 0 ? k : null;
|
|
36
|
+
}
|
|
37
|
+
export class CoreClient {
|
|
38
|
+
apiKey;
|
|
39
|
+
baseUrl;
|
|
40
|
+
fetchImpl;
|
|
41
|
+
constructor(opts) {
|
|
42
|
+
this.apiKey = opts.apiKey;
|
|
43
|
+
this.baseUrl = resolveBaseUrl(opts.baseUrl);
|
|
44
|
+
// Node >=20 ships a global fetch; the option exists only so tests can inject a fake core.
|
|
45
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
46
|
+
}
|
|
47
|
+
/** Build an absolute URL from a path and query params. Values are URL-encoded. The API key is
|
|
48
|
+
* NEVER a query param — it only ever rides in the Authorization header (keyHygiene). */
|
|
49
|
+
url(path, query) {
|
|
50
|
+
const u = new URL(this.baseUrl + path);
|
|
51
|
+
if (query) {
|
|
52
|
+
for (const [k, v] of Object.entries(query)) {
|
|
53
|
+
if (v !== undefined && v !== "")
|
|
54
|
+
u.searchParams.set(k, String(v));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return u.toString();
|
|
58
|
+
}
|
|
59
|
+
/** One request. On a non-2xx the core body is parsed for its stable `code`; 401/403/404 all map to
|
|
60
|
+
* `not_found` so the model can never tell "you may not" from "it does not exist". Other statuses
|
|
61
|
+
* keep their code for diagnostics but still never leak the key or the URL. */
|
|
62
|
+
async request(method, path, opts = {}) {
|
|
63
|
+
const url = this.url(path, opts.query);
|
|
64
|
+
const init = {
|
|
65
|
+
method,
|
|
66
|
+
headers: {
|
|
67
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
68
|
+
accept: "application/json",
|
|
69
|
+
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
if (opts.body !== undefined)
|
|
73
|
+
init.body = JSON.stringify(opts.body);
|
|
74
|
+
// A corporate proxy is honoured by Node's own undici dispatcher (HTTPS_PROXY / NODE_USE_ENV_PROXY);
|
|
75
|
+
// we do not re-implement it.
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await this.fetchImpl(url, init);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
// Transport failure: report the TYPE, never the URL (it carries query values) or the key.
|
|
82
|
+
throw new CoreError("network", `request failed: ${errName(e)}`);
|
|
83
|
+
}
|
|
84
|
+
if (res.ok) {
|
|
85
|
+
if (res.status === 204)
|
|
86
|
+
return undefined;
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
if (!text)
|
|
89
|
+
return undefined;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(text);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new CoreError("bad_response", "core returned a non-JSON body");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Anti-enumeration: do NOT distinguish "forbidden" from "not found" to the model.
|
|
98
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
99
|
+
throw new CoreError("not_found", "resource not found");
|
|
100
|
+
}
|
|
101
|
+
// Other errors: a stable code from the core body when present, else the status. The message is
|
|
102
|
+
// generic; the body is read but only its `code`/`detail` string is used, never echoed verbatim
|
|
103
|
+
// if it could carry caller input.
|
|
104
|
+
const code = await safeCode(res);
|
|
105
|
+
throw new CoreError(code, `core error (${res.status})`);
|
|
106
|
+
}
|
|
107
|
+
get(path, query) {
|
|
108
|
+
return this.request("GET", path, { query });
|
|
109
|
+
}
|
|
110
|
+
post(path, body) {
|
|
111
|
+
return this.request("POST", path, { body });
|
|
112
|
+
}
|
|
113
|
+
patch(path, body) {
|
|
114
|
+
return this.request("PATCH", path, { body });
|
|
115
|
+
}
|
|
116
|
+
delete(path, body) {
|
|
117
|
+
return this.request("DELETE", path, { body });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** The error constructor name only — never the message, which could carry a URL or system path. */
|
|
121
|
+
function errName(e) {
|
|
122
|
+
if (e && typeof e === "object" && "name" in e && typeof e.name === "string") {
|
|
123
|
+
return e.name;
|
|
124
|
+
}
|
|
125
|
+
return "Error";
|
|
126
|
+
}
|
|
127
|
+
/** Extract a stable short code from a core error body without echoing attacker-influenced text. */
|
|
128
|
+
async function safeCode(res) {
|
|
129
|
+
try {
|
|
130
|
+
const text = await res.text();
|
|
131
|
+
if (!text)
|
|
132
|
+
return "core_error";
|
|
133
|
+
const body = JSON.parse(text);
|
|
134
|
+
const raw = typeof body.code === "string" ? body.code : typeof body.detail === "string" ? body.detail : "";
|
|
135
|
+
// Only accept a conservative identifier shape; anything else collapses to a generic code so a
|
|
136
|
+
// reflected body can't smuggle content into our error.
|
|
137
|
+
return /^[a-z][a-z0-9_]{0,39}$/.test(raw) ? raw : "core_error";
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return "core_error";
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/clients.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Client config — where `pombus init` writes (or prints) the MCP server block, and how it writes it
|
|
2
|
+
// safely. The MCP block shape is the Resend/Postmark standard every client understands:
|
|
3
|
+
// { "command": "npx", "args": ["-y", "pombus", "mcp"], "env": { "POMBUS_API_KEY": "<key>" } }
|
|
4
|
+
//
|
|
5
|
+
// SECURITY (audited by pombus-sec, the write is the sharp edge of this slice):
|
|
6
|
+
// - MERGE, never clobber: the existing config is parsed and ONLY the `pombus` entry under
|
|
7
|
+
// `mcpServers` is added/updated; every other key the user has is preserved byte-for-byte on the
|
|
8
|
+
// parts we don't touch.
|
|
9
|
+
// - ATOMIC: write to a temp file in the SAME directory, fsync, then rename over the target. A crash
|
|
10
|
+
// mid-write never leaves a truncated config (rename is atomic on the same filesystem).
|
|
11
|
+
// - 0600: the file that carries the key is chmod'd to owner-only where the OS supports it (POSIX);
|
|
12
|
+
// on Windows chmod is a no-op and NTFS inherits the user profile ACL, so we do not pretend.
|
|
13
|
+
// - NEVER the project repo: only a path under the user's HOME. We refuse any target we cannot place
|
|
14
|
+
// inside os.homedir(), falling back to --print instead of guessing.
|
|
15
|
+
// - PRINT fallback: a client whose format we will not write by hand (Codex: TOML, which we will not
|
|
16
|
+
// serialize without a parser — ponytail forbids the dep, and a half-escaped TOML path is a known
|
|
17
|
+
// way to wedge the client) is PRINT-only. So is any client whose config dir we cannot resolve.
|
|
18
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync, rmSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join, dirname, resolve, sep } from "node:path";
|
|
21
|
+
/** Resolve the table of known clients for a given HOME. Taking `home` as an argument (not calling
|
|
22
|
+
* os.homedir() inline) is what lets a test point the whole table at a temp dir. */
|
|
23
|
+
export function clientTargets(home = homedir()) {
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
id: "claude-code",
|
|
27
|
+
label: "Claude Code",
|
|
28
|
+
mode: "json",
|
|
29
|
+
// code.claude.com/docs/en/mcp-quickstart: user-scoped servers live under the `mcpServers` key of
|
|
30
|
+
// ~/.claude.json (on Windows %USERPROFILE%\.claude.json). This file also holds other Claude Code
|
|
31
|
+
// state, which is exactly why we merge-only the mcpServers entry and never rewrite the rest.
|
|
32
|
+
path: join(home, ".claude.json"),
|
|
33
|
+
source: "code.claude.com/docs/en/mcp-quickstart",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "cursor",
|
|
37
|
+
label: "Cursor",
|
|
38
|
+
mode: "json",
|
|
39
|
+
// cursor.com/docs/mcp: global servers live in ~/.cursor/mcp.json under a top-level mcpServers.
|
|
40
|
+
path: join(home, ".cursor", "mcp.json"),
|
|
41
|
+
source: "cursor.com/docs/mcp",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "gemini",
|
|
45
|
+
label: "Gemini CLI",
|
|
46
|
+
mode: "json",
|
|
47
|
+
// geminicli.com/docs/reference/configuration: user settings at ~/.gemini/settings.json, MCP under
|
|
48
|
+
// the top-level mcpServers object (same shape as the others).
|
|
49
|
+
path: join(home, ".gemini", "settings.json"),
|
|
50
|
+
source: "geminicli.com/docs/reference/configuration",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "codex",
|
|
54
|
+
label: "Codex CLI",
|
|
55
|
+
mode: "print",
|
|
56
|
+
// community.openai.com: Codex reads ~/.codex/config.toml — a TOML file under an [mcp_servers.*]
|
|
57
|
+
// table. We do NOT write TOML (no parser without a dep; a mis-escaped Windows path is a known way
|
|
58
|
+
// to wedge the Codex app). So Codex is print-only: we show the block and let the user paste it.
|
|
59
|
+
path: join(home, ".codex", "config.toml"),
|
|
60
|
+
source: "community.openai.com/t/codex-config-toml-mcp",
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
/** The MCP server block, exactly the Resend/Postmark shape. `key` may be a placeholder for --print. */
|
|
65
|
+
export function serverBlock(key) {
|
|
66
|
+
return { command: "npx", args: ["-y", "pombus", "mcp"], env: { POMBUS_API_KEY: key } };
|
|
67
|
+
}
|
|
68
|
+
/** The pretty JSON a --print or a print-only client shows: the standard mcpServers wrapper with the
|
|
69
|
+
* single pombus entry, so the user can paste it straight in. */
|
|
70
|
+
export function printableJson(key) {
|
|
71
|
+
return JSON.stringify({ mcpServers: { pombus: serverBlock(key) } }, null, 2);
|
|
72
|
+
}
|
|
73
|
+
/** The TOML a Codex user pastes by hand (we print, never write it). Kept tiny and literal — this is
|
|
74
|
+
* display text, not a serializer: a fixed three-line table with a JSON-encoded env map, which TOML
|
|
75
|
+
* accepts as an inline table only in its own syntax, so we emit the explicit sub-table form. */
|
|
76
|
+
export function printableCodexToml(key) {
|
|
77
|
+
// Backslashes in a Windows npx path are not an issue here: `npx` is a bare command, and the key is a
|
|
78
|
+
// conservative token. We still single-quote the key (TOML literal string) so no escape is needed.
|
|
79
|
+
return [
|
|
80
|
+
"[mcp_servers.pombus]",
|
|
81
|
+
'command = "npx"',
|
|
82
|
+
'args = ["-y", "pombus", "mcp"]',
|
|
83
|
+
"",
|
|
84
|
+
"[mcp_servers.pombus.env]",
|
|
85
|
+
`POMBUS_API_KEY = '${key}'`,
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
/** Raised when a write target is unsafe (outside HOME) or unreadable in a way we refuse to overwrite.
|
|
89
|
+
* The caller turns it into a --print fallback, never a crash. */
|
|
90
|
+
export class ConfigWriteError extends Error {
|
|
91
|
+
code;
|
|
92
|
+
constructor(code) {
|
|
93
|
+
super(code);
|
|
94
|
+
this.name = "ConfigWriteError";
|
|
95
|
+
this.code = code;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** True when `target` is inside `home` (defense against a client table — or a tampered HOME env — that
|
|
99
|
+
* would place a write in the project repo or elsewhere). Path-prefix check after resolving both. */
|
|
100
|
+
export function isInsideHome(target, home) {
|
|
101
|
+
const h = resolve(home);
|
|
102
|
+
const t = resolve(target);
|
|
103
|
+
return t === h || t.startsWith(h + sep);
|
|
104
|
+
}
|
|
105
|
+
/** Merge the pombus server block into a client's JSON config and write it atomically with 0600.
|
|
106
|
+
* - Reads the existing file (missing file => start from {}); a present-but-corrupt file is NOT
|
|
107
|
+
* silently overwritten — we throw so the user's broken-but-real config is never destroyed.
|
|
108
|
+
* - Sets config.mcpServers.pombus = block, leaving every other key untouched.
|
|
109
|
+
* - Idempotent: running twice yields the same single entry (object key assignment, not append).
|
|
110
|
+
* Returns nothing; throws ConfigWriteError on an unsafe/corrupt target. */
|
|
111
|
+
export function writeJsonConfig(path, home, key) {
|
|
112
|
+
if (!isInsideHome(path, home))
|
|
113
|
+
throw new ConfigWriteError("outside_home");
|
|
114
|
+
let root;
|
|
115
|
+
let existing = null;
|
|
116
|
+
try {
|
|
117
|
+
existing = readFileSync(path, "utf8");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
existing = null; // ENOENT (or unreadable): treat as a fresh file.
|
|
121
|
+
}
|
|
122
|
+
if (existing === null || existing.trim() === "") {
|
|
123
|
+
root = {};
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(existing);
|
|
128
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
129
|
+
throw new ConfigWriteError("corrupt_config");
|
|
130
|
+
}
|
|
131
|
+
root = parsed;
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
if (e instanceof ConfigWriteError)
|
|
135
|
+
throw e;
|
|
136
|
+
// Real config that we cannot parse: refuse to clobber it. The caller falls back to --print.
|
|
137
|
+
throw new ConfigWriteError("corrupt_config");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const servers = asObject(root["mcpServers"]);
|
|
141
|
+
servers["pombus"] = serverBlock(key);
|
|
142
|
+
root["mcpServers"] = servers;
|
|
143
|
+
const body = JSON.stringify(root, null, 2) + "\n";
|
|
144
|
+
atomicWrite(path, body);
|
|
145
|
+
}
|
|
146
|
+
/** mcpServers must be an object; a non-object (someone set it to a string/array) is replaced with a
|
|
147
|
+
* fresh object holding our entry rather than throwing — we never want to fail install on a weird but
|
|
148
|
+
* non-fatal shape, and the rest of the file is preserved. */
|
|
149
|
+
function asObject(v) {
|
|
150
|
+
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
151
|
+
? v
|
|
152
|
+
: {};
|
|
153
|
+
}
|
|
154
|
+
/** Atomic file write with owner-only perms. Temp file in the SAME directory so rename stays on one
|
|
155
|
+
* filesystem (atomic); 0600 on the temp BEFORE rename so the key never sits world-readable even for an
|
|
156
|
+
* instant; chmod again after rename for the case where the target already existed with looser perms.
|
|
157
|
+
* On Windows chmod is a no-op — NTFS inherits the profile ACL, so we do not fake a guarantee. */
|
|
158
|
+
function atomicWrite(path, body) {
|
|
159
|
+
const dir = dirname(path);
|
|
160
|
+
mkdirSync(dir, { recursive: true });
|
|
161
|
+
const tmp = join(dir, `.pombus-${process.pid}-${Date.now()}.tmp`);
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(tmp, body, { mode: 0o600 });
|
|
164
|
+
try {
|
|
165
|
+
chmodSync(tmp, 0o600);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* non-POSIX: best effort */
|
|
169
|
+
}
|
|
170
|
+
renameSync(tmp, path);
|
|
171
|
+
try {
|
|
172
|
+
chmodSync(path, 0o600);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* non-POSIX: best effort */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
try {
|
|
180
|
+
rmSync(tmp, { force: true });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* temp may not exist */
|
|
184
|
+
}
|
|
185
|
+
throw e;
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// `pombus doctor` — a cheap, read-only health check. Validates the key against GET /v0/auth/me
|
|
2
|
+
// (via ScopeBinding.resolve) and tells the human, in i18n, whether the key works and at what scope, or
|
|
3
|
+
// what is wrong (no key / invalid key / core unreachable). One request, no writes.
|
|
4
|
+
import { CoreClient, CoreError } from "./client.js";
|
|
5
|
+
import { ScopeBinding } from "./scope.js";
|
|
6
|
+
import { humanCodeForBoot, humanMessage, resolveLocale } from "./i18n.js";
|
|
7
|
+
export async function runDoctor(opts) {
|
|
8
|
+
const locale = opts.locale ?? resolveLocale();
|
|
9
|
+
const out = opts.out ?? ((l) => process.stderr.write(l + "\n"));
|
|
10
|
+
const key = (opts.apiKey ?? "").trim();
|
|
11
|
+
if (!key) {
|
|
12
|
+
out(humanMessage("no_api_key", locale));
|
|
13
|
+
return { ok: false, scope: null, code: "no_api_key" };
|
|
14
|
+
}
|
|
15
|
+
const client = new CoreClient({
|
|
16
|
+
apiKey: key,
|
|
17
|
+
...(opts.baseUrl !== undefined ? { baseUrl: opts.baseUrl } : {}),
|
|
18
|
+
...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
const scope = await ScopeBinding.resolve(client);
|
|
22
|
+
// doctor_ok's message ends with "Scope: " — append the resolved scope type (a stable, non-secret
|
|
23
|
+
// identifier: inbox/domain/pod/organization). Not localized: it is an API constant, not prose.
|
|
24
|
+
out(humanMessage("doctor_ok", locale) + scope.scopeType);
|
|
25
|
+
return { ok: true, scope: scope.scopeType, code: "doctor_ok" };
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
// Same bridge the server boot uses: a rejected key (not_found) => auth_failed, unreachable core =>
|
|
29
|
+
// network, anything else => unknown. Never leaks the key or a URL (CoreError carries only a code).
|
|
30
|
+
const code = e instanceof CoreError ? humanCodeForBoot(e.code) : "unknown";
|
|
31
|
+
out(humanMessage(code, locale));
|
|
32
|
+
return { ok: false, scope: null, code };
|
|
33
|
+
}
|
|
34
|
+
}
|