@pitcher/cli-scripts 2.17.0 → 2.17.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.
- package/dist/src/core/ld.d.ts +10 -2
- package/dist/src/core/ld.js +159 -9
- package/dist/src/mcp/tools/analytics.js +2 -1
- package/package.json +1 -1
package/dist/src/core/ld.d.ts
CHANGED
|
@@ -17,8 +17,16 @@ export declare function requireInstance(opts: LdCommonOpts): string;
|
|
|
17
17
|
export declare function pickTruthy<T extends object>(opts: T, keys: readonly (keyof T)[]): Partial<Record<keyof T, unknown>>;
|
|
18
18
|
/**
|
|
19
19
|
* Resolve the standalone L&D Lambda base URL. Order: --ld-url flag → NG_LD_API_URL env →
|
|
20
|
-
* ld-app
|
|
21
|
-
*
|
|
20
|
+
* the installed ld-app record's `metadata.api_base_url` → the ld-app built-in default.
|
|
21
|
+
* Cached per (org, instanceId).
|
|
22
|
+
*
|
|
23
|
+
* PIT-7546: this used to read `/core/api/protected/apps/ld-app/config`, which is a
|
|
24
|
+
* machine-to-machine route gated on an `x-integrations-token` shared secret. The CLI sends
|
|
25
|
+
* `x-api-key`, so next-core rejected every call with 'Invalid m2m integrations token' before
|
|
26
|
+
* the L&D Lambda was ever contacted — one root cause behind all 63 `pitcher_ld_*` failures.
|
|
27
|
+
* The replacement reads the same row that route's SQL reads (`pitcher_file` where
|
|
28
|
+
* `app_metadata->>'name' = 'ld-app'` and `instance_id = $2`, `type = 'app'`, not deleted)
|
|
29
|
+
* over a credential the CLI actually holds.
|
|
22
30
|
*/
|
|
23
31
|
export declare function resolveLdBaseUrl(opts: LdCommonOpts, deps?: LdDeps, env?: NodeJS.ProcessEnv): Promise<string>;
|
|
24
32
|
/** Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base. */
|
package/dist/src/core/ld.js
CHANGED
|
@@ -1,7 +1,56 @@
|
|
|
1
1
|
import { resolveApiKey } from './auth.js';
|
|
2
2
|
import { clientFor, makeClient, toCliError } from './http.js';
|
|
3
|
+
import { resolveBaseUrl } from './baseUrl.js';
|
|
3
4
|
import { createCliError } from './errors.js';
|
|
4
|
-
|
|
5
|
+
import { MAX_PAGE_SIZE } from './pagination.js';
|
|
6
|
+
const LD_APP_NAME = 'ld-app';
|
|
7
|
+
const FILES_LIST_PATH = '/api/v1/files/';
|
|
8
|
+
/**
|
|
9
|
+
* Sparse fieldset for the installed-apps scan (`FileViewSet.get_serializer` forwards `?fields=`
|
|
10
|
+
* to `AlwaysIncludeIdFieldMixin`, which also re-adds `id`). Without it the full `FileSerializer`
|
|
11
|
+
* renders `content_url`/`thumbnail_url`/`zip_url`/`original_url` for every row — each one a
|
|
12
|
+
* CloudFront signing — on every single `p ld` invocation, since the cache is per-process.
|
|
13
|
+
* Measured on a 59-app instance: 153,837 -> 77,402 bytes and no signed URLs.
|
|
14
|
+
*/
|
|
15
|
+
const LD_APP_FIELDS = 'app_metadata,metadata';
|
|
16
|
+
/**
|
|
17
|
+
* Fallback L&D base URL, kept byte-identical to the ld-app frontend's own default
|
|
18
|
+
* (`ng-production-apps/ld-app/src/lib/apiClient.ts`). `api_base_url` is an OPTIONAL
|
|
19
|
+
* per-instance override (`app.config.json`, `is_required: false`), so on every instance
|
|
20
|
+
* that has never set it the app falls back to this single deployed Lambda.
|
|
21
|
+
*
|
|
22
|
+
* ONLY usable from the platform host below — see LD_FALLBACK_PLATFORM_ORIGIN.
|
|
23
|
+
*/
|
|
24
|
+
const LD_FALLBACK_BASE_URL = 'https://cgmfr9gjza.execute-api.eu-west-1.amazonaws.com/dev/api';
|
|
25
|
+
/**
|
|
26
|
+
* The one platform host the fallback Lambda authenticates against, so the only one whose
|
|
27
|
+
* API key may travel to it.
|
|
28
|
+
*
|
|
29
|
+
* The Lambda lives in the IaC Test account with `DefaultAuthorizer: NONE` (ld-app README
|
|
30
|
+
* section 2) and validates an incoming `x-api-key` by relaying it verbatim to
|
|
31
|
+
* `https://dev.my.pitcher.com/api/v1` (`ld-app/server/src/config/index.ts` →
|
|
32
|
+
* `resolvePitcherApiBaseUrl`, `providers/pitcherApiProvider.ts` → `credentialHeaders`).
|
|
33
|
+
* Defaulting any other environment's org key onto it would ship that credential into a
|
|
34
|
+
* test account and then fail validation, surfacing as a misleading AUTH — and `ldClientFor`
|
|
35
|
+
* sends exactly that key. The L&D server refuses the mirror-image default for the same
|
|
36
|
+
* stated reason ("would send credential validation and org-membership probes to the wrong
|
|
37
|
+
* environment"), so the CLI matches it rather than re-deciding.
|
|
38
|
+
*
|
|
39
|
+
* The frontend precedent does NOT extend here: it carries a short-lived per-user JWT, not
|
|
40
|
+
* the org-wide authoring key. Once a prod L&D stack exists, per-instance `api_base_url` is
|
|
41
|
+
* what points each instance at it — the fallback must never silently stand in.
|
|
42
|
+
*/
|
|
43
|
+
const LD_FALLBACK_PLATFORM_ORIGIN = 'https://dev.my.pitcher.com';
|
|
44
|
+
/**
|
|
45
|
+
* Safety stop for the installed-apps scan. At 500 records/page this is 10k apps in one
|
|
46
|
+
* instance — unreachable in practice, so tripping it means the server kept advertising a
|
|
47
|
+
* `next` page. Fail loudly rather than spin: a silent break would look identical to
|
|
48
|
+
* "ld-app is not installed", which is the exact misreport paging is here to prevent.
|
|
49
|
+
*/
|
|
50
|
+
const MAX_APP_PAGES = 20;
|
|
51
|
+
function isRecord(v) {
|
|
52
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
53
|
+
}
|
|
5
54
|
const baseUrlCache = new Map();
|
|
6
55
|
/** Test-only: reset the resolved-base-URL cache between cases. */
|
|
7
56
|
export function __clearLdBaseUrlCache() {
|
|
@@ -36,10 +85,95 @@ function trimUrl(raw, source) {
|
|
|
36
85
|
// origin + pathname only: strips any userinfo/query/hash; trailing slash trimmed.
|
|
37
86
|
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
|
|
38
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Find the installed `ld-app` file record for an instance and return its per-instance
|
|
90
|
+
* config (`pitcher_file.metadata`), or undefined when the app is not installed.
|
|
91
|
+
*
|
|
92
|
+
* Django's `FileFilter` (ng-rest-api `pitcher/views/fileviews.py`) has NO filter on
|
|
93
|
+
* `app_metadata` — its declared filters and `Meta.fields` cover `name`, `type`, `tags__in`
|
|
94
|
+
* etc., and `name` is the file's DISPLAY name ('L&D'), not `app_metadata->>'name'`
|
|
95
|
+
* ('ld-app'). So the name match has to happen client-side, which in turn means paging to
|
|
96
|
+
* exhaustion: a first-page-only match would report 'not installed' on an instance that
|
|
97
|
+
* simply has more apps than one page holds.
|
|
98
|
+
*
|
|
99
|
+
* If an instance somehow carries two ld-app records, the first in server order wins. That
|
|
100
|
+
* order is `-modified_at` (`fileviews.py` FileViewSet.ordering), so the most-recently-modified
|
|
101
|
+
* one does — strictly more deterministic than the `LIMIT 1` with no ORDER BY in the m2m route
|
|
102
|
+
* this replaces. No dev instance has more than one (measured across all 20 that have it).
|
|
103
|
+
*/
|
|
104
|
+
async function fetchLdAppMetadata(platform, instanceId) {
|
|
105
|
+
let scanned = 0;
|
|
106
|
+
let named = 0;
|
|
107
|
+
let exhausted = false;
|
|
108
|
+
for (let page = 1; page <= MAX_APP_PAGES; page += 1) {
|
|
109
|
+
const res = await platform.get(FILES_LIST_PATH, {
|
|
110
|
+
params: { instance_id: instanceId, type__in: 'app', fields: LD_APP_FIELDS, page, page_size: MAX_PAGE_SIZE },
|
|
111
|
+
});
|
|
112
|
+
const body = res.data;
|
|
113
|
+
// Fail loudly on an unexpected body rather than coercing it to an empty page: that would
|
|
114
|
+
// surface as 'ld-app is not installed', the exact misreport the paging below is here to
|
|
115
|
+
// prevent, and it would be indistinguishable from the real thing.
|
|
116
|
+
if (!isRecord(body) || !Array.isArray(body.results)) {
|
|
117
|
+
throw createCliError('UNKNOWN', `Unexpected response shape from ${FILES_LIST_PATH} (expected a paginated body)`);
|
|
118
|
+
}
|
|
119
|
+
scanned += body.results.length;
|
|
120
|
+
for (const row of body.results) {
|
|
121
|
+
const file = row;
|
|
122
|
+
// A single nameless row is DATA, not a bad response — `app_metadata` is a JSONField
|
|
123
|
+
// defaulting to `{}`, and such a row is definitively not ld-app, so skipping it is
|
|
124
|
+
// correct. It also cannot hide ld-app, which has a name and is found by scanning every
|
|
125
|
+
// row. Hard-failing on it would take out `p ld` for an instance over one junk record.
|
|
126
|
+
if (typeof file?.app_metadata?.name !== 'string')
|
|
127
|
+
continue;
|
|
128
|
+
named += 1;
|
|
129
|
+
if (file.app_metadata.name !== LD_APP_NAME)
|
|
130
|
+
continue;
|
|
131
|
+
// Behaviourally this is just null-safety: the sole consumer reads `.api_base_url`, which
|
|
132
|
+
// is undefined on a string or array anyway, so `isRecord` and `?? {}` are equivalent for
|
|
133
|
+
// every input. It is written this way because it narrows without an `as` cast — do not
|
|
134
|
+
// read the strictness as a contract. What DOES matter is never returning a nullish value:
|
|
135
|
+
// the caller treats that as "ld-app not installed".
|
|
136
|
+
return isRecord(file.metadata) ? file.metadata : {};
|
|
137
|
+
}
|
|
138
|
+
// DRF's pagination contract for `next` is a non-empty URL string, `null`, or absent.
|
|
139
|
+
// Anything else means we cannot trust our own stopping condition: a truthy non-string
|
|
140
|
+
// would otherwise page all the way to the cap before failing, and an empty string would
|
|
141
|
+
// read as "exhausted" and land on NOT_FOUND. Fail immediately and accurately instead.
|
|
142
|
+
const next = body.next;
|
|
143
|
+
if (next !== undefined && next !== null && (typeof next !== 'string' || !next)) {
|
|
144
|
+
throw createCliError('UNKNOWN', `Unexpected \`next\` in the ${FILES_LIST_PATH} response (expected a URL, null or absent)`);
|
|
145
|
+
}
|
|
146
|
+
if (!next) {
|
|
147
|
+
exhausted = true;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Ran out of pages before the server ran out of `next`. Throw rather than return: a silent
|
|
152
|
+
// give-up is indistinguishable from "not installed", the exact misreport paging prevents.
|
|
153
|
+
if (!exhausted) {
|
|
154
|
+
throw createCliError('UNKNOWN', `Gave up looking for ${LD_APP_NAME} after ${MAX_APP_PAGES} pages of installed apps. Set --ld-url or NG_LD_API_URL.`);
|
|
155
|
+
}
|
|
156
|
+
// The systemic case IS a shape problem: rows came back but not one carried
|
|
157
|
+
// `app_metadata.name`, so the field we match on is missing wholesale (a dropped `?fields=`
|
|
158
|
+
// entry, a serializer change). Reporting NOT_FOUND there would claim "ld-app is not
|
|
159
|
+
// installed" on an instance that has it — the misreport this whole scan is built to avoid.
|
|
160
|
+
if (scanned > 0 && named === 0) {
|
|
161
|
+
throw createCliError('UNKNOWN', `No app row from ${FILES_LIST_PATH} carried app_metadata.name (${scanned} scanned) — the files list may no longer return it`);
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
39
165
|
/**
|
|
40
166
|
* Resolve the standalone L&D Lambda base URL. Order: --ld-url flag → NG_LD_API_URL env →
|
|
41
|
-
* ld-app
|
|
42
|
-
*
|
|
167
|
+
* the installed ld-app record's `metadata.api_base_url` → the ld-app built-in default.
|
|
168
|
+
* Cached per (org, instanceId).
|
|
169
|
+
*
|
|
170
|
+
* PIT-7546: this used to read `/core/api/protected/apps/ld-app/config`, which is a
|
|
171
|
+
* machine-to-machine route gated on an `x-integrations-token` shared secret. The CLI sends
|
|
172
|
+
* `x-api-key`, so next-core rejected every call with 'Invalid m2m integrations token' before
|
|
173
|
+
* the L&D Lambda was ever contacted — one root cause behind all 63 `pitcher_ld_*` failures.
|
|
174
|
+
* The replacement reads the same row that route's SQL reads (`pitcher_file` where
|
|
175
|
+
* `app_metadata->>'name' = 'ld-app'` and `instance_id = $2`, `type = 'app'`, not deleted)
|
|
176
|
+
* over a credential the CLI actually holds.
|
|
43
177
|
*/
|
|
44
178
|
export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
|
|
45
179
|
const override = opts.ldUrl?.trim() || env.NG_LD_API_URL?.trim();
|
|
@@ -48,17 +182,33 @@ export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
|
|
|
48
182
|
if (!opts.instanceId) {
|
|
49
183
|
throw createCliError('VALIDATION', '--instance-id is required to resolve the L&D API URL (or set NG_LD_API_URL)');
|
|
50
184
|
}
|
|
51
|
-
|
|
185
|
+
// `--url` outranks `--org` in resolveBaseUrl (baseUrl.ts), so keying on org alone lets two
|
|
186
|
+
// different platform hosts collide on one cached L&D URL within a single process.
|
|
187
|
+
const cacheKey = `${opts.url ?? opts.org ?? 'dev'}:${opts.instanceId}`;
|
|
52
188
|
const cached = baseUrlCache.get(cacheKey);
|
|
53
189
|
if (cached)
|
|
54
190
|
return cached;
|
|
55
191
|
const platform = deps.platformClient ?? clientFor(opts);
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
192
|
+
const metadata = await fetchLdAppMetadata(platform, opts.instanceId);
|
|
193
|
+
if (!metadata) {
|
|
194
|
+
throw createCliError('NOT_FOUND', 'ld-app is not configured in this instance (not installed). Set --ld-url or NG_LD_API_URL.');
|
|
195
|
+
}
|
|
196
|
+
// `api_base_url` is an optional override; absent means "use the app's own default" —
|
|
197
|
+
// but only where that default's credential environment matches ours (see the constant).
|
|
198
|
+
const apiBaseUrl = metadata.api_base_url;
|
|
199
|
+
let url;
|
|
200
|
+
if (typeof apiBaseUrl === 'string' && apiBaseUrl.trim()) {
|
|
201
|
+
url = trimUrl(apiBaseUrl.trim(), `ld-app config (metadata.api_base_url on instance ${opts.instanceId})`);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const platformOrigin = resolveBaseUrl({ org: opts.org, url: opts.url });
|
|
205
|
+
if (platformOrigin !== LD_FALLBACK_PLATFORM_ORIGIN) {
|
|
206
|
+
throw createCliError('NOT_FOUND', `ld-app has no api_base_url configured on this instance, and the built-in default only serves ` +
|
|
207
|
+
`${LD_FALLBACK_PLATFORM_ORIGIN} (this run targets ${platformOrigin}). Set metadata.api_base_url ` +
|
|
208
|
+
`on the instance, or pass --ld-url / NG_LD_API_URL.`);
|
|
209
|
+
}
|
|
210
|
+
url = LD_FALLBACK_BASE_URL;
|
|
60
211
|
}
|
|
61
|
-
const url = trimUrl(apiBaseUrl, 'ld-app config');
|
|
62
212
|
baseUrlCache.set(cacheKey, url);
|
|
63
213
|
return url;
|
|
64
214
|
}
|
|
@@ -160,7 +160,8 @@ export const analyticsTools = [
|
|
|
160
160
|
title: 'DSR per-viewer session timeline',
|
|
161
161
|
description: 'Per-viewer session event timeline for a DSR canvas (drill-down for one viewer). Returns the raw, ' +
|
|
162
162
|
'uncapped event list (not summarized). Works with a member key. Requires canvasId and userEmail — ' +
|
|
163
|
-
'get both from pitcher_analytics_viewers.'
|
|
163
|
+
'get both from pitcher_analytics_viewers: pass its `normalized_email` (or its `email`) VERBATIM. ' +
|
|
164
|
+
'Do not tidy the address — the stored identity carries an ` (External)` suffix.',
|
|
164
165
|
inputShape: {
|
|
165
166
|
instanceId: z.string().describe('Instance ID (required)'),
|
|
166
167
|
canvasId: z.string().describe('Canvas ID (required)'),
|