@pitcher/cli-scripts 2.21.0 → 3.0.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.
Files changed (45) hide show
  1. package/README.md +42 -41
  2. package/dist/index.js +4 -0
  3. package/dist/src/commands/analytics.d.ts +3 -3
  4. package/dist/src/commands/analytics.js +8 -6
  5. package/dist/src/commands/announcements.js +24 -3
  6. package/dist/src/commands/devSync.d.ts +3 -1
  7. package/dist/src/commands/devSync.js +26 -15
  8. package/dist/src/commands/login.d.ts +83 -0
  9. package/dist/src/commands/login.js +348 -0
  10. package/dist/src/commands/mcp.js +4 -3
  11. package/dist/src/commands/publishApp.d.ts +2 -1
  12. package/dist/src/commands/publishApp.js +10 -7
  13. package/dist/src/commands/withAuth.d.ts +5 -1
  14. package/dist/src/commands/withAuth.js +8 -3
  15. package/dist/src/core/archive.d.ts +2 -1
  16. package/dist/src/core/archive.js +3 -2
  17. package/dist/src/core/credential.d.ts +4 -16
  18. package/dist/src/core/credential.js +1 -25
  19. package/dist/src/core/http.d.ts +15 -7
  20. package/dist/src/core/http.js +33 -21
  21. package/dist/src/core/kpiTypes.d.ts +9 -0
  22. package/dist/src/core/kpiTypes.js +23 -0
  23. package/dist/src/core/ld.d.ts +3 -2
  24. package/dist/src/core/ld.js +45 -24
  25. package/dist/src/core/pitcherAuth.d.ts +86 -0
  26. package/dist/src/core/pitcherAuth.js +143 -0
  27. package/dist/src/core/session.d.ts +90 -0
  28. package/dist/src/core/session.js +293 -0
  29. package/dist/src/core/target.d.ts +47 -0
  30. package/dist/src/core/target.js +114 -0
  31. package/dist/src/mcp/local.d.ts +6 -0
  32. package/dist/src/mcp/local.js +8 -0
  33. package/dist/src/mcp/serve.d.ts +19 -4
  34. package/dist/src/mcp/serve.js +38 -15
  35. package/dist/src/mcp/server.d.ts +3 -1
  36. package/dist/src/mcp/server.js +2 -6
  37. package/dist/src/mcp/tools/analytics.js +3 -2
  38. package/dist/src/mcp/tools/publishApp.js +3 -3
  39. package/package.json +11 -6
  40. package/dist/src/core/auth.d.ts +0 -3
  41. package/dist/src/core/auth.js +0 -10
  42. package/dist/src/mcp/bundled.d.ts +0 -4
  43. package/dist/src/mcp/bundled.js +0 -25
  44. package/dist/src/mcp/env.d.ts +0 -7
  45. package/dist/src/mcp/env.js +0 -13
