@pitcher/cli-scripts 2.17.1 → 2.19.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/README.md +69 -10
- package/dist/index.js +0 -0
- package/dist/src/commands/devSync.d.ts +2 -0
- package/dist/src/commands/devSync.js +5 -5
- package/dist/src/commands/dsr.d.ts +5 -0
- package/dist/src/commands/dsr.js +177 -21
- package/dist/src/commands/publishApp.d.ts +2 -0
- package/dist/src/commands/publishApp.js +3 -3
- package/dist/src/commands/scaffolds.js +11 -3
- package/dist/src/core/archive.js +3 -3
- package/dist/src/core/credential.d.ts +25 -0
- package/dist/src/core/credential.js +25 -0
- package/dist/src/core/http.d.ts +8 -2
- package/dist/src/core/http.js +168 -8
- package/dist/src/core/ld.d.ts +38 -2
- package/dist/src/core/ld.js +162 -6
- package/dist/src/core/readInput.js +8 -1
- package/dist/src/mcp/bundled.js +1 -1
- package/dist/src/mcp/hosted.d.ts +21 -0
- package/dist/src/mcp/hosted.js +82 -0
- package/dist/src/mcp/index.d.ts +7 -0
- package/dist/src/mcp/index.js +6 -0
- package/dist/src/mcp/prompts/dsr.js +4 -0
- package/dist/src/mcp/serve.js +1 -1
- package/dist/src/mcp/server.d.ts +19 -2
- package/dist/src/mcp/server.js +4 -4
- package/dist/src/mcp/tools/devSync.js +2 -2
- package/dist/src/mcp/tools/dsr.js +37 -8
- package/dist/src/mcp/tools/publishApp.js +2 -1
- package/dist/src/mcp/tools/types.d.ts +1 -1
- package/package.json +17 -6
package/dist/src/core/archive.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ZipArchive } from 'archiver';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
export async function zipDirectoryToBuffer(dir) {
|
|
4
4
|
return new Promise((resolve, reject) => {
|
|
5
5
|
const chunks = [];
|
|
6
|
-
const archive =
|
|
6
|
+
const archive = new ZipArchive({ zlib: { level: 9 } });
|
|
7
7
|
archive.on('data', (chunk) => chunks.push(chunk));
|
|
8
8
|
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
|
9
9
|
archive.on('error', reject);
|
|
@@ -14,7 +14,7 @@ export async function zipDirectoryToBuffer(dir) {
|
|
|
14
14
|
export async function zipDirectoryToFile(dir, outPath) {
|
|
15
15
|
return new Promise((resolve, reject) => {
|
|
16
16
|
const output = fs.createWriteStream(outPath);
|
|
17
|
-
const archive =
|
|
17
|
+
const archive = new ZipArchive({ zlib: { level: 9 } });
|
|
18
18
|
output.on('close', () => resolve(archive.pointer()));
|
|
19
19
|
output.on('error', reject);
|
|
20
20
|
archive.on('error', reject);
|
|
@@ -0,0 +1,25 @@
|
|
|
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).
|
|
4
|
+
*/
|
|
5
|
+
export type Credential = {
|
|
6
|
+
kind: 'bearer';
|
|
7
|
+
token: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'apiKey';
|
|
10
|
+
key: string;
|
|
11
|
+
};
|
|
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;
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
}
|
package/dist/src/core/http.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { type AxiosInstance } from 'axios';
|
|
2
|
+
import { type Credential } from './credential.js';
|
|
2
3
|
import { type CliError } from './errors.js';
|
|
3
|
-
export declare function makeClient(baseUrl: string,
|
|
4
|
+
export declare function makeClient(baseUrl: string, cred: Credential, opts?: {
|
|
5
|
+
maxRedirects?: number;
|
|
6
|
+
}): AxiosInstance;
|
|
4
7
|
export interface CommonOpts {
|
|
5
8
|
apiKey?: string;
|
|
9
|
+
/** Explicit credential (hosted MCP: a user's bearer token). Wins over `apiKey`/`NG_API_KEY` — see `credentialOf`. */
|
|
10
|
+
credential?: Credential;
|
|
6
11
|
org?: string;
|
|
7
12
|
url?: string;
|
|
8
13
|
quiet?: boolean;
|
|
@@ -24,7 +29,8 @@ export declare function clickhouseClientFor(opts: CommonOpts, deps?: {
|
|
|
24
29
|
/**
|
|
25
30
|
* Build a human-readable message from a server error body. Handles DRF shapes:
|
|
26
31
|
* a `detail`/`error` string, `non_field_errors`, and field-level error arrays
|
|
27
|
-
* (e.g. `{ version: ["already exists"] }`)
|
|
32
|
+
* (e.g. `{ version: ["already exists"] }`), plus next-core's
|
|
33
|
+
* `{ error, details }` validation shape. Falls back to the raw body so the
|
|
28
34
|
* full server response is visible in logs instead of a bare status line.
|
|
29
35
|
*/
|
|
30
36
|
export declare function extractApiMessage(data: unknown, fallback: string): string;
|
package/dist/src/core/http.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
-
import { resolveApiKey } from './auth.js';
|
|
3
2
|
import { resolveBaseUrl } from './baseUrl.js';
|
|
3
|
+
import { authHeaders, credentialOf } from './credential.js';
|
|
4
4
|
import { createCliError, isCliError } from './errors.js';
|
|
5
|
-
export function makeClient(baseUrl,
|
|
5
|
+
export function makeClient(baseUrl, cred, opts = {}) {
|
|
6
6
|
return axios.create({
|
|
7
|
+
...opts,
|
|
7
8
|
baseURL: baseUrl,
|
|
8
9
|
headers: {
|
|
9
10
|
accept: 'application/json',
|
|
10
|
-
|
|
11
|
+
...authHeaders(cred),
|
|
11
12
|
},
|
|
12
13
|
});
|
|
13
14
|
}
|
|
14
15
|
/** Resolve auth + base URL into a client, or use an injected one (tests). */
|
|
15
16
|
export function clientFor(opts, deps = {}) {
|
|
16
|
-
return deps.client ?? makeClient(resolveBaseUrl({ org: opts.org, url: opts.url }),
|
|
17
|
+
return deps.client ?? makeClient(resolveBaseUrl({ org: opts.org, url: opts.url }), credentialOf(opts));
|
|
17
18
|
}
|
|
18
19
|
export const CLICKHOUSE_API_BASE_URL = 'https://clickhouse.pitcher.com';
|
|
19
20
|
/**
|
|
@@ -30,15 +31,163 @@ export function clickhouseClientFor(opts, deps = {}) {
|
|
|
30
31
|
baseURL: CLICKHOUSE_API_BASE_URL,
|
|
31
32
|
headers: {
|
|
32
33
|
accept: 'application/json',
|
|
33
|
-
|
|
34
|
+
...authHeaders(credentialOf(opts)),
|
|
34
35
|
'x-original-domain': originalDomain,
|
|
35
36
|
},
|
|
36
37
|
});
|
|
37
38
|
}
|
|
39
|
+
/** Max number of `details` entries folded into a message before it is summarised. */
|
|
40
|
+
const MAX_DETAIL_PARTS = 3;
|
|
41
|
+
/** Max characters of joined `details` text, before the `(+N more)` marker is appended. */
|
|
42
|
+
const MAX_DETAIL_CHARS = 200;
|
|
43
|
+
/** How deep to walk a nested `details` value before falling back to JSON. */
|
|
44
|
+
const MAX_DETAIL_DEPTH = 3;
|
|
45
|
+
/**
|
|
46
|
+
* DRF envelope keys: the two places a serializer puts a message that names no field.
|
|
47
|
+
* next-core's `upstreamErrorMessage()` (`app/api/utils/upstream-error.ts`) reads `detail`
|
|
48
|
+
* first and sorts `non_field_errors` ahead of everything else, so a summary derived from a
|
|
49
|
+
* relayed upstream body is usually one of these two verbatim. Every other key IS a field
|
|
50
|
+
* name, and naming the field is the whole point of folding `details` in — so only a line
|
|
51
|
+
* from one of these keys may be dropped for merely repeating the summary.
|
|
52
|
+
*/
|
|
53
|
+
const ENVELOPE_KEYS = new Set(['detail', 'non_field_errors']);
|
|
54
|
+
/**
|
|
55
|
+
* `JSON.stringify` throws on a circular structure and on a BigInt. This runs inside the
|
|
56
|
+
* error formatter, so a throw here would lose the API error entirely and print a raw
|
|
57
|
+
* stack instead — every failure has to collapse to a string.
|
|
58
|
+
*/
|
|
59
|
+
function safeDetailJson(val) {
|
|
60
|
+
try {
|
|
61
|
+
const text = JSON.stringify(val, (_key, entry) => (typeof entry === 'bigint' ? String(entry) : entry));
|
|
62
|
+
return text ?? '[unserializable]';
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return '[unserializable]';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether an array entry already identifies itself in the output. A zod issue carries
|
|
70
|
+
* its own `path`, and an entry that renders as a single scalar (`{ title: ['Required'] }`)
|
|
71
|
+
* reads better bare; anything else gets an `[i]` so entries do not blur together.
|
|
72
|
+
*/
|
|
73
|
+
function labelsItself(entry) {
|
|
74
|
+
if (typeof entry !== 'object' || entry === null)
|
|
75
|
+
return true;
|
|
76
|
+
return typeof entry.message === 'string';
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Flatten one `details` value into human-readable, path-prefixed lines.
|
|
80
|
+
*
|
|
81
|
+
* next-core emits several shapes for the same field: zod issues
|
|
82
|
+
* (`[{ path, message }]`, from `_4xx('Invalid request data', 400, { details: error.errors })`),
|
|
83
|
+
* a `zod.flatten()` object, a relayed upstream body (`{ details: error.response.data }`),
|
|
84
|
+
* a plain object (`{ details: { id } }`) and a bare string. All of them collapse to
|
|
85
|
+
* `path: message` lines here.
|
|
86
|
+
*
|
|
87
|
+
* The walk is total: every recursive branch sits below the depth guard, and the guard's
|
|
88
|
+
* own fallback cannot throw.
|
|
89
|
+
*
|
|
90
|
+
* `envelope` marks the lines that descend from a top-level `ENVELOPE_KEYS` key; it is what
|
|
91
|
+
* lets `renderDetails` drop a relay echo without dropping a labelled field error.
|
|
92
|
+
*/
|
|
93
|
+
function detailParts(val, prefix = '', depth = 0, envelope = false) {
|
|
94
|
+
if (val == null)
|
|
95
|
+
return [];
|
|
96
|
+
const part = (value) => value ? [{ text: prefix ? `${prefix}: ${value}` : value, value, envelope }] : [];
|
|
97
|
+
if (typeof val === 'string')
|
|
98
|
+
return part(val.trim());
|
|
99
|
+
if (typeof val === 'number' || typeof val === 'boolean')
|
|
100
|
+
return part(String(val));
|
|
101
|
+
if (typeof val !== 'object')
|
|
102
|
+
return [];
|
|
103
|
+
// zod issue shape: `{ path: ['canvas_id'], message: 'Invalid ULID format for canvas_id' }`.
|
|
104
|
+
// A leaf, so it is answered above the depth guard.
|
|
105
|
+
if (!Array.isArray(val)) {
|
|
106
|
+
const obj = val;
|
|
107
|
+
if (typeof obj.message === 'string' && obj.message.trim()) {
|
|
108
|
+
const path = Array.isArray(obj.path) ? obj.path.join('.') : '';
|
|
109
|
+
const scope = [prefix, path].filter(Boolean).join('.');
|
|
110
|
+
const value = obj.message.trim();
|
|
111
|
+
return [{ text: scope ? `${scope}: ${value}` : value, value, envelope }];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (depth >= MAX_DETAIL_DEPTH)
|
|
115
|
+
return part(safeDetailJson(val));
|
|
116
|
+
if (Array.isArray(val)) {
|
|
117
|
+
return val.flatMap((entry, i) => detailParts(entry, labelsItself(entry) ? prefix : `${prefix}[${i}]`, depth + 1, envelope));
|
|
118
|
+
}
|
|
119
|
+
const obj = val;
|
|
120
|
+
return Object.entries(obj).flatMap(([key, entry]) =>
|
|
121
|
+
// Only a key at the root of `details` can be the envelope — `{ canvas: { detail: … } }`
|
|
122
|
+
// is a field named `canvas`, not a DRF envelope.
|
|
123
|
+
detailParts(entry, prefix ? `${prefix}.${key}` : key, depth + 1, envelope || (!prefix && ENVELOPE_KEYS.has(key))));
|
|
124
|
+
}
|
|
125
|
+
/** Cut `text` to `max` code points — never splitting a surrogate pair — and mark the cut. */
|
|
126
|
+
function truncateDetails(text, max) {
|
|
127
|
+
const points = [...text];
|
|
128
|
+
if (points.length <= max)
|
|
129
|
+
return text;
|
|
130
|
+
return `${points.slice(0, max - 1).join('').trimEnd()}\u2026`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Flatten a `details` value into one capped, de-duplicated line.
|
|
134
|
+
*
|
|
135
|
+
* When `summary` is given, two kinds of echo are suppressed, and only these two:
|
|
136
|
+
* - a whole rendered line equal to the summary (`_400Validation` in next-core answers a
|
|
137
|
+
* failed parse with `"<path>: <message>"`, which is exactly how the same issue renders
|
|
138
|
+
* here), and
|
|
139
|
+
* - a line from a DRF envelope key (`ENVELOPE_KEYS`) whose message equals the summary —
|
|
140
|
+
* the relay path, where `next-core .../shared-links/route.ts` builds its summary with
|
|
141
|
+
* `upstreamErrorMessage(body)` and then relays that same body as `details`, so the most
|
|
142
|
+
* common `p dsr` 400 would otherwise print `Not found. | detail: Not found.`
|
|
143
|
+
*
|
|
144
|
+
* A *field* entry is never dropped for matching the summary, however: in
|
|
145
|
+
* `{ error: 'This field is required.', details: { canvas: [...], expires_at: [...] } }` the
|
|
146
|
+
* field path is the only thing telling the user which field, and dropping `canvas` there
|
|
147
|
+
* left the message actively misleading (PR #5570 review).
|
|
148
|
+
*
|
|
149
|
+
* Output is capped so a large validation payload cannot become a wall of text. The
|
|
150
|
+
* `(+N more)` marker is appended after the cap, so it is never cut into a wrong count.
|
|
151
|
+
*/
|
|
152
|
+
function renderDetails(details, summary) {
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
const parts = [];
|
|
155
|
+
let dropped = 0;
|
|
156
|
+
for (const { text, value, envelope } of detailParts(details)) {
|
|
157
|
+
if (summary !== undefined && (text === summary || (envelope && value === summary)))
|
|
158
|
+
continue;
|
|
159
|
+
if (seen.has(text))
|
|
160
|
+
continue;
|
|
161
|
+
seen.add(text);
|
|
162
|
+
if (parts.length < MAX_DETAIL_PARTS)
|
|
163
|
+
parts.push(text);
|
|
164
|
+
else
|
|
165
|
+
dropped += 1;
|
|
166
|
+
}
|
|
167
|
+
if (!parts.length)
|
|
168
|
+
return '';
|
|
169
|
+
let text = truncateDetails(parts.join('; '), MAX_DETAIL_CHARS);
|
|
170
|
+
if (dropped > 0)
|
|
171
|
+
text += ` (+${dropped} more)`;
|
|
172
|
+
return text;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Fold an error body's `details` into the top-level summary so the user sees both
|
|
176
|
+
* halves on one line — next-core answers a failed schema parse with a generic
|
|
177
|
+
* `"Invalid request data"` and puts the only actionable text (the field and why it
|
|
178
|
+
* was rejected) in `details`, which used to be dropped entirely (PIT-7521).
|
|
179
|
+
*/
|
|
180
|
+
function withDetails(summary, details) {
|
|
181
|
+
if (details === undefined)
|
|
182
|
+
return summary;
|
|
183
|
+
const text = renderDetails(details, summary);
|
|
184
|
+
return text ? `${summary} | ${text}` : summary;
|
|
185
|
+
}
|
|
38
186
|
/**
|
|
39
187
|
* Build a human-readable message from a server error body. Handles DRF shapes:
|
|
40
188
|
* a `detail`/`error` string, `non_field_errors`, and field-level error arrays
|
|
41
|
-
* (e.g. `{ version: ["already exists"] }`)
|
|
189
|
+
* (e.g. `{ version: ["already exists"] }`), plus next-core's
|
|
190
|
+
* `{ error, details }` validation shape. Falls back to the raw body so the
|
|
42
191
|
* full server response is visible in logs instead of a bare status line.
|
|
43
192
|
*/
|
|
44
193
|
export function extractApiMessage(data, fallback) {
|
|
@@ -50,11 +199,22 @@ export function extractApiMessage(data, fallback) {
|
|
|
50
199
|
return String(data);
|
|
51
200
|
const obj = data;
|
|
52
201
|
if (typeof obj.detail === 'string' && obj.detail)
|
|
53
|
-
return obj.detail;
|
|
202
|
+
return withDetails(obj.detail, obj.details);
|
|
54
203
|
if (typeof obj.error === 'string' && obj.error)
|
|
55
|
-
return obj.error;
|
|
204
|
+
return withDetails(obj.error, obj.details);
|
|
56
205
|
const parts = [];
|
|
57
206
|
for (const [key, val] of Object.entries(obj)) {
|
|
207
|
+
// A body carrying `details` but no string `detail`/`error` summary lands here. next-core
|
|
208
|
+
// cannot produce that shape (`_4xx` always emits `{ error: message, ...data }`), but Django
|
|
209
|
+
// or another upstream can — and the generic rendering below would `join('; ')` structured
|
|
210
|
+
// entries into `details: [object Object]`, so walk it the same way `withDetails` does.
|
|
211
|
+
if (key === 'details') {
|
|
212
|
+
const rendered = renderDetails(val);
|
|
213
|
+
if (rendered) {
|
|
214
|
+
parts.push(rendered);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
58
218
|
const text = Array.isArray(val) ? val.join('; ') : typeof val === 'string' ? val : JSON.stringify(val);
|
|
59
219
|
parts.push(key === 'non_field_errors' ? text : `${key}: ${text}`);
|
|
60
220
|
}
|
package/dist/src/core/ld.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AxiosInstance } from 'axios';
|
|
2
2
|
import { type CommonOpts } from './http.js';
|
|
3
3
|
import { type CliError } from './errors.js';
|
|
4
4
|
export interface LdCommonOpts extends CommonOpts {
|
|
@@ -8,7 +8,17 @@ export interface LdCommonOpts extends CommonOpts {
|
|
|
8
8
|
export interface LdDeps {
|
|
9
9
|
ldClient?: AxiosInstance;
|
|
10
10
|
platformClient?: AxiosInstance;
|
|
11
|
+
/** Test seam for the bearer → L&D token exchange POST (`{ldBase}/auth/token`). */
|
|
12
|
+
ldAuthClient?: AxiosInstance;
|
|
11
13
|
}
|
|
14
|
+
/** The exact hosts a bearer credential may be sent to. An unusable `LD_ALLOWED_HOSTS` entry fails closed. */
|
|
15
|
+
export declare function ldAllowedHosts(env?: NodeJS.ProcessEnv): Set<string>;
|
|
16
|
+
/**
|
|
17
|
+
* Resolved L&D base URL per (platform host, instance). Bounded in TIME, not just per process: the
|
|
18
|
+
* hosted MCP server is one long-lived process, so an admin re-pointing an instance's ld-app
|
|
19
|
+
* `api_base_url` must take effect without a restart.
|
|
20
|
+
*/
|
|
21
|
+
export declare const LD_BASE_URL_TTL_MS: number;
|
|
12
22
|
/** Test-only: reset the resolved-base-URL cache between cases. */
|
|
13
23
|
export declare function __clearLdBaseUrlCache(): void;
|
|
14
24
|
/** Every L&D run* requires an instance; returns it narrowed to string, else VALIDATION. */
|
|
@@ -29,7 +39,33 @@ export declare function pickTruthy<T extends object>(opts: T, keys: readonly (ke
|
|
|
29
39
|
* over a credential the CLI actually holds.
|
|
30
40
|
*/
|
|
31
41
|
export declare function resolveLdBaseUrl(opts: LdCommonOpts, deps?: LdDeps, env?: NodeJS.ProcessEnv): Promise<string>;
|
|
32
|
-
/**
|
|
42
|
+
/** Max cached L&D tokens (one per distinct Pitcher token × L&D base). Oldest-used evicted first. */
|
|
43
|
+
export declare const LD_TOKEN_CACHE_MAX = 500;
|
|
44
|
+
/** Test-only: reset the L&D token-exchange cache between cases. */
|
|
45
|
+
export declare function __clearLdTokenCache(): void;
|
|
46
|
+
/** Test-only: current number of cached L&D tokens. */
|
|
47
|
+
export declare function __ldTokenCacheSize(): number;
|
|
48
|
+
/**
|
|
49
|
+
* The client that POSTs the user's token. `maxRedirects: 0` is load-bearing: axios follows 307/308 and
|
|
50
|
+
* RE-SENDS the body, so an allowlisted host answering 307 would forward the token to any host it names —
|
|
51
|
+
* straight past the allowlist. A 3xx is therefore a failed exchange (non-2xx), never followed.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createLdAuthHttp(): AxiosInstance;
|
|
54
|
+
/**
|
|
55
|
+
* Exchange a Pitcher (Auth0) access token for an L&D JWT at `POST {ldBase}/auth/token {token}`.
|
|
56
|
+
* The L&D envelope is `{ success, message, data: { token, refreshToken, user }, statusCode }`, so the
|
|
57
|
+
* JWT is at `res.data.data.token` (ld-app `authController.ts`).
|
|
58
|
+
*
|
|
59
|
+
* Cached until `min(L&D exp, Pitcher exp) − 60 s`: the L&D JWT embeds the Pitcher token, so it is
|
|
60
|
+
* useless once that expires, and its own lifetime is env-configurable (`JWT_EXPIRES_IN`), so neither
|
|
61
|
+
* is assumed. A token whose TTL cannot be read, or is already ≤ 0, is used once and not cached.
|
|
62
|
+
*/
|
|
63
|
+
export declare function exchangeLdToken(ldBase: string, pitcherToken: string, deps: LdDeps, env: NodeJS.ProcessEnv): Promise<string>;
|
|
64
|
+
/**
|
|
65
|
+
* Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base.
|
|
66
|
+
* API key → sent as-is (`x-api-key`). Bearer → exchanged for an L&D JWT first (the Lambda does not
|
|
67
|
+
* accept an Auth0 token directly), and that JWT is sent as the bearer.
|
|
68
|
+
*/
|
|
33
69
|
export declare function ldClientFor(opts: LdCommonOpts, deps?: LdDeps, env?: NodeJS.ProcessEnv): Promise<AxiosInstance>;
|
|
34
70
|
/**
|
|
35
71
|
* Unwrap the L&D success envelope `{ success, data, message? }` to its resource payload.
|
package/dist/src/core/ld.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { credentialOf } from './credential.js';
|
|
2
4
|
import { clientFor, makeClient, toCliError } from './http.js';
|
|
3
5
|
import { resolveBaseUrl } from './baseUrl.js';
|
|
4
6
|
import { createCliError } from './errors.js';
|
|
@@ -41,6 +43,50 @@ const LD_FALLBACK_BASE_URL = 'https://cgmfr9gjza.execute-api.eu-west-1.amazonaws
|
|
|
41
43
|
* what points each instance at it — the fallback must never silently stand in.
|
|
42
44
|
*/
|
|
43
45
|
const LD_FALLBACK_PLATFORM_ORIGIN = 'https://dev.my.pitcher.com';
|
|
46
|
+
/**
|
|
47
|
+
* Bearer mode (the hosted MCP server) sends the USER's Pitcher Auth0 access token to
|
|
48
|
+
* `POST {ldBase}/auth/token`. `ldBase` can come from `metadata.api_base_url`, which any
|
|
49
|
+
* instance admin can write — so without a gate an admin could point it at a host they own and
|
|
50
|
+
* collect every user's token (plus SSRF from the server). In bearer mode the resolved base
|
|
51
|
+
* must therefore be `https:` on an EXACT allowlisted host (no port, no wildcard, no suffix
|
|
52
|
+
* match — anyone can mint a `*.execute-api` host). Default = the fallback Lambda's host;
|
|
53
|
+
* `LD_ALLOWED_HOSTS` (comma-separated exact hostnames) extends it. API-key mode is unchanged.
|
|
54
|
+
*/
|
|
55
|
+
const LD_DEFAULT_ALLOWED_HOST = new URL(LD_FALLBACK_BASE_URL).host;
|
|
56
|
+
/** The exact hosts a bearer credential may be sent to. An unusable `LD_ALLOWED_HOSTS` entry fails closed. */
|
|
57
|
+
export function ldAllowedHosts(env = process.env) {
|
|
58
|
+
const hosts = new Set([LD_DEFAULT_ALLOWED_HOST]);
|
|
59
|
+
for (const raw of (env.LD_ALLOWED_HOSTS ?? '').split(',')) {
|
|
60
|
+
const entry = raw.trim().toLowerCase();
|
|
61
|
+
if (!entry)
|
|
62
|
+
continue;
|
|
63
|
+
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(entry)) {
|
|
64
|
+
throw createCliError('VALIDATION', `LD_ALLOWED_HOSTS entries must be exact hostnames (no wildcard/port/scheme): ${entry}`);
|
|
65
|
+
}
|
|
66
|
+
hosts.add(entry);
|
|
67
|
+
}
|
|
68
|
+
return hosts;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fail CLOSED unless `url` is safe to receive a bearer token. The message names the host, never the
|
|
72
|
+
* token. Called at resolution AND immediately before the exchange POST (defence in depth).
|
|
73
|
+
*/
|
|
74
|
+
function assertBearerLdBase(url, env) {
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = new URL(url);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw createCliError('VALIDATION', 'LD_HOST_NOT_ALLOWED: the L&D base URL is not a valid URL');
|
|
81
|
+
}
|
|
82
|
+
if (parsed.protocol !== 'https:') {
|
|
83
|
+
throw createCliError('VALIDATION', `LD_HOST_NOT_ALLOWED: the L&D base URL must be https (got ${parsed.protocol})`);
|
|
84
|
+
}
|
|
85
|
+
if (!ldAllowedHosts(env).has(parsed.host)) {
|
|
86
|
+
throw createCliError('VALIDATION', `LD_HOST_NOT_ALLOWED: ${parsed.host} is not an allowed L&D host for a signed-in user. ` +
|
|
87
|
+
`Add it to LD_ALLOWED_HOSTS on the server if it is a genuine L&D deployment.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
44
90
|
/**
|
|
45
91
|
* Safety stop for the installed-apps scan. At 500 records/page this is 10k apps in one
|
|
46
92
|
* instance — unreachable in practice, so tripping it means the server kept advertising a
|
|
@@ -51,6 +97,12 @@ const MAX_APP_PAGES = 20;
|
|
|
51
97
|
function isRecord(v) {
|
|
52
98
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
53
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolved L&D base URL per (platform host, instance). Bounded in TIME, not just per process: the
|
|
102
|
+
* hosted MCP server is one long-lived process, so an admin re-pointing an instance's ld-app
|
|
103
|
+
* `api_base_url` must take effect without a restart.
|
|
104
|
+
*/
|
|
105
|
+
export const LD_BASE_URL_TTL_MS = 10 * 60000;
|
|
54
106
|
const baseUrlCache = new Map();
|
|
55
107
|
/** Test-only: reset the resolved-base-URL cache between cases. */
|
|
56
108
|
export function __clearLdBaseUrlCache() {
|
|
@@ -176,6 +228,13 @@ async function fetchLdAppMetadata(platform, instanceId) {
|
|
|
176
228
|
* over a credential the CLI actually holds.
|
|
177
229
|
*/
|
|
178
230
|
export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
|
|
231
|
+
const url = await resolveLdBaseUrlUnchecked(opts, deps, env);
|
|
232
|
+
// Every source (flag, env, instance metadata, fallback, cache) is checked, on every call.
|
|
233
|
+
if (opts.credential?.kind === 'bearer')
|
|
234
|
+
assertBearerLdBase(url, env);
|
|
235
|
+
return url;
|
|
236
|
+
}
|
|
237
|
+
async function resolveLdBaseUrlUnchecked(opts, deps, env) {
|
|
179
238
|
const override = opts.ldUrl?.trim() || env.NG_LD_API_URL?.trim();
|
|
180
239
|
if (override)
|
|
181
240
|
return trimUrl(override, opts.ldUrl ? '--ld-url' : 'NG_LD_API_URL');
|
|
@@ -186,8 +245,8 @@ export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
|
|
|
186
245
|
// different platform hosts collide on one cached L&D URL within a single process.
|
|
187
246
|
const cacheKey = `${opts.url ?? opts.org ?? 'dev'}:${opts.instanceId}`;
|
|
188
247
|
const cached = baseUrlCache.get(cacheKey);
|
|
189
|
-
if (cached)
|
|
190
|
-
return cached;
|
|
248
|
+
if (cached && cached.expiresAt > Date.now())
|
|
249
|
+
return cached.url;
|
|
191
250
|
const platform = deps.platformClient ?? clientFor(opts);
|
|
192
251
|
const metadata = await fetchLdAppMetadata(platform, opts.instanceId);
|
|
193
252
|
if (!metadata) {
|
|
@@ -209,15 +268,112 @@ export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
|
|
|
209
268
|
}
|
|
210
269
|
url = LD_FALLBACK_BASE_URL;
|
|
211
270
|
}
|
|
212
|
-
baseUrlCache.set(cacheKey, url);
|
|
271
|
+
baseUrlCache.set(cacheKey, { url, expiresAt: Date.now() + LD_BASE_URL_TTL_MS });
|
|
213
272
|
return url;
|
|
214
273
|
}
|
|
215
|
-
/**
|
|
274
|
+
/** Max cached L&D tokens (one per distinct Pitcher token × L&D base). Oldest-used evicted first. */
|
|
275
|
+
export const LD_TOKEN_CACHE_MAX = 500;
|
|
276
|
+
/** Safety margin subtracted from the earliest expiry, so a cached token is never used at the edge. */
|
|
277
|
+
const LD_TOKEN_SKEW_MS = 60000;
|
|
278
|
+
/**
|
|
279
|
+
* Bearer → L&D token cache, keyed `sha256(pitcherToken)|ldBase` (the raw token is never a key).
|
|
280
|
+
* A `Map` iterates in insertion order, so re-inserting on hit makes the first key the LRU one.
|
|
281
|
+
*/
|
|
282
|
+
const ldTokenCache = new Map();
|
|
283
|
+
/** Test-only: reset the L&D token-exchange cache between cases. */
|
|
284
|
+
export function __clearLdTokenCache() {
|
|
285
|
+
ldTokenCache.clear();
|
|
286
|
+
}
|
|
287
|
+
/** Test-only: current number of cached L&D tokens. */
|
|
288
|
+
export function __ldTokenCacheSize() {
|
|
289
|
+
return ldTokenCache.size;
|
|
290
|
+
}
|
|
291
|
+
/** `exp` (seconds) of a JWT, read WITHOUT verification — only ever used to bound a cache TTL. */
|
|
292
|
+
function jwtExpMs(token) {
|
|
293
|
+
const payload = token.split('.')[1];
|
|
294
|
+
if (!payload)
|
|
295
|
+
return undefined;
|
|
296
|
+
try {
|
|
297
|
+
const exp = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).exp;
|
|
298
|
+
return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : undefined;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The client that POSTs the user's token. `maxRedirects: 0` is load-bearing: axios follows 307/308 and
|
|
306
|
+
* RE-SENDS the body, so an allowlisted host answering 307 would forward the token to any host it names —
|
|
307
|
+
* straight past the allowlist. A 3xx is therefore a failed exchange (non-2xx), never followed.
|
|
308
|
+
*/
|
|
309
|
+
export function createLdAuthHttp() {
|
|
310
|
+
return axios.create({ headers: { accept: 'application/json' }, timeout: 10000, maxRedirects: 0 });
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Exchange a Pitcher (Auth0) access token for an L&D JWT at `POST {ldBase}/auth/token {token}`.
|
|
314
|
+
* The L&D envelope is `{ success, message, data: { token, refreshToken, user }, statusCode }`, so the
|
|
315
|
+
* JWT is at `res.data.data.token` (ld-app `authController.ts`).
|
|
316
|
+
*
|
|
317
|
+
* Cached until `min(L&D exp, Pitcher exp) − 60 s`: the L&D JWT embeds the Pitcher token, so it is
|
|
318
|
+
* useless once that expires, and its own lifetime is env-configurable (`JWT_EXPIRES_IN`), so neither
|
|
319
|
+
* is assumed. A token whose TTL cannot be read, or is already ≤ 0, is used once and not cached.
|
|
320
|
+
*/
|
|
321
|
+
export async function exchangeLdToken(ldBase, pitcherToken, deps, env) {
|
|
322
|
+
// Re-checked at the point the token leaves, independent of how `ldBase` was obtained.
|
|
323
|
+
assertBearerLdBase(ldBase, env);
|
|
324
|
+
const key = `${createHash('sha256').update(pitcherToken).digest('hex')}|${ldBase}`;
|
|
325
|
+
const hit = ldTokenCache.get(key);
|
|
326
|
+
if (hit) {
|
|
327
|
+
ldTokenCache.delete(key);
|
|
328
|
+
if (hit.expiresAt > Date.now()) {
|
|
329
|
+
ldTokenCache.set(key, hit);
|
|
330
|
+
return hit.token;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
let ldToken;
|
|
334
|
+
try {
|
|
335
|
+
const http = deps.ldAuthClient ?? createLdAuthHttp();
|
|
336
|
+
const res = await http.post(`${ldBase}/auth/token`, { token: pitcherToken });
|
|
337
|
+
ldToken = res.data?.data?.token;
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
const err = toLdCliError(e);
|
|
341
|
+
throw createCliError('AUTH', `L&D token exchange failed: ${err.message}`);
|
|
342
|
+
}
|
|
343
|
+
if (typeof ldToken !== 'string' || !ldToken) {
|
|
344
|
+
throw createCliError('AUTH', 'L&D token exchange failed: no token in the /auth/token response');
|
|
345
|
+
}
|
|
346
|
+
const ldExp = jwtExpMs(ldToken);
|
|
347
|
+
const pitcherExp = jwtExpMs(pitcherToken);
|
|
348
|
+
if (ldExp !== undefined && pitcherExp !== undefined) {
|
|
349
|
+
const expiresAt = Math.min(ldExp, pitcherExp) - LD_TOKEN_SKEW_MS;
|
|
350
|
+
if (expiresAt > Date.now()) {
|
|
351
|
+
ldTokenCache.set(key, { token: ldToken, expiresAt });
|
|
352
|
+
while (ldTokenCache.size > LD_TOKEN_CACHE_MAX) {
|
|
353
|
+
const oldest = ldTokenCache.keys().next().value;
|
|
354
|
+
if (oldest === undefined)
|
|
355
|
+
break;
|
|
356
|
+
ldTokenCache.delete(oldest);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return ldToken;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base.
|
|
364
|
+
* API key → sent as-is (`x-api-key`). Bearer → exchanged for an L&D JWT first (the Lambda does not
|
|
365
|
+
* accept an Auth0 token directly), and that JWT is sent as the bearer.
|
|
366
|
+
*/
|
|
216
367
|
export async function ldClientFor(opts, deps = {}, env = process.env) {
|
|
217
368
|
if (deps.ldClient)
|
|
218
369
|
return deps.ldClient;
|
|
219
370
|
const base = await resolveLdBaseUrl(opts, deps, env);
|
|
220
|
-
|
|
371
|
+
const cred = credentialOf(opts);
|
|
372
|
+
if (cred.kind === 'apiKey')
|
|
373
|
+
return makeClient(base, cred);
|
|
374
|
+
// No redirects for a bearer either: the L&D JWT embeds the user's Pitcher token.
|
|
375
|
+
const ldToken = await exchangeLdToken(base, cred.token, deps, env);
|
|
376
|
+
return makeClient(base, { kind: 'bearer', token: ldToken }, { maxRedirects: 0 });
|
|
221
377
|
}
|
|
222
378
|
/**
|
|
223
379
|
* Unwrap the L&D success envelope `{ success, data, message? }` to its resource payload.
|
|
@@ -48,9 +48,16 @@ export function resolveTextInput(inline, file, label) {
|
|
|
48
48
|
// Resolve an inline-or-file JSON flag and parse it. VALIDATION when neither is given
|
|
49
49
|
// (`provide --<label> (inline JSON) or --<label>-file`) or the content is not valid JSON.
|
|
50
50
|
// Used by the L&D canvas / question / assessment-link inputs.
|
|
51
|
+
//
|
|
52
|
+
// The "neither" test is `=== undefined`, NOT truthiness: `resolveTextInput` returns `''` for a
|
|
53
|
+
// ZERO-BYTE file, and a falsy test would report that as "you did not pass the flag" when the
|
|
54
|
+
// caller passed `--<label>-file empty.json` and the file is simply empty. An empty string is
|
|
55
|
+
// content, so let it reach `JSON.parse` and be named for what it is — not valid JSON. (An empty
|
|
56
|
+
// INLINE value still lands on the "neither" message: `resolveTextInput`'s own XOR is truthy-based,
|
|
57
|
+
// so it returns undefined for `--<label> ''` before this line runs.)
|
|
51
58
|
export function parseJsonInput(inline, file, label) {
|
|
52
59
|
const raw = resolveTextInput(inline, file, label);
|
|
53
|
-
if (
|
|
60
|
+
if (raw === undefined)
|
|
54
61
|
throw createCliError('VALIDATION', `provide --${label} (inline JSON) or --${label}-file`);
|
|
55
62
|
try {
|
|
56
63
|
return JSON.parse(raw);
|
package/dist/src/mcp/bundled.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { allTools } from './registry.js';
|
|
2
2
|
// Developer-only tools excluded from the consumer .mcpb bundle. `publish_app` is alwaysDestructive
|
|
3
3
|
// (creates a live, org-visible app release); `dev_sync`/`dev_sync_revert` mutate remote S3 dev state.
|
|
4
|
-
// None are relevant to a non-technical adopter installing the extension. The CLI `p mcp`
|
|
4
|
+
// None are relevant to a non-technical adopter installing the extension. The CLI `p mcp` registers every tool.
|
|
5
5
|
export const BUNDLE_EXCLUDED_TOOLS = new Set(['pitcher_publish_app', 'pitcher_dev_sync', 'pitcher_dev_sync_revert']);
|
|
6
6
|
// The tool set the .mcpb bundle registers.
|
|
7
7
|
export const bundledTools = allTools.filter((t) => !BUNDLE_EXCLUDED_TOOLS.has(t.name));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BuildServerOptions } from './server.js';
|
|
2
|
+
import type { ToolDescriptor } from './tools/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The tool set served by the hosted (remote, OAuth) MCP server — PIT-8912.
|
|
5
|
+
*
|
|
6
|
+
* Differences from stdio `p mcp`:
|
|
7
|
+
* - the three disk-bound tools (`LOCAL_ONLY`) are dropped: they zip a folder on the server host;
|
|
8
|
+
* - every file-path input (`FILE_KEY`) is stripped from the schemas AND rejected at runtime, so a
|
|
9
|
+
* hosted call can never make the server read its own disk; every one has an inline alternative;
|
|
10
|
+
* - descriptions that talk about local files are rewritten.
|
|
11
|
+
*/
|
|
12
|
+
export declare const LOCAL_ONLY: ReadonlySet<string>;
|
|
13
|
+
/**
|
|
14
|
+
* A file-path input. Covers the 8 schema keys on main (`filePath`, `configFile`, `landingContextFile`,
|
|
15
|
+
* `htmlFile`, `cssFile`, `bodyFile`, `fromFile`, `sectionsFile`) AND command-layer keys no schema
|
|
16
|
+
* exposes today (`questionFile`, `answersFile`, `canvasFile`, `assessmentsFile`, `contentHtmlFile`).
|
|
17
|
+
*/
|
|
18
|
+
export declare const FILE_KEY: RegExp;
|
|
19
|
+
/** Build options every hosted server uses: a write always asks the client to confirm. */
|
|
20
|
+
export declare const HOSTED_BUILD_OPTIONS: Readonly<BuildServerOptions>;
|
|
21
|
+
export declare const hostedTools: ToolDescriptor[];
|