@pitcher/cli-scripts 2.20.1 → 3.0.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.
Files changed (46) hide show
  1. package/README.md +45 -45
  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 +17 -14
  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/sharedLinks.d.ts +0 -2
  14. package/dist/src/commands/sharedLinks.js +10 -20
  15. package/dist/src/commands/withAuth.d.ts +5 -1
  16. package/dist/src/commands/withAuth.js +8 -3
  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/dist/src/mcp/tools/sharedLinks.js +4 -6
  40. package/package.json +11 -6
  41. package/dist/src/core/auth.d.ts +0 -3
  42. package/dist/src/core/auth.js +0 -10
  43. package/dist/src/mcp/bundled.d.ts +0 -4
  44. package/dist/src/mcp/bundled.js +0 -25
  45. package/dist/src/mcp/env.d.ts +0 -7
  46. package/dist/src/mcp/env.js +0 -13
@@ -0,0 +1,348 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { createServer } from 'node:http';
4
+ import { createInterface } from 'node:readline';
5
+ import { Command } from 'commander';
6
+ import { authHeaders } from '../core/target.js';
7
+ import { ok, fail } from '../core/envelope.js';
8
+ import { createCliError, isCliError } from '../core/errors.js';
9
+ import { emitEnvelope, log } from '../core/output.js';
10
+ import { AUTH0_NATIVE_CLIENT_IDS, buildAuthorizeUrl, exchangeCode, fetchOrgConfig, isUpstreamError, normalizeDomain, } from '../core/pitcherAuth.js';
11
+ import { decodeJwtPayload, deleteSession, listSessionDomains, readSession, selectDomain, sessionCredential, withDomainLock, writeSession, } from '../core/session.js';
12
+ /** The loopback redirect registered on both Auth0 Native apps (`http://127.0.0.1:53682/callback`). */
13
+ export const LOGIN_PORT = 53682;
14
+ const LOGIN_TIMEOUT_MS = 300000;
15
+ const b64url = (b) => b.toString('base64url');
16
+ /**
17
+ * The URL-opening command per platform. Windows uses the URL handler directly: through `cmd /c start`,
18
+ * cmd.exe would split the unquoted URL at its first `&` and drop client_id, state and the PKCE challenge.
19
+ */
20
+ export function browserCommand(url, platform) {
21
+ if (platform === 'darwin')
22
+ return ['open', [url]];
23
+ if (platform === 'win32')
24
+ return ['rundll32', ['url.dll,FileProtocolHandler', url]];
25
+ return ['xdg-open', [url]];
26
+ }
27
+ function defaultOpenBrowser(url) {
28
+ const [cmd, args] = browserCommand(url, process.platform);
29
+ try {
30
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
31
+ child.on('error', () => undefined);
32
+ child.unref();
33
+ }
34
+ catch {
35
+ // the URL is printed to stderr regardless
36
+ }
37
+ }
38
+ function defaultPrompt(question) {
39
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
40
+ return new Promise((resolve) => rl.question(question, (answer) => {
41
+ rl.close();
42
+ resolve(answer);
43
+ }));
44
+ }
45
+ /** Equal byte length first: `timingSafeEqual` throws on unequal lengths. */
46
+ function stateMatches(received, expected) {
47
+ const a = Buffer.from(received);
48
+ const b = Buffer.from(expected);
49
+ return a.length === b.length && timingSafeEqual(a, b);
50
+ }
51
+ /** `GET /api/v1/users/me/` with the bearer: proves the token works on Django and yields the email. */
52
+ export async function fetchMe(domain, cred, doFetch) {
53
+ let res;
54
+ try {
55
+ res = await doFetch(`https://${domain}/api/v1/users/me/`, {
56
+ headers: { accept: 'application/json', ...authHeaders(cred) },
57
+ redirect: 'error',
58
+ signal: AbortSignal.timeout(10000),
59
+ });
60
+ }
61
+ catch {
62
+ throw createCliError('NETWORK', `could not reach ${domain}`);
63
+ }
64
+ if (res.status === 401 || res.status === 403)
65
+ throw createCliError('AUTH', `${domain} refused the session`);
66
+ if (!res.ok)
67
+ throw createCliError('NETWORK', `${domain} answered ${res.status} for users/me`);
68
+ const body = (await res.json().catch(() => ({})));
69
+ return { email: typeof body.email === 'string' ? body.email : undefined };
70
+ }
71
+ /** One-shot loopback listener on 127.0.0.1 ONLY (never 0.0.0.0). Resolves with the code or rejects AUTH. */
72
+ function listenForCallback(port, state, timeoutMs, onListen) {
73
+ return new Promise((resolveListen, rejectListen) => {
74
+ let settle;
75
+ const result = new Promise((resolve, reject) => {
76
+ settle = { resolve, reject };
77
+ });
78
+ let done = false;
79
+ let timer;
80
+ const server = createServer((req, res) => {
81
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
82
+ if (req.method !== 'GET' || url.pathname !== '/callback' || done) {
83
+ res.writeHead(404, { 'content-type': 'text/plain' }).end('Not found');
84
+ return;
85
+ }
86
+ done = true;
87
+ res
88
+ .writeHead(200, {
89
+ 'content-type': 'text/plain; charset=utf-8',
90
+ 'cache-control': 'no-store',
91
+ 'referrer-policy': 'no-referrer',
92
+ })
93
+ .end('You can close this tab and return to the terminal.');
94
+ close();
95
+ const error = url.searchParams.get('error');
96
+ if (error) {
97
+ settle?.reject(createCliError('AUTH', `login failed: ${error.slice(0, 64)}`));
98
+ return;
99
+ }
100
+ if (!stateMatches(url.searchParams.get('state') ?? '', state)) {
101
+ settle?.reject(createCliError('AUTH', 'login failed: state mismatch'));
102
+ return;
103
+ }
104
+ const code = url.searchParams.get('code');
105
+ if (!code) {
106
+ settle?.reject(createCliError('AUTH', 'login failed: no authorization code'));
107
+ return;
108
+ }
109
+ settle?.resolve({ code });
110
+ });
111
+ const close = () => {
112
+ if (timer)
113
+ clearTimeout(timer);
114
+ server.closeAllConnections?.();
115
+ server.close();
116
+ };
117
+ server.once('error', (e) => {
118
+ rejectListen(e.code === 'EADDRINUSE'
119
+ ? createCliError('AUTH', `port ${port} is busy (another p login?)`)
120
+ : createCliError('AUTH', `could not listen on 127.0.0.1:${port}`));
121
+ });
122
+ server.listen(port, '127.0.0.1', () => {
123
+ const address = server.address();
124
+ onListen?.(address);
125
+ timer = setTimeout(() => {
126
+ if (done)
127
+ return;
128
+ done = true;
129
+ close();
130
+ settle?.reject(createCliError('AUTH', 'login timed out'));
131
+ }, timeoutMs);
132
+ resolveListen({ redirectUri: `http://127.0.0.1:${address.port}/callback`, result, close });
133
+ });
134
+ });
135
+ }
136
+ export async function runLogin(opts, deps = {}) {
137
+ const env = deps.env ?? process.env;
138
+ const doFetch = deps.fetch ?? globalThis.fetch;
139
+ const now = deps.now ?? Date.now;
140
+ try {
141
+ // 1. domain
142
+ let input = opts.domain;
143
+ if (!input) {
144
+ if (!(deps.isTty ?? Boolean(process.stdin.isTTY && process.stderr.isTTY))) {
145
+ throw createCliError('VALIDATION', 'pass --domain (e.g. --domain acme.my.pitcher.com)');
146
+ }
147
+ input = await (deps.prompt ?? defaultPrompt)('Pitcher domain (e.g. acme.my.pitcher.com): ');
148
+ }
149
+ const n = normalizeDomain(input);
150
+ if (!n.ok)
151
+ throw createCliError('VALIDATION', n.error);
152
+ const domain = n.domain;
153
+ // 2-3. org-config → tenant → Native client id
154
+ let cfg;
155
+ try {
156
+ cfg = await fetchOrgConfig(domain, { fetch: doFetch });
157
+ }
158
+ catch (e) {
159
+ if (isUpstreamError(e))
160
+ throw createCliError('NETWORK', `could not reach ${domain} (${e.message})`);
161
+ throw e;
162
+ }
163
+ if (!cfg || !cfg.organization)
164
+ throw createCliError('AUTH', 'This organization has no MCP login configured');
165
+ const organization = cfg.organization;
166
+ const clientId = AUTH0_NATIVE_CLIENT_IDS[cfg.auth0Domain];
167
+ if (!clientId)
168
+ throw createCliError('AUTH', `Pitcher login is not enabled for ${cfg.auth0Domain} yet`);
169
+ // 4. PKCE + state
170
+ const verifier = b64url(randomBytes(32));
171
+ const codeChallenge = b64url(createHash('sha256').update(verifier).digest());
172
+ const state = b64url(randomBytes(32));
173
+ // 5-7. loopback listener, browser, callback
174
+ const listener = await listenForCallback(deps.port ?? LOGIN_PORT, state, deps.timeoutMs ?? LOGIN_TIMEOUT_MS, deps.onListen);
175
+ const authorizeUrl = buildAuthorizeUrl({
176
+ auth0Domain: cfg.auth0Domain,
177
+ clientId,
178
+ redirectUri: listener.redirectUri,
179
+ organization,
180
+ audience: cfg.audience,
181
+ state,
182
+ codeChallenge,
183
+ });
184
+ // The URL carries only public values (client id, PKCE challenge, state), so stderr is fine.
185
+ log(`Open this URL to sign in to ${domain}:\n${authorizeUrl}`, { quiet: false });
186
+ if (opts.browser !== false)
187
+ (deps.openBrowser ?? defaultOpenBrowser)(authorizeUrl);
188
+ let code;
189
+ try {
190
+ ;
191
+ ({ code } = await listener.result);
192
+ }
193
+ finally {
194
+ listener.close();
195
+ }
196
+ // 8. tokens
197
+ let tokens;
198
+ try {
199
+ tokens = await exchangeCode({ auth0Domain: cfg.auth0Domain, clientId, code, verifier, redirectUri: listener.redirectUri }, { fetch: doFetch });
200
+ }
201
+ catch (e) {
202
+ if (isUpstreamError(e))
203
+ throw createCliError('AUTH', `login failed: ${e.message}`);
204
+ throw e;
205
+ }
206
+ if (!tokens.refresh_token) {
207
+ throw createCliError('AUTH', 'login returned no refresh token (offline_access is not enabled for this app)');
208
+ }
209
+ // 9. consistency check (not a security boundary: every backend verifies the JWT)
210
+ const claims = decodeJwtPayload(tokens.access_token);
211
+ if (claims?.org_id !== organization || typeof claims.exp !== 'number' || typeof claims.sub !== 'string') {
212
+ throw createCliError('AUTH', 'login landed in a different organization');
213
+ }
214
+ const exp = claims.exp;
215
+ const sub = claims.sub;
216
+ // 10. proves the token works on Django
217
+ const me = await fetchMe(domain, { kind: 'bearer', token: tokens.access_token }, doFetch);
218
+ // 11. save (the lock is held only around the write, never while the browser is open)
219
+ const refreshToken = tokens.refresh_token;
220
+ await withDomainLock(domain, () => writeSession(domain, {
221
+ version: 1,
222
+ domain,
223
+ environment: cfg.environment,
224
+ auth0Domain: cfg.auth0Domain,
225
+ clientId,
226
+ audience: cfg.audience,
227
+ organization,
228
+ sub,
229
+ email: me.email,
230
+ accessToken: tokens.access_token,
231
+ accessTokenExpiresAt: exp,
232
+ refreshToken,
233
+ updatedAt: Math.floor(now() / 1000),
234
+ }, env), env);
235
+ log(`Logged in as ${me.email ?? sub} to ${domain}${cfg.environment === 'test' ? ' [test]' : ''}`, {
236
+ quiet: deps.quiet,
237
+ });
238
+ return ok({ domain, environment: cfg.environment, email: me.email, sub, expiresAt: exp });
239
+ }
240
+ catch (e) {
241
+ return fail(isCliError(e) ? e : createCliError('UNKNOWN', e instanceof Error ? e.message : String(e)));
242
+ }
243
+ }
244
+ export async function runLogout(opts, deps = {}) {
245
+ const env = deps.env ?? process.env;
246
+ try {
247
+ if (opts.all && opts.domain)
248
+ throw createCliError('VALIDATION', 'use a domain OR --all, not both');
249
+ const existing = listSessionDomains(env);
250
+ if (existing.length === 0) {
251
+ log('Not logged in', { quiet: deps.quiet });
252
+ return ok({ loggedOut: [] });
253
+ }
254
+ const domains = opts.all ? existing : [selectDomain({ domain: opts.domain }, env)];
255
+ for (const domain of domains) {
256
+ // ⛔ LOCAL ONLY, deliberately no Auth0 /oauth/revoke: the CLI shares ONE Native client id per tenant with
257
+ // the hosted MCP server, and with the tenant toggle "Refresh Token Revocation Deletes Grant" (not readable
258
+ // via the Management API, so it cannot be guaranteed off) a revoke kills EVERY refresh token for that
259
+ // user + client + audience, i.e. the user's ChatGPT/Claude connectors too. The token expires on its own.
260
+ await withDomainLock(domain, () => deleteSession(domain, env), env);
261
+ log(`Logged out of ${domain}`, { quiet: deps.quiet });
262
+ }
263
+ return ok({ loggedOut: domains });
264
+ }
265
+ catch (e) {
266
+ return fail(isCliError(e) ? e : createCliError('UNKNOWN', e instanceof Error ? e.message : String(e)));
267
+ }
268
+ }
269
+ /**
270
+ * One whoami row's status, matched on the specific failure (never "any AUTH"): only a session Auth0 or Django
271
+ * no longer accepts is `expired`; a held lock is `busy`; a file we refuse to read is `invalid session file`;
272
+ * anything else (network, 5xx) is `unreachable`.
273
+ */
274
+ export function whoamiStatus(domain, e) {
275
+ const message = isCliError(e) ? e.message : '';
276
+ const expired = [' expired; ', 'refused the session', 'Not logged in to ', 'belongs to another organization'];
277
+ if (isCliError(e) && e.code === 'AUTH' && expired.some((m) => message.includes(m))) {
278
+ return `expired: run p login --domain ${domain}`;
279
+ }
280
+ if (message.includes('session is locked by another p process') ||
281
+ message.includes('session changed while refreshing')) {
282
+ return 'busy: another p process is using this session; retry';
283
+ }
284
+ if (/^session file /.test(message))
285
+ return `invalid session file: ${message}`;
286
+ return 'unreachable';
287
+ }
288
+ /** Never reads `--api-key`/`NG_API_KEY`; never returns a token. One bad session never fails the listing. */
289
+ export async function runWhoami(opts, deps = {}) {
290
+ const env = deps.env ?? process.env;
291
+ const doFetch = deps.fetch ?? globalThis.fetch;
292
+ try {
293
+ const explicit = opts.domain?.trim() || env.PITCHER_DOMAIN?.trim();
294
+ const domains = explicit ? [selectDomain(opts, env)] : listSessionDomains(env);
295
+ if (domains.length === 0)
296
+ throw createCliError('AUTH', 'Not logged in; run `p login`');
297
+ const sessions = [];
298
+ for (const domain of domains) {
299
+ const row = { domain, status: 'ok' };
300
+ try {
301
+ const s = readSession(domain, env);
302
+ row.environment = s?.environment;
303
+ row.email = s?.email;
304
+ const cred = await sessionCredential(domain, { ...deps.session, env });
305
+ row.expiresAt = readSession(domain, env)?.accessTokenExpiresAt;
306
+ const me = await fetchMe(domain, cred, doFetch);
307
+ row.email = me.email ?? row.email;
308
+ }
309
+ catch (e) {
310
+ row.status = whoamiStatus(domain, e);
311
+ }
312
+ sessions.push(row);
313
+ }
314
+ return ok({ sessions });
315
+ }
316
+ catch (e) {
317
+ return fail(isCliError(e) ? e : createCliError('UNKNOWN', e instanceof Error ? e.message : String(e)));
318
+ }
319
+ }
320
+ export function createLoginCommand() {
321
+ return new Command('login')
322
+ .description('Sign in to a Pitcher org in the browser; the session is stored per org domain')
323
+ .option('--domain <domain>', 'Your Pitcher web address, e.g. acme.my.pitcher.com')
324
+ .option('--no-browser', 'Print the sign-in URL instead of opening a browser')
325
+ .action(async (options, command) => {
326
+ const globals = command.optsWithGlobals();
327
+ emitEnvelope(await runLogin(options, { quiet: globals.quiet }), globals);
328
+ });
329
+ }
330
+ export function createLogoutCommand() {
331
+ return new Command('logout')
332
+ .description('Sign out of one Pitcher org (or --all): deletes the local session only')
333
+ .argument('[domain]', 'The org domain to sign out of (needed when signed in to several)')
334
+ .option('--all', 'Sign out of every org')
335
+ .action(async (domain, options, command) => {
336
+ const globals = command.optsWithGlobals();
337
+ emitEnvelope(await runLogout({ domain, all: options.all }, { quiet: globals.quiet }), globals);
338
+ });
339
+ }
340
+ export function createWhoamiCommand() {
341
+ return new Command('whoami')
342
+ .description('Show your p login sessions (or one, with --domain / PITCHER_DOMAIN)')
343
+ .option('--domain <domain>', 'Show only this org domain')
344
+ .action(async (options, command) => {
345
+ const globals = command.optsWithGlobals();
346
+ emitEnvelope(await runWhoami(options), globals);
347
+ });
348
+ }
@@ -3,10 +3,11 @@ import { runMcpServe } from '../mcp/serve.js';
3
3
  import { isCliError } from '../core/errors.js';