@@ -0,0 +1,293 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmodSync, closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { createCliError } from './errors.js';
6
+ import { isUpstreamError, normalizeDomain, refresh as auth0Refresh } from './pitcherAuth.js';
7
+ const SESSION_FILE = /^[a-z0-9-]+\.my\.pitcher\.com\.json$/;
8
+ /** Refresh this many seconds before the access token expires. */
9
+ const EXPIRY_SKEW_SEC = 120;
10
+ const LOCK_RETRY_MS = 100;
11
+ const LOCK_TIMEOUT_MS = 10000;
12
+ const LOCK_STALE_MS = 30000;
13
+ const loginHint = (domain) => `run \`p login --domain ${domain}\``;
14
+ export function configRoot(env = process.env) {
15
+ if (env.PITCHER_CONFIG_DIR)
16
+ return env.PITCHER_CONFIG_DIR;
17
+ return path.join(env.XDG_CONFIG_HOME || path.join(homedir(), '.config'), 'pitcher');
18
+ }
19
+ export function sessionsDir(env = process.env) {
20
+ return path.join(configRoot(env), 'sessions');
21
+ }
22
+ /** Only call with a normalized domain (the allowlist makes it a safe file name). */
23
+ export function sessionPath(domain, env = process.env) {
24
+ return path.join(sessionsDir(env), `${domain}.json`);
25
+ }
26
+ /** Sorted. Inclusion match on the file name, so stray files (temp, lock, .DS_Store) are never sessions. */
27
+ export function listSessionDomains(env = process.env) {
28
+ const dir = sessionsDir(env);
29
+ if (!existsSync(dir))
30
+ return [];
31
+ return readdirSync(dir)
32
+ .filter((f) => SESSION_FILE.test(f))
33
+ .map((f) => f.slice(0, -'.json'.length))
34
+ .sort();
35
+ }
36
+ /** null = no file. AUTH on too-open permissions, bad JSON, an unknown version, or a domain ≠ the file name. */
37
+ export function readSession(domain, env = process.env) {
38
+ const file = sessionPath(domain, env);
39
+ let raw;
40
+ try {
41
+ if (process.platform !== 'win32' && (statSync(file).mode & 0o077) !== 0) {
42
+ throw createCliError('AUTH', `session file permissions are too open; run \`chmod 600 ${file}\` or \`p login --domain ${domain}\``);
43
+ }
44
+ raw = readFileSync(file, 'utf8');
45
+ }
46
+ catch (e) {
47
+ if (e.code === 'ENOENT')
48
+ return null;
49
+ throw e;
50
+ }
51
+ let s;
52
+ try {
53
+ s = JSON.parse(raw);
54
+ }
55
+ catch {
56
+ throw createCliError('AUTH', `session file ${file} is not valid JSON; ${loginHint(domain)}`);
57
+ }
58
+ if (s?.version !== 1)
59
+ throw createCliError('AUTH', `session file ${file} has an unknown version; ${loginHint(domain)}`);
60
+ if (s.domain !== domain) {
61
+ throw createCliError('AUTH', `session file ${file} belongs to another domain; ${loginHint(domain)}`);
62
+ }
63
+ return s;
64
+ }
65
+ function ensureDirs(env) {
66
+ const root = configRoot(env);
67
+ const dir = sessionsDir(env);
68
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
69
+ // Either directory may predate us with a looser mode.
70
+ chmodSync(root, 0o700);
71
+ chmodSync(dir, 0o700);
72
+ }
73
+ /** Atomic replace of ONE domain's session; other domains are untouched. The caller holds the domain lock. */
74
+ export function writeSession(domain, s, env = process.env) {
75
+ ensureDirs(env);
76
+ const file = sessionPath(domain, env);
77
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
78
+ const fd = openSync(tmp, 'wx', 0o600);
79
+ try {
80
+ writeSync(fd, JSON.stringify(s, null, 2) + '\n');
81
+ }
82
+ finally {
83
+ closeSync(fd);
84
+ }
85
+ renameSync(tmp, file);
86
+ }
87
+ /** The caller holds the domain lock. */
88
+ export function deleteSession(domain, env = process.env) {
89
+ try {
90
+ unlinkSync(sessionPath(domain, env));
91
+ }
92
+ catch (e) {
93
+ if (e.code !== 'ENOENT')
94
+ throw e;
95
+ }
96
+ }
97
+ /**
98
+ * The ONE domain picker every session consumer uses: `--domain` (or `logout`'s positional) → `PITCHER_DOMAIN`
99
+ * → the single existing session. Never "most recent wins": a silent pick could send a prod write to the
100
+ * wrong org, so two or more sessions with nothing explicit is a VALIDATION error.
101
+ */
102
+ export function selectDomain(opts, env = process.env) {
103
+ const explicit = opts.domain?.trim() || env.PITCHER_DOMAIN?.trim();
104
+ if (explicit) {
105
+ const n = normalizeDomain(explicit);
106
+ if (!n.ok)
107
+ throw createCliError('VALIDATION', n.error);
108
+ if (!existsSync(sessionPath(n.domain, env))) {
109
+ throw createCliError('AUTH', `Not logged in to ${n.domain}; ${loginHint(n.domain)}`);
110
+ }
111
+ return n.domain;
112
+ }
113
+ const all = listSessionDomains(env);
114
+ if (all.length === 0)
115
+ throw createCliError('AUTH', 'Not logged in; run `p login`');
116
+ if (all.length === 1)
117
+ return all[0];
118
+ throw createCliError('VALIDATION', `Logged in to several Pitcher orgs (${all.join(', ')}); pass --domain or set PITCHER_DOMAIN`);
119
+ }
120
+ // ── per-domain lock ────────────────────────────────────────────────────────────────────────────────────────
121
+ const lockPath = (domain, env) => `${sessionPath(domain, env)}.lock`;
122
+ function readOrNull(file) {
123
+ try {
124
+ return readFileSync(file, 'utf8');
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ /**
131
+ * Break a lock that was observed stale with content `observed`, without the unlink race: MOVE it aside
132
+ * (only one waiter can move a given inode), then compare what was moved. Our stale lock → drop it. Someone
133
+ * else's fresh lock (created after we looked) → put it back with `link`, which fails harmlessly if an even
134
+ * newer lock exists. Exported for the tests.
135
+ */
136
+ export function breakStaleLock(lock, observed, waiterToken) {
137
+ const moved = `${lock}.${waiterToken}.stale`;
138
+ try {
139
+ renameSync(lock, moved);
140
+ }
141
+ catch {
142
+ return; // already gone or moved by another waiter
143
+ }
144
+ const content = readOrNull(moved);
145
+ if (content !== observed) {
146
+ try {
147
+ linkSync(moved, lock);
148
+ }
149
+ catch {
150
+ // a newer lock already exists: leave it
151
+ }
152
+ }
153
+ try {
154
+ unlinkSync(moved);
155
+ }
156
+ catch {
157
+ // nothing to clean up
158
+ }
159
+ }
160
+ /** Serialise `fn` per domain across processes (`<domain>.json.lock`, `wx`). AUTH after 10 s of waiting. */
161
+ export async function withDomainLock(domain, fn, env = process.env, opts = {}) {
162
+ const now = opts.now ?? Date.now;
163
+ const lock = lockPath(domain, env);
164
+ const token = randomBytes(16).toString('hex');
165
+ const started = now();
166
+ ensureDirs(env);
167
+ for (;;) {
168
+ try {
169
+ const fd = openSync(lock, 'wx', 0o600);
170
+ try {
171
+ writeSync(fd, JSON.stringify({ token, ts: now() }));
172
+ }
173
+ finally {
174
+ closeSync(fd);
175
+ }
176
+ break;
177
+ }
178
+ catch (e) {
179
+ if (e.code !== 'EEXIST')
180
+ throw e;
181
+ }
182
+ const observed = readOrNull(lock);
183
+ if (observed !== null) {
184
+ let ts;
185
+ try {
186
+ ts = JSON.parse(observed).ts;
187
+ }
188
+ catch {
189
+ ts = undefined; // being written right now: not stale
190
+ }
191
+ if (typeof ts === 'number' && now() - ts > (opts.staleMs ?? LOCK_STALE_MS)) {
192
+ breakStaleLock(lock, observed, token);
193
+ continue;
194
+ }
195
+ }
196
+ if (now() - started > (opts.timeoutMs ?? LOCK_TIMEOUT_MS)) {
197
+ throw createCliError('AUTH', 'session is locked by another p process');
198
+ }
199
+ await new Promise((r) => setTimeout(r, opts.retryMs ?? LOCK_RETRY_MS));
200
+ }
201
+ try {
202
+ return await fn();
203
+ }
204
+ finally {
205
+ // Release only a lock that is still ours (a waiter may have broken it as stale).
206
+ const content = readOrNull(lock);
207
+ try {
208
+ if (content !== null && JSON.parse(content).token === token)
209
+ unlinkSync(lock);
210
+ }
211
+ catch {
212
+ // unreadable or already gone
213
+ }
214
+ }
215
+ }
216
+ // ── credential + refresh ───────────────────────────────────────────────────────────────────────────────────
217
+ /** Decode (NOT verify) a JWT payload. The CLI is not a security boundary here: every backend verifies. */
218
+ export function decodeJwtPayload(token) {
219
+ try {
220
+ const part = token.split('.')[1];
221
+ if (!part)
222
+ return null;
223
+ const json = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
224
+ return json && typeof json === 'object' ? json : null;
225
+ }
226
+ catch {
227
+ return null;
228
+ }
229
+ }
230
+ const isFresh = (s, nowMs) => s.accessTokenExpiresAt - EXPIRY_SKEW_SEC > nowMs / 1000;
231
+ /**
232
+ * The session's bearer, refreshed when it is within 120 s of expiry. A refresh runs under the domain lock:
233
+ * re-read (another process may have refreshed), refresh, re-check `org_id`, then compare-and-write the
234
+ * rotated RT BEFORE returning the access token.
235
+ */
236
+ export async function sessionCredential(domain, opts = {}) {
237
+ const env = opts.env ?? process.env;
238
+ const now = opts.now ?? Date.now;
239
+ const refresh = opts.auth0?.refresh ?? ((p) => auth0Refresh(p));
240
+ const first = readSession(domain, env);
241
+ if (!first)
242
+ throw createCliError('AUTH', `Not logged in to ${domain}; ${loginHint(domain)}`);
243
+ if (isFresh(first, now()))
244
+ return { kind: 'bearer', token: first.accessToken };
245
+ return withDomainLock(domain, async () => {
246
+ const s = readSession(domain, env);
247
+ if (!s)
248
+ throw createCliError('AUTH', `Not logged in to ${domain}; ${loginHint(domain)}`);
249
+ if (isFresh(s, now()))
250
+ return { kind: 'bearer', token: s.accessToken };
251
+ const sentRt = s.refreshToken;
252
+ let t;
253
+ try {
254
+ t = await refresh({ auth0Domain: s.auth0Domain, clientId: s.clientId, refreshToken: sentRt });
255
+ }
256
+ catch (e) {
257
+ if (isUpstreamError(e) && e.kind === 'grant') {
258
+ // Compare-and-delete: never delete a session we did not read.
259
+ if (readSession(domain, env)?.refreshToken === sentRt)
260
+ deleteSession(domain, env);
261
+ throw createCliError('AUTH', `Your session for ${domain} expired; ${loginHint(domain)}`);
262
+ }
263
+ throw createCliError('AUTH', `could not refresh the ${domain} session (network)`);
264
+ }
265
+ const claims = decodeJwtPayload(t.access_token);
266
+ // Auth0 has already rotated sentRt, so it is spent: replaying it later trips reuse detection, which
267
+ // revokes the token family and the user's SSO session. Compare-and-delete instead of leaving it on disk.
268
+ const rejectRotated = (why) => {
269
+ if (readSession(domain, env)?.refreshToken === sentRt)
270
+ deleteSession(domain, env);
271
+ return createCliError('AUTH', `${why}; ${loginHint(domain)}`);
272
+ };
273
+ if (claims?.org_id !== s.organization)
274
+ throw rejectRotated('refreshed token belongs to another organization');
275
+ if (typeof claims.exp !== 'number')
276
+ throw rejectRotated('refreshed token has no expiry');
277
+ // Compare-and-write: only if the file still holds the RT we sent.
278
+ const current = readSession(domain, env);
279
+ if (current?.refreshToken !== sentRt) {
280
+ if (current && isFresh(current, now()))
281
+ return { kind: 'bearer', token: current.accessToken };
282
+ throw createCliError('AUTH', `session changed while refreshing; retry`);
283
+ }
284
+ writeSession(domain, {
285
+ ...current,
286
+ accessToken: t.access_token,
287
+ accessTokenExpiresAt: claims.exp,
288
+ refreshToken: t.refresh_token ?? sentRt,
289
+ updatedAt: Math.floor(now() / 1000),
290
+ }, env);
291
+ return { kind: 'bearer', token: t.access_token };
292
+ }, env, opts.lock);
293
+ }
@@ -0,0 +1,47 @@
1
+ import type { Credential } from './credential.js';
2
+ /**
3
+ * The ONE target resolver: credential AND origin come from the same rule, so a `p login` token can never be
4
+ * sent to a host another rule picked (PIT-8943, D-14). Every client builder goes through here.
5
+ *
6
+ * ⛔ Bypass guard (`test/commands/noBypass.test.ts`): outside this file and `baseUrl.ts`, nothing under `src/`
7
+ * may call `resolveBaseUrl(` / `resolveApiKey(` or write an auth header literal. That is why `authHeaders` and
8
+ * `resolveApiKey` live HERE.
9
+ */
10
+ /** A static credential, or one resolved per request (a session refreshes when its access token nears expiry). */
11
+ export type CredentialSource = Credential | (() => Promise<Credential>);
12
+ export interface RequestTarget {
13
+ /** Origin, e.g. `https://acme.my.pitcher.com` */
14
+ baseUrl: string;
15
+ /** `new URL(baseUrl).hostname`: NO port */
16
+ host: string;
17
+ source: CredentialSource;
18
+ via: 'explicit' | 'apiKeyFlag' | 'session' | 'apiKeyEnv';
19
+ }
20
+ export interface TargetOpts {
21
+ apiKey?: string;
22
+ /** Explicit credential (the hosted MCP server's bearer, or local `p mcp`'s resolved session). */
23
+ credential?: Credential;
24
+ /** A `p login` session domain (`acme.my.pitcher.com`). */
25
+ domain?: string;
26
+ org?: string;
27
+ url?: string;
28
+ }
29
+ export interface TargetDeps {
30
+ /** Where the precedence notices go. Default: stderr, each notice once per process. */
31
+ warn?: (message: string) => void;
32
+ isTty?: boolean;
33
+ }
34
+ /** The auth header(s) for a credential. Exactly one header, never both. */
35
+ export declare function authHeaders(c: Credential): Record<string, string>;
36
+ /** `--api-key` then `NG_API_KEY` (trimmed; blank = absent). Only the target resolver calls it. */
37
+ export declare function resolveApiKey(opts: {
38
+ apiKey?: string;
39
+ }, env?: NodeJS.ProcessEnv): string;
40
+ /**
41
+ * Precedence: explicit credential → `--api-key` → a `p login` session → `NG_API_KEY` (OD-4: an exported key
42
+ * never shadows a login) → AUTH. With sessions present but the domain ambiguous or not logged in, the result is
43
+ * `selectDomain`'s error, NEVER a silent fall back to `NG_API_KEY`.
44
+ */
45
+ export declare function resolveRequestTarget(opts: TargetOpts, env?: NodeJS.ProcessEnv, deps?: TargetDeps): RequestTarget;
46
+ /** Resolve a source to a concrete credential (static as is, or a session's possibly refreshed bearer). */
47
+ export declare function credentialFrom(source: CredentialSource): Promise<Credential>;
@@ -0,0 +1,114 @@
1
+ import { resolveBaseUrl } from './baseUrl.js';
2
+ import { createCliError } from './errors.js';
3
+ import { normalizeDomain } from './pitcherAuth.js';
4
+ import { listSessionDomains, selectDomain, sessionCredential } from './session.js';
5
+ /** The auth header(s) for a credential. Exactly one header, never both. */
6
+ export function authHeaders(c) {
7
+ return c.kind === 'bearer' ? { authorization: `Bearer ${c.token}` } : { 'x-api-key': c.key };
8
+ }
9
+ /** `--api-key` then `NG_API_KEY` (trimmed; blank = absent). Only the target resolver calls it. */
10
+ export function resolveApiKey(opts, env = process.env) {
11
+ const flagKey = opts.apiKey?.trim();
12
+ if (flagKey)
13
+ return flagKey;
14
+ const envKey = env.NG_API_KEY?.trim();
15
+ if (envKey)
16
+ return envKey;
17
+ throw createCliError('AUTH', 'API key is required. Provide --api-key or set the NG_API_KEY environment variable.');
18
+ }
19
+ const NOT_LOGGED_IN = 'Not logged in. Run `p login` (automation: pass --api-key).';
20
+ const KEY_ON_TTY = 'API keys are for automation; run `p login` for your own account';
21
+ const ENV_KEY_IGNORED = 'NG_API_KEY ignored: using your p login session (pass --api-key to use a key)';
22
+ const warnedOnce = new Set();
23
+ function defaultWarn(message) {
24
+ if (warnedOnce.has(message))
25
+ return;
26
+ warnedOnce.add(message);
27
+ process.stderr.write(`warning: ${message}\n`);
28
+ }
29
+ function checkedExplicit(c) {
30
+ const value = c.kind === 'bearer' ? c.token : c.key;
31
+ if (typeof value !== 'string' || !value.trim()) {
32
+ throw createCliError('AUTH', `Empty ${c.kind === 'bearer' ? 'bearer token' : 'API key'} credential`);
33
+ }
34
+ return c;
35
+ }
36
+ const hostOf = (baseUrl) => new URL(baseUrl).hostname;
37
+ /**
38
+ * The session domain an explicit `--url`/`--publish-url` names, or a refusal. Only `https://<org>.my.pitcher.com`
39
+ * (no port) can carry a session token; anything else would hand the user's token to another host.
40
+ */
41
+ function sessionDomainOfUrl(url) {
42
+ let u;
43
+ try {
44
+ u = new URL(url);
45
+ }
46
+ catch {
47
+ throw createCliError('VALIDATION', `Invalid base URL: ${url}`);
48
+ }
49
+ const n = normalizeDomain(u.hostname);
50
+ if (u.protocol !== 'https:' || u.port || !n.ok || n.domain !== u.hostname) {
51
+ throw createCliError('VALIDATION', `refusing to send your Pitcher session to ${u.host}`);
52
+ }
53
+ return n.domain;
54
+ }
55
+ /**
56
+ * Precedence: explicit credential → `--api-key` → a `p login` session → `NG_API_KEY` (OD-4: an exported key
57
+ * never shadows a login) → AUTH. With sessions present but the domain ambiguous or not logged in, the result is
58
+ * `selectDomain`'s error, NEVER a silent fall back to `NG_API_KEY`.
59
+ */
60
+ export function resolveRequestTarget(opts, env = process.env, deps = {}) {
61
+ const warn = deps.warn ?? defaultWarn;
62
+ const isTty = deps.isTty ?? Boolean(process.stderr.isTTY);
63
+ const warnKeyOnTty = () => {
64
+ if (isTty && !env.CI)
65
+ warn(KEY_ON_TTY);
66
+ };
67
+ // 0.
68
+ if (opts.domain && opts.org)
69
+ throw createCliError('VALIDATION', 'use --domain OR --org, not both');
70
+ // 1. explicit credential (hosted MCP, local p mcp): never falls through, fails closed.
71
+ if (opts.credential) {
72
+ const baseUrl = resolveBaseUrl({ org: opts.org, url: opts.url });
73
+ return { baseUrl, host: hostOf(baseUrl), source: checkedExplicit(opts.credential), via: 'explicit' };
74
+ }
75
+ // 2. --api-key flag (automation)
76
+ const flagKey = opts.apiKey?.trim();
77
+ if (flagKey) {
78
+ if (opts.domain) {
79
+ throw createCliError('VALIDATION', '--domain selects a p login session; with --api-key use --org/--url');
80
+ }
81
+ warnKeyOnTty();
82
+ const baseUrl = resolveBaseUrl({ org: opts.org, url: opts.url });
83
+ return { baseUrl, host: hostOf(baseUrl), source: { kind: 'apiKey', key: flagKey }, via: 'apiKeyFlag' };
84
+ }
85
+ // 3. a p login session
86
+ if (listSessionDomains(env).length > 0) {
87
+ const urlDomain = opts.url ? sessionDomainOfUrl(opts.url) : undefined;
88
+ const named = opts.domain ?? (opts.org ? `${opts.org}.my.pitcher.com` : undefined);
89
+ const domain = selectDomain({ domain: named ?? urlDomain }, env);
90
+ if (urlDomain && urlDomain !== domain) {
91
+ throw createCliError('VALIDATION', `refusing to send your Pitcher session to ${urlDomain}`);
92
+ }
93
+ if (env.NG_API_KEY?.trim())
94
+ warn(ENV_KEY_IGNORED);
95
+ const baseUrl = `https://${domain}`;
96
+ return { baseUrl, host: domain, source: () => sessionCredential(domain, { env }), via: 'session' };
97
+ }
98
+ // --domain names a session, and none exist.
99
+ if (opts.domain)
100
+ selectDomain({ domain: opts.domain }, env);
101
+ // 4. NG_API_KEY (CI: no session exists, so CI is unaffected)
102
+ const envKey = env.NG_API_KEY?.trim();
103
+ if (envKey) {
104
+ warnKeyOnTty();
105
+ const baseUrl = resolveBaseUrl({ org: opts.org, url: opts.url });
106
+ return { baseUrl, host: hostOf(baseUrl), source: { kind: 'apiKey', key: envKey }, via: 'apiKeyEnv' };
107
+ }
108
+ // 5.
109
+ throw createCliError('AUTH', NOT_LOGGED_IN);
110
+ }
111
+ /** Resolve a source to a concrete credential (static as is, or a session's possibly refreshed bearer). */
112
+ export async function credentialFrom(source) {
113
+ return typeof source === 'function' ? source() : source;
114
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The tool set of LOCAL `p mcp` (PIT-8943): only the three disk-bound tools (`dev_sync`, `dev_sync_revert`,
3
+ * `publish_app`), which zip a folder on this machine and so cannot run hosted. Everything else is the hosted
4
+ * server (`https://pitcher-mcp.fly.dev/mcp`).
5
+ */
6
+ export declare const localTools: import("./index.js").ToolDescriptor[];
@@ -0,0 +1,8 @@
1
+ import { LOCAL_ONLY } from './hosted.js';
2
+ import { allTools } from './registry.js';
3
+ /**
4
+ * The tool set of LOCAL `p mcp` (PIT-8943): only the three disk-bound tools (`dev_sync`, `dev_sync_revert`,
5
+ * `publish_app`), which zip a folder on this machine and so cannot run hosted. Everything else is the hosted
6
+ * server (`https://pitcher-mcp.fly.dev/mcp`).
7
+ */
8
+ export const localTools = allTools.filter((t) => LOCAL_ONLY.has(t.name));
@@ -1,4 +1,19 @@
1
- import type { ToolDescriptor } from './tools/types.js';
2
- import type { PromptDescriptor } from './prompts/types.js';
3
- import type { ResourceDescriptor } from './resources/types.js';
4
- export declare function runMcpServe(env?: NodeJS.ProcessEnv, tools?: ToolDescriptor[], prompts?: PromptDescriptor[], resources?: ResourceDescriptor[]): Promise<void>;
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type SessionCredentialOpts } from '../core/session.js';
3
+ export interface LocalServer {
4
+ server: McpServer;
5
+ domain: string;
6
+ destructiveWrites: boolean;
7
+ }
8
+ /**
9
+ * The local `p mcp` server on the `p login` session (PIT-8943). The domain is picked once with `selectDomain`
10
+ * (`--domain` → `PITCHER_DOMAIN` → the only session) and fixed for the life of the process; API keys are never
11
+ * read. `session` is a test seam for the refresh clock and Auth0.
12
+ */
13
+ export declare function buildLocalServer(opts?: {
14
+ domain?: string;
15
+ }, env?: NodeJS.ProcessEnv, session?: Omit<SessionCredentialOpts, 'env'>): Promise<LocalServer>;
16
+ /** `p mcp`: connect the local server over stdio. Never writes to stdout except protocol frames. */
17
+ export declare function runMcpServe(opts?: {
18
+ domain?: string;
19
+ }, env?: NodeJS.ProcessEnv): Promise<void>;
@@ -1,18 +1,41 @@
1
1
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
- import { resolveMcpEnv } from './env.js';
2
+ import { createCliError } from '../core/errors.js';
3
+ import { orgSubdomain } from '../core/pitcherAuth.js';
4
+ import { readSession, selectDomain, sessionCredential } from '../core/session.js';
5
+ import { localTools } from './local.js';
3
6
  import { buildServer } from './server.js';
4
- import { allTools } from './registry.js';
5
- import { allPrompts } from './prompts/registry.js';
6
- import { allResources } from './resources/registry.js';
7
- // Resolves auth/env, builds the server, connects stdio. Never writes to stdout except protocol frames.
8
- // `tools` defaults to the full set (the CLI `p mcp`); the .mcpb bundle passes a curated subset.
9
- // Prompts/resources default to the full set for every frontend (no subsetting).
10
- export async function runMcpServe(env = process.env, tools = allTools, prompts = allPrompts, resources = allResources) {
11
- const mcpEnv = resolveMcpEnv(env);
12
- const server = buildServer({ credential: { kind: 'apiKey', key: mcpEnv.apiKey }, org: mcpEnv.org, url: mcpEnv.url }, tools, prompts, resources, { destructiveWrites: mcpEnv.prod });
13
- const transport = new StdioServerTransport();
14
- await server.connect(transport);
15
- const target = mcpEnv.url ?? `https://${mcpEnv.org}.my.pitcher.com`;
16
- console.error(`[pitcher-mcp] serving ${tools.length} tools, ${prompts.length} prompts, ${resources.length} resources ` +
17
- `against ${target}${mcpEnv.prod ? ' (prod: destructive-hints on)' : ''}`);
7
+ /**
8
+ * The local `p mcp` server on the `p login` session (PIT-8943). The domain is picked once with `selectDomain`
9
+ * (`--domain` → `PITCHER_DOMAIN` → the only session) and fixed for the life of the process; API keys are never
10
+ * read. `session` is a test seam for the refresh clock and Auth0.
11
+ */
12
+ export async function buildLocalServer(opts = {}, env = process.env, session = {}) {
13
+ const domain = selectDomain(opts, env);
14
+ const s = readSession(domain, env);
15
+ if (!s)
16
+ throw createCliError('AUTH', `Not logged in to ${domain}; run \`p login --domain ${domain}\``);
17
+ // FAIL CLOSED: `environment` is optional upstream, so unknown is treated as production.
18
+ const destructiveWrites = s.environment !== 'test';
19
+ const credentialOpts = { ...session, env };
20
+ const server = buildServer({
21
+ // Boot check: a dead session fails at start, not on the first tool call.
22
+ credential: await sessionCredential(domain, credentialOpts),
23
+ resolveCredential: async () => {
24
+ if (!readSession(domain, env)) {
25
+ throw createCliError('AUTH', `Logged out of ${domain}; run \`p login --domain ${domain}\` and restart`);
26
+ }
27
+ return sessionCredential(domain, credentialOpts);
28
+ },
29
+ // NEVER `url`: runPublishApp treats `url` as the publish endpoint, so the org is what targets the session.
30
+ org: orgSubdomain(domain),
31
+ }, localTools, [], // no prompts/resources: none references a local tool
32
+ [], { destructiveWrites });
33
+ return { server, domain, destructiveWrites };
34
+ }
35
+ /** `p mcp`: connect the local server over stdio. Never writes to stdout except protocol frames. */
36
+ export async function runMcpServe(opts = {}, env = process.env) {
37
+ const { server, domain, destructiveWrites } = await buildLocalServer(opts, env);
38
+ await server.connect(new StdioServerTransport());
39
+ console.error(`[pitcher-mcp] serving ${localTools.length} local tools against https://${domain}` +
40
+ (destructiveWrites ? ' (destructive-hints on)' : ''));
18
41
  }
@@ -5,11 +5,13 @@ import type { PromptDescriptor } from './prompts/types.js';
5
5
  import type { ResourceDescriptor } from './resources/types.js';
6
6
  /**
7
7
  * The per-server request context: who every tool call runs as, and against which origin.
8
- * A plain value captured at build time — stdio builds one from env (API key); the hosted server
8
+ * Captured at build time — local stdio `p mcp` builds one from the `p login` session; the hosted server
9
9
  * builds one server per request from the caller's bearer token. Never a tool input.
10
10
  */
11
11
  export interface McpRunContext {
12
12
  credential: Credential;
13
+ /** Local stdio only: resolved before EVERY tool call (refresh-aware). When set it wins over `credential`. Hosted never sets it. */
14
+ resolveCredential?: () => Promise<Credential>;
13
15
  org?: string;
14
16
  url?: string;
15
17
  }
@@ -9,11 +9,6 @@ import { envelopeToToolResult, toErrorResult } from './result.js';
9
9
  import { ANALYTICS_RENDER_POLICY } from './renderPolicy.js';
10
10
  import { isCliError } from '../core/errors.js';
11
11
  const version = (() => {
12
- // The .mcpb bundle inlines this at build time (esbuild define) because the bundled single
13
- // file has no package.json to walk to. Undefined at CLI runtime → the readFileSync path below.
14
- const injected = process.env.PITCHER_CLI_VERSION;
15
- if (injected)
16
- return injected;
17
12
  try {
18
13
  const dir = dirname(fileURLToPath(import.meta.url));
19
14
  return JSON.parse(readFileSync(join(dir, '..', '..', '..', 'package.json'), 'utf8')).version ?? '0.0.0';
@@ -55,8 +50,9 @@ export function buildServer(ctx, tools = allTools, prompts = allPrompts, resourc
55
50
  annotations: { readOnlyHint: tool.readOnly, destructiveHint: destructive },
56
51
  }, async (args) => {
57
52
  try {
53
+ const credential = ctx.resolveCredential ? await ctx.resolveCredential() : ctx.credential;
58
54
  // spread args FIRST so injected auth/env always wins — never reorder to { credential, ...args }
59
- const envelope = await tool.run({ ...args, credential: ctx.credential, org: ctx.org, url: ctx.url });
55
+ const envelope = await tool.run({ ...args, credential, org: ctx.org, url: ctx.url });
60
56
  return envelopeToToolResult(envelope);
61
57
  }
62
58
  catch (e) {
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { KPI_TYPES } from '../../core/kpiTypes.js';
2
3
  import { runAnalyticsOrg, runAnalyticsInstance, runAnalyticsKpi, runAnalyticsEngagement, runAnalyticsPopularity, runAnalyticsUsers, runAnalyticsFiles, runAnalyticsCanvases, runAnalyticsViewers, runAnalyticsSessions, } from '../../commands/analytics.js';
3
4
  export const analyticsTools = [
4
5
  {
@@ -21,11 +22,11 @@ export const analyticsTools = [
21
22
  name: 'pitcher_analytics_kpi',
22
23
  title: 'Instance KPI metrics',
23
24
  description: 'Read KPI metrics for an instance from the clickhouse analytics service (PIT-7647). ' +
24
- 'Access follows the API key user’s real role; activity-metric types need start/end dates ' +
25
+ 'Access follows the signed-in user’s own role; activity-metric types need start/end dates ' +
25
26
  '(inventory counts like total-members do not).',
26
27
  inputShape: {
27
28
  instanceId: z.string().describe('Instance ID (required)'),
28
- type: z.string().describe('KPI type, e.g. total-members, total-active-users (required)'),
29
+ type: z.enum(KPI_TYPES).describe('KPI type'),
29
30
  startDate: z.string().optional().describe('Window start, ISO date (required by the server for activity metrics)'),
30
31
  endDate: z.string().optional().describe('Window end, ISO date (required by the server for activity metrics)'),
31
32
  groupIds: z.string().optional().describe('Comma-separated distribution-group IDs to scope by'),
@@ -12,7 +12,7 @@ export const publishAppTools = [
12
12
  'optionally installing it to specific instances or all instances of an environment afterward. ' +
13
13
  'Consequential: this creates a live, org-visible app release — use dryRun to preview first. ' +
14
14
  'Re-publishing an already-existing version is a no-op (returns ok with a warning), not a failure. ' +
15
- 'Publish targets the org resolved from NG_ORG; the injected NG_URL is not used as the publish endpoint.',
15
+ 'Publishes to the org of the `p login` session this server runs on.',
16
16
  inputShape: {
17
17
  folder: z.string().optional().describe('Local folder to zip and publish (defaults to the MCP server cwd)'),
18
18
  incrementVersion: z.boolean().optional().describe('Bump the version in app.json before publishing'),
@@ -20,8 +20,8 @@ export const publishAppTools = [
20
20
  installTo: z.string().optional().describe('Comma-separated instance IDs to install the app to after publishing'),
21
21
  installToEnvName: z.string().optional().describe('Environment name (e.g. "test") to install the app to after publishing'),
22
22
  },
23
- // runPublishApp overloads `url` as BOTH origin and publish endpoint; the server injects NG_URL into
24
- // every tool, so forward only apiKey + credential + org (never url) plus this tool's own inputs.
23
+ // runPublishApp overloads `url` as BOTH origin and publish endpoint, so forward only apiKey + credential +
24
+ // org (never url) plus this tool's own inputs; local `p mcp` targets its session through `org`.
25
25
  run: (opts) => runPublishApp({
26
26
  folder: asString(opts.folder),
27
27
  incrementVersion: asBool(opts.incrementVersion),