4
4
  export function createMcpCommand() {
5
5
  return new Command('mcp')
6
- .description('Run the Pitcher MCP server over stdio (reads NG_API_KEY, NG_ORG/NG_URL, NG_ENV from env)')
7
- .action(async () => {
6
+ .description('Run the LOCAL Pitcher MCP server over stdio: only dev_sync, dev_sync_revert, publish_app, on your `p login` session')
7
+ .option('--domain <domain>', 'p login session to serve, e.g. acme.my.pitcher.com (needed when signed in to several)')
8
+ .action(async (options) => {
8
9
  try {
9
- await runMcpServe();
10
+ await runMcpServe({ domain: options.domain });
10
11
  }
11
12
  catch (e) {
12
13
  const message = isCliError(e) ? e.message : e instanceof Error ? e.message : String(e);
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import type { AxiosInstance } from 'axios';
3
- import { type Credential } from '../core/credential.js';
3
+ import type { Credential } from '../core/credential.js';
4
4
  import { type Envelope } from '../core/envelope.js';
5
5
  interface PublishResult {
6
6
  app?: {
@@ -16,6 +16,7 @@ interface PublishOptions {
16
16
  folder?: string;
17
17
  apiKey?: string;
18
18
  credential?: Credential;
19
+ domain?: string;
19
20
  org?: string;
20
21
  url?: string;
21
22
  incrementVersion?: boolean;
@@ -4,8 +4,7 @@ import os from 'os';
4
4
  import path from 'path';
5
5
  import { randomUUID } from 'crypto';
6
6
  import FormData from 'form-data';
7
- import { credentialOf } from '../core/credential.js';
8
- import { resolveBaseUrl } from '../core/baseUrl.js';
7
+ import { resolveRequestTarget } from '../core/target.js';
9
8
  import { makeClient, toCliError } from '../core/http.js';
10
9
  import { zipDirectoryToFile } from '../core/archive.js';
11
10
  import { ok, fail } from '../core/envelope.js';
@@ -92,8 +91,10 @@ async function installToInstances(client, appName, instances, quiet) {
92
91
  }
93
92
  export async function runPublishApp(opts, deps = {}) {
94
93
  try {
95
- const cred = credentialOf(opts);
96
- const baseUrl = resolveBaseUrl({ org: opts.org, url: opts.url });
94
+ // On the key path (CI) `--publish-url` stays the exact POST target; on the session path its host must be
95
+ // the session's domain (resolveRequestTarget refuses anything else before a request is made).
96
+ const target = resolveRequestTarget(opts);
97
+ const baseUrl = target.baseUrl;
97
98
  const folderPath = path.resolve(opts.folder ?? '.');
98
99
  const appJsonPath = path.join(folderPath, 'app.json');
99
100
  const warnings = [];
@@ -124,7 +125,7 @@ export async function runPublishApp(opts, deps = {}) {
124
125
  fs.writeFileSync(appJsonPath, JSON.stringify(existingAppJson, null, 2));
125
126
  log(`Updated app.json version to ${nextVersion}`, { quiet: opts.quiet });
126
127
  }
127
- const client = deps.client ?? makeClient(baseUrl, cred);
128
+ const client = deps.client ?? makeClient(baseUrl, target.source);
128
129
  const zipPath = path.join(deps.tmpDir ?? os.tmpdir(), `cli-publish-${process.pid}-${randomUUID()}.zip`);
129
130
  try {
130
131
  log(`Zipping ${folderPath}...`, { quiet: opts.quiet });
@@ -191,8 +192,9 @@ export async function runPublishApp(opts, deps = {}) {
191
192
  export function createPublishAppCommand() {
192
193
  return new Command('publish-app')
193
194
  .arguments('[folder]')
194
- .option('--api-key <apiKey>', 'API Key for authentication')
195
- .option('--org <organization>', 'Organization subdomain', 'dev')
195
+ .option('--api-key <apiKey>', 'API key (CI/automation only; people use `p login`)')
196
+ .option('--domain <domain>', 'p login session to use, e.g. acme.my.pitcher.com (see p whoami)')
197
+ .option('--org <organization>', 'Organization subdomain (with --api-key: default dev; with a session: picks it)')
196
198
  .option('--increment-version', 'Increment version in app.json')
197
199
  .option('--dry-run', 'Perform a dry run without publishing')
198
200
  .option('--publish-url <url>', 'Explicit publish URL')
@@ -204,6 +206,7 @@ export function createPublishAppCommand() {
204
206
  const env = await runPublishApp({
205
207
  folder,
206
208
  apiKey: options.apiKey,
209
+ domain: options.domain,
207
210
  org: options.org,
208
211
  url: options.publishUrl,
209
212
  incrementVersion: options.incrementVersion,
@@ -7,10 +7,8 @@ export interface SharedLinksDeps {
7
7
  }
8
8
  export declare function runSharedLinksCreate(opts: CommonOpts & {
9
9
  canvasId?: string;
10
- fileId?: string;
11
10
  }, deps?: SharedLinksDeps): Promise<Envelope<unknown>>;
12
11
  export declare function runSharedLinksGet(opts: CommonOpts & {
13
12
  canvasId?: string;
14
- fileId?: string;
15
13
  }, deps?: SharedLinksDeps): Promise<Envelope<unknown>>;
16
14
  export declare function createSharedLinksCommand(): Command;
@@ -4,22 +4,17 @@ import { ok, fail } from '../core/envelope.js';
4
4
  import { createCliError } from '../core/errors.js';
5
5
  import { emitEnvelope } from '../core/output.js';
6
6
  import { withAuthFlags } from './withAuth.js';
7
- /** Enforce exactly one of --canvas-id / --file-id. Scope is derived from the target. */
8
- function requireOneTarget(opts) {
9
- if (opts.canvasId && opts.fileId)
10
- throw createCliError('VALIDATION', 'pass exactly one of --canvas-id or --file-id');
11
- if (opts.canvasId)
12
- return 'canvas';
13
- if (opts.fileId)
14
- return 'file';
15
- throw createCliError('VALIDATION', 'pass exactly one of --canvas-id or --file-id');
7
+ /** Only canvases can be shared. */
8
+ function requireCanvasId(opts) {
9
+ if (!opts.canvasId)
10
+ throw createCliError('VALIDATION', '--canvas-id is required');
11
+ return opts.canvasId;
16
12
  }
17
13
  export async function runSharedLinksCreate(opts, deps = {}) {
18
14
  try {
19
- const target = requireOneTarget(opts);
15
+ const canvasId = requireCanvasId(opts);
20
16
  const client = clientFor(opts, deps);
21
- const body = target === 'canvas' ? { canvas_id: opts.canvasId } : { file_id: opts.fileId };
22
- const res = await client.post('/api/v1/shared-links/', body);
17
+ const res = await client.post('/api/v1/shared-links/', { canvas_id: canvasId });
23
18
  return ok(res.data);
24
19
  }
25
20
  catch (e) {
@@ -28,13 +23,10 @@ export async function runSharedLinksCreate(opts, deps = {}) {
28
23
  }
29
24
  export async function runSharedLinksGet(opts, deps = {}) {
30
25
  try {
31
- const target = requireOneTarget(opts);
26
+ const canvasId = requireCanvasId(opts);
32
27
  const client = clientFor(opts, deps);
33
28
  // encodeURIComponent so an id containing `/` or `..` can't retarget the request path
34
- const path = target === 'canvas'
35
- ? `/api/v1/shared-links/canvas/${encodeURIComponent(opts.canvasId)}`
36
- : `/api/v1/shared-links/file/${encodeURIComponent(opts.fileId)}`;
37
- const res = await client.get(path);
29
+ const res = await client.get(`/api/v1/shared-links/canvas/${encodeURIComponent(canvasId)}`);
38
30
  return ok(res.data);
39
31
  }
40
32
  catch (e) {
@@ -42,17 +34,15 @@ export async function runSharedLinksGet(opts, deps = {}) {
42
34
  }
43
35
  }
44
36
  export function createSharedLinksCommand() {
45
- const cmd = new Command('shared-links').description('create and read share links for canvases and files');
37
+ const cmd = new Command('shared-links').description('create and read share links for canvases');
46
38
  withAuthFlags(cmd.command('create'))
47
39
  .option('--canvas-id <id>', 'Canvas to share')
48
- .option('--file-id <id>', 'File to share')
49
40
  .action(async (options, command) => {
50
41
  const globals = command.optsWithGlobals();
51
42
  emitEnvelope(await runSharedLinksCreate({ ...options, quiet: globals.quiet }), globals);
52
43
  });
53
44
  withAuthFlags(cmd.command('get'))
54
45
  .option('--canvas-id <id>', 'Canvas whose link to fetch')
55
- .option('--file-id <id>', 'File whose link to fetch')
56
46
  .action(async (options, command) => {
57
47
  const globals = command.optsWithGlobals();
58
48
  emitEnvelope(await runSharedLinksGet({ ...options, quiet: globals.quiet }), globals);
@@ -1,3 +1,7 @@
1
1
  import { Command } from 'commander';
2
- /** Attach the shared auth flags (--api-key/--org/--url) to a (sub)command. */
2
+ /**
3
+ * Attach the shared auth flags to a (sub)command. People sign in with `p login` (`--domain` picks a session);
4
+ * `--api-key` is for automation. There is no commander default on `--org`: on the session path the session's
5
+ * org is the target, and only the key path falls back to `dev` (`resolveBaseUrl`).
6
+ */
3
7
  export declare function withAuthFlags(c: Command): Command;
@@ -1,7 +1,12 @@
1
- /** Attach the shared auth flags (--api-key/--org/--url) to a (sub)command. */
1
+ /**
2
+ * Attach the shared auth flags to a (sub)command. People sign in with `p login` (`--domain` picks a session);
3
+ * `--api-key` is for automation. There is no commander default on `--org`: on the session path the session's
4
+ * org is the target, and only the key path falls back to `dev` (`resolveBaseUrl`).
5
+ */
2
6
  export function withAuthFlags(c) {
3
7
  return c
4
- .option('--api-key <apiKey>', 'API Key for authentication')
5
- .option('--org <organization>', 'Organization subdomain', 'dev')
8
+ .option('--api-key <apiKey>', 'API key (CI/automation only; people use `p login`)')
9
+ .option('--domain <domain>', 'p login session to use, e.g. acme.my.pitcher.com (see p whoami)')
10
+ .option('--org <organization>', 'Organization subdomain (with --api-key: default dev; with a session: picks it)')
6
11
  .option('--url <url>', 'Explicit base URL override');
7
12
  }
@@ -1,6 +1,8 @@
1
1
  /**
2
- * The credential a request is sent with. `apiKey` is the Knox key the CLI (and stdio `p mcp`) has
3
- * always used; `bearer` is a user's Auth0 access token, used by the hosted MCP server (PIT-8912).
2
+ * The credential a request is sent with. `apiKey` is a Knox key (automation, CI); `bearer` is a user's Auth0
3
+ * access token (`p login` sessions, the hosted MCP server). Resolution and the auth headers live in
4
+ * `target.ts` (`resolveRequestTarget`, `authHeaders`); this file is only the type, which `src/mcp/index.ts`
5
+ * re-exports for mcp-server.
4
6
  */
5
7
  export type Credential = {
6
8
  kind: 'bearer';
@@ -9,17 +11,3 @@ export type Credential = {
9
11
  kind: 'apiKey';
10
12
  key: string;
11
13
  };
12
- /** The auth header(s) for a credential. Exactly one header, never both. */
13
- export declare function authHeaders(c: Credential): Record<string, string>;
14
- /**
15
- * The ONE credential resolver every call site uses.
16
- *
17
- * An explicit `credential` always wins and never falls through: a bearer context must fail CLOSED,
18
- * so it can never pick up `--api-key` or `NG_API_KEY` from the process env (the hosted server
19
- * would otherwise act as whoever minted that key). Only when no credential is given (CLI / stdio)
20
- * does this reach `resolveApiKey`, which reads `--api-key` then `NG_API_KEY`.
21
- */
22
- export declare function credentialOf(opts: {
23
- apiKey?: string;
24
- credential?: Credential;
25
- }): Credential;
@@ -1,25 +1 @@
1
- import { resolveApiKey } from './auth.js';
2
- import { createCliError } from './errors.js';
3
- /** The auth header(s) for a credential. Exactly one header, never both. */
4
- export function authHeaders(c) {
5
- return c.kind === 'bearer' ? { authorization: `Bearer ${c.token}` } : { 'x-api-key': c.key };
6
- }
7
- /**
8
- * The ONE credential resolver every call site uses.
9
- *
10
- * An explicit `credential` always wins and never falls through: a bearer context must fail CLOSED,
11
- * so it can never pick up `--api-key` or `NG_API_KEY` from the process env (the hosted server
12
- * would otherwise act as whoever minted that key). Only when no credential is given (CLI / stdio)
13
- * does this reach `resolveApiKey`, which reads `--api-key` then `NG_API_KEY`.
14
- */
15
- export function credentialOf(opts) {
16
- const c = opts.credential;
17
- if (c) {
18
- const value = c.kind === 'bearer' ? c.token : c.key;
19
- if (typeof value !== 'string' || !value.trim()) {
20
- throw createCliError('AUTH', `Empty ${c.kind === 'bearer' ? 'bearer token' : 'API key'} credential`);
21
- }
22
- return c;
23
- }
24
- return { kind: 'apiKey', key: resolveApiKey(opts) };
25
- }
1
+ export {};
@@ -1,13 +1,22 @@
1
1
  import { type AxiosInstance } from 'axios';
2
- import { type Credential } from './credential.js';
2
+ import type { Credential } from './credential.js';
3
3
  import { type CliError } from './errors.js';
4
- export declare function makeClient(baseUrl: string, cred: Credential, opts?: {
4
+ import { type CredentialSource } from './target.js';
5
+ /**
6
+ * A static credential sets its header once, exactly as before. A function source (a `p login` session) is
7
+ * awaited PER REQUEST by an interceptor, so a long-running client (`dev-sync watch`) picks up a refreshed
8
+ * token and every builder stays synchronous.
9
+ */
10
+ export declare function makeClient(baseUrl: string, source: CredentialSource, opts?: {
5
11
  maxRedirects?: number;
12
+ headers?: Record<string, string>;
6
13
  }): AxiosInstance;
7
14
  export interface CommonOpts {
8
15
  apiKey?: string;
9
- /** Explicit credential (hosted MCP: a user's bearer token). Wins over `apiKey`/`NG_API_KEY` — see `credentialOf`. */
16
+ /** Explicit credential (hosted MCP: a user's bearer token). Wins over everything — see `resolveRequestTarget`. */
10
17
  credential?: Credential;
18
+ /** A `p login` session domain (`acme.my.pitcher.com`); cannot be combined with `--org` or `--api-key`. */
19
+ domain?: string;
11
20
  org?: string;
12
21
  url?: string;
13
22
  quiet?: boolean;
@@ -18,10 +27,9 @@ export declare function clientFor(opts: CommonOpts, deps?: {
18
27
  }): AxiosInstance;
19
28
  export declare const CLICKHOUSE_API_BASE_URL = "https://clickhouse.pitcher.com";
20
29
  /**
21
- * Client for the clickhouse-api analytics service (PIT-7647). The service lives
22
- * on ONE host for every env/region; `x-original-domain` (the same `--org`/`--url`
23
- * resolution as Django calls) is what routes the request to the right core for
24
- * key validation + tenant scoping, so the usual flags select test vs prod.
30
+ * Client for the clickhouse-api analytics service (PIT-7647). The service lives on ONE host for every
31
+ * env/region; `x-original-domain` routes the request to the right core for key/token validation and tenant
32
+ * scoping. It is the TARGET's host, so a session call is scoped to the session's org, never a default.
25
33
  */
26
34
  export declare function clickhouseClientFor(opts: CommonOpts, deps?: {
27
35
  client?: AxiosInstance;