@pitcher/cli-scripts 2.16.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/README.md +31 -4
- package/dist/src/commands/analytics.d.ts +3 -2
- package/dist/src/commands/analytics.js +3 -2
- package/dist/src/commands/dsr.d.ts +21 -0
- package/dist/src/commands/dsr.js +257 -1
- package/dist/src/commands/scaffolds.js +15 -14
- package/dist/src/core/ld.d.ts +10 -2
- package/dist/src/core/ld.js +159 -9
- package/dist/src/mcp/bundled.js +1 -1
- package/dist/src/mcp/prompts/dsr.js +13 -1
- package/dist/src/mcp/tools/analytics.js +2 -1
- package/dist/src/mcp/tools/dsr.js +49 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ p command --help
|
|
|
12
12
|
|
|
13
13
|
## MCP server
|
|
14
14
|
|
|
15
|
-
Run `p mcp` to start a stdio [MCP](https://modelcontextprotocol.io) server that exposes this CLI's commands as tools to an MCP-capable host (Claude Desktop, Claude Code, etc). It wraps
|
|
15
|
+
Run `p mcp` to start a stdio [MCP](https://modelcontextprotocol.io) server that exposes this CLI's commands as tools to an MCP-capable host (Claude Desktop, Claude Code, etc). It wraps 123 tools, one per CLI `run*` function: every command above (including `dsr create`/`update`/`delete`/`bind-landing` and the full `ld` surface) except the five local `scaffold` subcommands (which just print/write a starter file and make no request). `dev-sync` is exposed as a one-shot sync only; the continuous `watch` mode is not available as a tool.
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
p mcp
|
|
@@ -55,7 +55,7 @@ yarn mcpb:pack # → pitcher.mcpb (validated + packed)
|
|
|
55
55
|
|
|
56
56
|
Install by double-clicking `pitcher.mcpb` (or Settings → Extensions → Install Extension). Claude Desktop prompts for the API key (stored in the OS keychain) and organization; both are editable afterward, so switching orgs needs no reinstall.
|
|
57
57
|
|
|
58
|
-
The bundle reuses the same server as `p mcp` but registers a **content-tool subset (
|
|
58
|
+
The bundle reuses the same server as `p mcp` but registers a **content-tool subset (120)** — it omits the developer-only `publish-app` and `dev-sync`/`dev-sync-revert`. The `organization` field accepts a subdomain (`acme`), a bare hostname (`acme.my.pitcher.com`), or a full `https://` URL. Build details (esbuild single-file, version injection) live in `mcpb/` and `CLAUDE.md`.
|
|
59
59
|
|
|
60
60
|
This section is a reference only. For app-development conventions (modules, settings, mount points, SFDC), see the `pitcher-apps` skill, not this README.
|
|
61
61
|
|
|
@@ -506,7 +506,11 @@ p metadata-template upsert --instance-id 01... --sections-file ./schema.json --a
|
|
|
506
506
|
|
|
507
507
|
## To run the `dsr` command:
|
|
508
508
|
|
|
509
|
-
Create and share a Digital Sales Room (a canvas share link) and get a link back in one command.
|
|
509
|
+
Create and share a Digital Sales Room (a canvas share link) and get a link back in one command. Five subcommands: `create` (build + share, with optional landing-page binding), `update` (change or clear the access settings of an existing share), `delete` (revoke a share), `bind-landing` (bind/unbind a landing template on an existing share), and `scaffold` (print a starter `{source,security,landing}` spec for `--config-file`).
|
|
510
|
+
|
|
511
|
+
The create endpoint is a **get-or-create**: sharing an already-shared canvas returns the existing link and updates only the fields you pass. That is deliberate (it mirrors Admin's "Create link once / Copy link after"), but it means `create` with individual flags can never *remove* a value — omitting a flag leaves the stored value alone. Use `update` for that, and `--clear <field>` to remove one.
|
|
512
|
+
|
|
513
|
+
A `--config-file` spec is different: its `security` block is sent as written, so the `dsr scaffold` starter's `auth_strategy: any_email`, `valid_until: null` and empty lists would all be written onto an existing share. That is why `--config-file` defaults to `--if-exists error` (individual flags default to `return`); pass `--if-exists return` to apply a spec to an already-shared canvas on purpose.
|
|
510
514
|
|
|
511
515
|
```bash
|
|
512
516
|
# simplest — email-gated link (server default auth_strategy=any_email)
|
|
@@ -520,11 +524,34 @@ p dsr create --instance-id 01... --sharebox-file-ids 01A,01B --account-id 01...
|
|
|
520
524
|
# bind/unbind a landing template on an existing share
|
|
521
525
|
p dsr bind-landing --id 01... --landing-template-id 01...
|
|
522
526
|
p dsr bind-landing --id 01... --landing-template-id none
|
|
527
|
+
# refuse to reuse an existing share instead of silently returning it
|
|
528
|
+
p dsr create --canvas-id 01... --if-exists error
|
|
529
|
+
# change an existing share — only the flags you pass are sent
|
|
530
|
+
p dsr update --id 01... --valid-until 2026-12-01T00:00:00Z
|
|
531
|
+
p dsr update --id 01... --auth-strategy specified_domains --allowed-domains acme.com
|
|
532
|
+
# REMOVE a value (this is the only way — omitting the flag is a no-op)
|
|
533
|
+
p dsr update --id 01... --clear valid-until
|
|
534
|
+
p dsr update --id 01... --clear allowed-emails --clear allowed-domains
|
|
535
|
+
# revoke the link entirely (the canvas is kept)
|
|
536
|
+
p dsr delete --id 01...
|
|
523
537
|
# scaffold a spec for --config-file
|
|
524
538
|
p dsr scaffold --out share.json
|
|
525
539
|
```
|
|
526
540
|
|
|
527
|
-
|
|
541
|
+
`update` PATCHes `/core/api/protected/shared-links/{id}`, which distinguishes an **absent** key from an **explicit null** — so omitting `--valid-until` leaves the stored expiry exactly as it is, and `--clear valid-until` is what resets it. `--clear` is repeatable and accepts `valid-until`, `allowed-emails`, `allowed-domains` (the CLI sends `null` for the first and `[]` for the lists, matching what each field's server schema accepts). An empty `--allowed-emails`/`--allowed-domains` is rejected on both `create` and `update`, because `""` from an unset shell variable would otherwise be sent as `[]` and wipe the stored list. `--clear` is a separate flag rather than an in-value sentinel such as `--valid-until ""` because an empty string is something a script can produce by accident from an unset variable, and `none` is already a real `--auth-strategy` value. `tags` and `--allow-download` are **create-time only** — the PATCH surface does not accept them, and `--clear` says so rather than dropping them silently.
|
|
542
|
+
|
|
543
|
+
**Clearing an allow-list is refused while a strategy that needs it is in force**, because an empty allow-list denies every recipient rather than admitting them — the clear would brick the link, not open it. `specified_emails`/`verified_emails` need `allowed-emails`; `specified_domains`/`verified_domains` need `allowed-domains`. The check covers a strategy passed in the same call **and** the one already stored on the share (read from the public `GET /core/api/shared-links/{id}/config`, since the PATCH surface has no GET). If that read fails the update still goes through, with a warning saying the check did not run. Two ways out:
|
|
544
|
+
|
|
545
|
+
```bash
|
|
546
|
+
# relax the gate in the same call (or in an earlier one)
|
|
547
|
+
p dsr update --id 01... --auth-strategy any_email --clear allowed-emails
|
|
548
|
+
# or just replace the list instead of clearing it
|
|
549
|
+
p dsr update --id 01... --allowed-emails new@acme.com
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
`create` returns the share as `{ id, type, file, canvas, instance, organization, status, url, short_url }` — **`url` is the full link; there is no `full_url` field.**
|
|
553
|
+
|
|
554
|
+
Creating, updating and deleting need a share-capable key (`canvas.share` on the link's own instance when `enable_granular_permissions` is on); **binding a landing page currently needs an editor key** (PIT-7525 will open it to reps for their own DSRs) — a denied bind returns the link with a warning. Discover landing template IDs via `p dsr-landing list`.
|
|
528
555
|
|
|
529
556
|
## To run the `ld` command:
|
|
530
557
|
|
|
@@ -83,8 +83,9 @@ export interface AnalyticsUsersOpts extends CommonOpts {
|
|
|
83
83
|
* CLI does not expose it.)
|
|
84
84
|
* - `--stats` → `GET /analytics/users/stats`: requires `start_date`/`end_date`
|
|
85
85
|
* (defaulted via `withDefaultWindow`), and `type` is ALWAYS forced to
|
|
86
|
-
* `'active-users'` — the wire
|
|
87
|
-
*
|
|
86
|
+
* `'active-users'` — `type` is required on the wire and rejected with a 400
|
|
87
|
+
* unless it names a supported statistic (it used to default to a nonexistent
|
|
88
|
+
* `all`, which 500'd; see `routes/users.ts` USER_STATS_TYPES). The
|
|
88
89
|
* page-size param on this route is the camelCase `pageSize` (verified
|
|
89
90
|
* `users.ts:230`) — the one route in the CLI that reads camelCase on the wire.
|
|
90
91
|
*/
|
|
@@ -146,8 +146,9 @@ export async function runAnalyticsPopularity(opts, deps = {}) {
|
|
|
146
146
|
* CLI does not expose it.)
|
|
147
147
|
* - `--stats` → `GET /analytics/users/stats`: requires `start_date`/`end_date`
|
|
148
148
|
* (defaulted via `withDefaultWindow`), and `type` is ALWAYS forced to
|
|
149
|
-
* `'active-users'` — the wire
|
|
150
|
-
*
|
|
149
|
+
* `'active-users'` — `type` is required on the wire and rejected with a 400
|
|
150
|
+
* unless it names a supported statistic (it used to default to a nonexistent
|
|
151
|
+
* `all`, which 500'd; see `routes/users.ts` USER_STATS_TYPES). The
|
|
151
152
|
* page-size param on this route is the camelCase `pageSize` (verified
|
|
152
153
|
* `users.ts:230`) — the one route in the CLI that reads camelCase on the wire.
|
|
153
154
|
*/
|
|
@@ -31,6 +31,27 @@ export interface DsrCreateOpts extends CommonOpts {
|
|
|
31
31
|
landingTemplateId?: string;
|
|
32
32
|
landingContext?: string;
|
|
33
33
|
configFile?: string;
|
|
34
|
+
ifExists?: string;
|
|
34
35
|
}
|
|
36
|
+
export declare const IF_EXISTS_MODES: readonly ["return", "error"];
|
|
37
|
+
export type IfExistsMode = (typeof IF_EXISTS_MODES)[number];
|
|
35
38
|
export declare function runDsrCreate(opts: DsrCreateOpts, deps?: DsrDeps): Promise<Envelope<unknown>>;
|
|
39
|
+
export declare const CLEARABLE_UPDATE_FIELDS: readonly ["valid-until", "allowed-emails", "allowed-domains"];
|
|
40
|
+
export type ClearableUpdateField = (typeof CLEARABLE_UPDATE_FIELDS)[number];
|
|
41
|
+
export interface DsrUpdateOpts extends CommonOpts {
|
|
42
|
+
id?: string;
|
|
43
|
+
instanceId?: string;
|
|
44
|
+
authStrategy?: string;
|
|
45
|
+
allowedEmails?: string[];
|
|
46
|
+
allowedDomains?: string[];
|
|
47
|
+
validUntil?: string;
|
|
48
|
+
allowForward?: boolean;
|
|
49
|
+
clear?: string[];
|
|
50
|
+
}
|
|
51
|
+
export declare function assembleUpdateBody(opts: DsrUpdateOpts): Record<string, unknown>;
|
|
52
|
+
export declare function runDsrUpdate(opts: DsrUpdateOpts, deps?: DsrDeps): Promise<Envelope<unknown>>;
|
|
53
|
+
export declare function runDsrDelete(opts: CommonOpts & {
|
|
54
|
+
id?: string;
|
|
55
|
+
instanceId?: string;
|
|
56
|
+
}, deps?: DsrDeps): Promise<Envelope<unknown>>;
|
|
36
57
|
export declare function createDsrCommand(): Command;
|
package/dist/src/commands/dsr.js
CHANGED
|
@@ -156,14 +156,66 @@ function resolveLandingBody(opts, cfgLanding) {
|
|
|
156
156
|
b.landing_page_context = parseJsonObject(opts.landingContext, 'landing-context');
|
|
157
157
|
return b;
|
|
158
158
|
}
|
|
159
|
+
// ── create collision policy (PIT-7521) ──────────────────────────────────────
|
|
160
|
+
// `POST /core/api/protected/shared-links` is a documented get-or-create ("Shared link
|
|
161
|
+
// created successfully or existing link returned"): a second create for the same canvas
|
|
162
|
+
// hands back the link that already exists, mirroring Admin's "Create link once / Copy
|
|
163
|
+
// link after". `--if-exists return` keeps exactly that and stays the default for individual
|
|
164
|
+
// flags, so nothing scripted with them changes. `--if-exists error` asks for honest create
|
|
165
|
+
// semantics, which the CLI gets from a preflight GET against Django's `shared-links/canvas/{id}`
|
|
166
|
+
// — the same endpoint the server's get-or-create consults. Advisory, not atomic: the server has
|
|
167
|
+
// no create-only mode, so a link minted between the GET and the POST is still returned.
|
|
168
|
+
//
|
|
169
|
+
// `--config-file` defaults to `error` instead. A spec's `security` block is sent verbatim, and
|
|
170
|
+
// the `dsr scaffold` starter fills it with `auth_strategy: any_email`, `[]` lists and
|
|
171
|
+
// `valid_until: null`. Against an existing share the POST writes every key present, so a
|
|
172
|
+
// restricted, time-boxed link would silently open to any email and never expire. Applying a
|
|
173
|
+
// spec to an existing share has to be asked for with `--if-exists return`.
|
|
174
|
+
export const IF_EXISTS_MODES = ['return', 'error'];
|
|
175
|
+
function normalizeIfExists(raw, fromConfigFile) {
|
|
176
|
+
if (raw === undefined)
|
|
177
|
+
return fromConfigFile ? 'error' : 'return';
|
|
178
|
+
const mode = raw.trim().toLowerCase(); // same normalisation as --clear, so the two flags agree
|
|
179
|
+
if (!IF_EXISTS_MODES.includes(mode))
|
|
180
|
+
throw createCliError('VALIDATION', `--if-exists must be one of: ${IF_EXISTS_MODES.join(', ')} (got "${raw}")`);
|
|
181
|
+
return mode;
|
|
182
|
+
}
|
|
183
|
+
async function assertNoExistingShare(client, canvasId, fromConfigFile) {
|
|
184
|
+
let existing;
|
|
185
|
+
try {
|
|
186
|
+
const res = await client.get(`/api/v1/shared-links/canvas/${encodeURIComponent(canvasId)}`);
|
|
187
|
+
existing = (res.data ?? null);
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
// 404 is the ONLY answer that means "not shared yet". Anything else (403, 500, a dead
|
|
191
|
+
// socket) leaves the guard UNRESOLVED, and an unresolved guard must fail rather than
|
|
192
|
+
// wave the create through as if the canvas were unshared.
|
|
193
|
+
if (toCliError(e).code === 'NOT_FOUND')
|
|
194
|
+
return;
|
|
195
|
+
throw e;
|
|
196
|
+
}
|
|
197
|
+
// Any 2xx means a link exists, whatever the body looks like — `id` is only used to name
|
|
198
|
+
// it in the message, never to decide whether to fail.
|
|
199
|
+
const known = existing?.id ? ` ${existing.id}` : '';
|
|
200
|
+
throw createCliError('CONFLICT', `canvas ${canvasId} already has shared link${known} — change it with \`p dsr update${existing?.id ? ` --id ${existing.id}` : ''}\`, or pass --if-exists return to reuse it${fromConfigFile ? ' (the security block of the spec is then written onto the existing share as-is)' : ''}`);
|
|
201
|
+
}
|
|
159
202
|
export async function runDsrCreate(opts, deps = {}) {
|
|
160
203
|
try {
|
|
204
|
+
const fromConfigFile = opts.configFile !== undefined;
|
|
205
|
+
const ifExists = normalizeIfExists(opts.ifExists, fromConfigFile);
|
|
161
206
|
const { body, landing: cfgLanding } = assembleSpec(opts);
|
|
207
|
+
// flags only: a --config-file spec is guarded by its --if-exists error default instead
|
|
208
|
+
assertNoEmptyLists(opts, (flag) => `omit it, or remove a stored list with \`p dsr update --clear ${flag}\``);
|
|
162
209
|
assertCreateBody(body, opts.instanceId);
|
|
163
210
|
const landingBody = resolveLandingBody(opts, cfgLanding); // parse/validate context BEFORE the network call
|
|
164
211
|
// bind against the same instance the share was created in — a config-file source.instance_id must reach the PUT too
|
|
165
212
|
const effectiveInstanceId = body.instance_id ?? opts.instanceId;
|
|
166
213
|
const client = clientFor(opts, deps);
|
|
214
|
+
// only the existing-canvas source can collide: --sharebox-file-ids mints a NEW canvas
|
|
215
|
+
// every time, so there is never a prior link to find
|
|
216
|
+
const sourceCanvasId = body.canvas_id;
|
|
217
|
+
if (ifExists === 'error' && sourceCanvasId)
|
|
218
|
+
await assertNoExistingShare(client, sourceCanvasId, fromConfigFile);
|
|
167
219
|
const res = await client.post(ENDPOINT, body);
|
|
168
220
|
const share = res.data;
|
|
169
221
|
if (!landingBody)
|
|
@@ -183,6 +235,178 @@ export async function runDsrCreate(opts, deps = {}) {
|
|
|
183
235
|
return fail(toCliError(e));
|
|
184
236
|
}
|
|
185
237
|
}
|
|
238
|
+
// ── explicit clear (PIT-7521) ───────────────────────────────────────────────
|
|
239
|
+
// `dsr update` sends ONLY the fields the caller named, so the server's `hasField()`
|
|
240
|
+
// gate leaves every other column alone. That makes "omit the flag" a no-op — correct,
|
|
241
|
+
// but it leaves no way to REMOVE a value that is already set. `--clear <field>` is
|
|
242
|
+
// that way, and it is a separate flag rather than an in-value sentinel
|
|
243
|
+
// (`--valid-until ""` / `--valid-until none`) on purpose:
|
|
244
|
+
// * an empty string can be produced by ACCIDENT — `--valid-until "$MAYBE_UNSET"` is
|
|
245
|
+
// ordinary shell, and under a sentinel rule it would silently wipe a live expiry
|
|
246
|
+
// instead of being the no-op the author meant;
|
|
247
|
+
// * `none` is already a legal `--auth-strategy` value, so one sentinel would mean
|
|
248
|
+
// "clear" on one flag and a real value on its neighbour;
|
|
249
|
+
// * `--clear` is enumerable, so naming a field that CANNOT be cleared fails loudly
|
|
250
|
+
// instead of being silently dropped into a body the server ignores.
|
|
251
|
+
// `dsr bind-landing --landing-template-id none` keeps its own sentinel: that is
|
|
252
|
+
// pre-existing public behaviour on a different endpoint, not a new choice.
|
|
253
|
+
export const CLEARABLE_UPDATE_FIELDS = ['valid-until', 'allowed-emails', 'allowed-domains'];
|
|
254
|
+
// flag name → wire key + the flag it would contradict
|
|
255
|
+
const CLEARABLE = {
|
|
256
|
+
'valid-until': { wire: 'valid_until', opt: 'validUntil' },
|
|
257
|
+
'allowed-emails': { wire: 'allowed_emails', opt: 'allowedEmails' },
|
|
258
|
+
'allowed-domains': { wire: 'allowed_domains', opt: 'allowedDomains' },
|
|
259
|
+
};
|
|
260
|
+
// Set on create, never on PATCH: `tags` lands on the ShareBox canvas and `allow_download`
|
|
261
|
+
// on canvas metadata, neither of which the shared-links PATCH schema accepts.
|
|
262
|
+
const CREATE_ONLY_FIELDS = new Set(['tags', 'allow-download']);
|
|
263
|
+
// The CLEAR value is per-field and the two shapes are NOT interchangeable. Server-side
|
|
264
|
+
// `valid_until` is `z.string().datetime().nullable()`, so JSON null clears it;
|
|
265
|
+
// `allowed_emails`/`allowed_domains` are plain optional arrays with no `.nullable()`,
|
|
266
|
+
// so a null there is a 400 and an empty array is their clear. Built fresh per call —
|
|
267
|
+
// a shared `[]` literal would be one mutable array handed to every request body.
|
|
268
|
+
function clearValueFor(field) {
|
|
269
|
+
return field === 'valid-until' ? null : [];
|
|
270
|
+
}
|
|
271
|
+
function normalizeClearFields(raw) {
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const entry of raw ?? []) {
|
|
274
|
+
// accept the flag spelling (valid-until) and the wire spelling (valid_until)
|
|
275
|
+
const name = entry.trim().toLowerCase().replace(/_/g, '-');
|
|
276
|
+
if (CREATE_ONLY_FIELDS.has(name))
|
|
277
|
+
throw createCliError('VALIDATION', `"${name}" is set at create time only — the shared-links PATCH surface does not accept it, so it cannot be cleared`);
|
|
278
|
+
if (!CLEARABLE_UPDATE_FIELDS.includes(name))
|
|
279
|
+
throw createCliError('VALIDATION', `--clear must be one of: ${CLEARABLE_UPDATE_FIELDS.join(', ')} (got "${entry}")`);
|
|
280
|
+
out.push(name);
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
// Mirrors the server's refine, which only fires when auth_strategy is in the body. A PATCH that
|
|
285
|
+
// clears a list without naming a strategy is checked against the STORED strategy in runDsrUpdate.
|
|
286
|
+
function assertUpdateAuth(body) {
|
|
287
|
+
const strat = body.auth_strategy;
|
|
288
|
+
if (strat === undefined)
|
|
289
|
+
return;
|
|
290
|
+
if (!AUTH_STRATEGIES.includes(strat))
|
|
291
|
+
throw createCliError('VALIDATION', `--auth-strategy must be one of: ${AUTH_STRATEGIES.join(', ')}`);
|
|
292
|
+
const emails = body.allowed_emails;
|
|
293
|
+
const domains = body.allowed_domains;
|
|
294
|
+
if ((strat === 'specified_emails' || strat === 'verified_emails') && !(emails && emails.length))
|
|
295
|
+
throw createCliError('VALIDATION', `--allowed-emails is required for --auth-strategy ${strat}`);
|
|
296
|
+
if ((strat === 'specified_domains' || strat === 'verified_domains') && !(domains && domains.length))
|
|
297
|
+
throw createCliError('VALIDATION', `--allowed-domains is required for --auth-strategy ${strat}`);
|
|
298
|
+
}
|
|
299
|
+
// `parseTags('')` is `[]`, not undefined, so `--allowed-emails "$MAYBE_UNSET"` would reach the
|
|
300
|
+
// server as an empty array — which is precisely the wire shape that CLEARS the list. That makes
|
|
301
|
+
// an unset shell variable indistinguishable from a deliberate removal, the accident `--clear`
|
|
302
|
+
// exists to rule out. So an empty list is rejected on `update` AND on `create` (whose get-or-create
|
|
303
|
+
// POST writes the [] over an existing share's list), and `--clear` stays the ONE way to remove a
|
|
304
|
+
// value. (Same reasoning covers an MCP caller passing `allowedEmails: []`.)
|
|
305
|
+
function assertNoEmptyLists(opts, hint = (flag) => `use \`--clear ${flag}\` to remove the list`) {
|
|
306
|
+
const lists = [
|
|
307
|
+
['allowed-emails', opts.allowedEmails],
|
|
308
|
+
['allowed-domains', opts.allowedDomains],
|
|
309
|
+
];
|
|
310
|
+
for (const [flag, value] of lists) {
|
|
311
|
+
if (Array.isArray(value) && value.length === 0)
|
|
312
|
+
throw createCliError('VALIDATION', `--${flag} was empty — ${hint(flag)}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// A stored strategy that REQUIRES a list, and the list it requires.
|
|
316
|
+
const STRATEGY_REQUIRES = {
|
|
317
|
+
specified_emails: 'allowed_emails',
|
|
318
|
+
verified_emails: 'allowed_emails',
|
|
319
|
+
specified_domains: 'allowed_domains',
|
|
320
|
+
verified_domains: 'allowed_domains',
|
|
321
|
+
};
|
|
322
|
+
function clearedListFields(body) {
|
|
323
|
+
return ['allowed_emails', 'allowed_domains'].filter((k) => Array.isArray(body[k]) && body[k].length === 0);
|
|
324
|
+
}
|
|
325
|
+
// The protected PATCH surface has no GET, but the PUBLIC pre-auth config the sharing page reads
|
|
326
|
+
// does expose auth_strategy. Returns null when it cannot be resolved — see the call site for why
|
|
327
|
+
// that is a warning here and a hard failure in the --if-exists preflight.
|
|
328
|
+
async function readStoredAuthStrategy(client, id) {
|
|
329
|
+
try {
|
|
330
|
+
const res = await client.get(`/core/api/shared-links/${encodeURIComponent(id)}/config`);
|
|
331
|
+
const strategy = res.data?.auth_strategy;
|
|
332
|
+
return typeof strategy === 'string' ? strategy : null;
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
export function assembleUpdateBody(opts) {
|
|
339
|
+
const cleared = normalizeClearFields(opts.clear);
|
|
340
|
+
assertNoEmptyLists(opts);
|
|
341
|
+
for (const field of cleared) {
|
|
342
|
+
if (opts[CLEARABLE[field].opt] !== undefined)
|
|
343
|
+
throw createCliError('VALIDATION', `--clear ${field} conflicts with --${field}: pass one or the other`);
|
|
344
|
+
}
|
|
345
|
+
const body = {};
|
|
346
|
+
if (opts.authStrategy !== undefined)
|
|
347
|
+
body.auth_strategy = opts.authStrategy;
|
|
348
|
+
if (opts.allowedEmails !== undefined)
|
|
349
|
+
body.allowed_emails = opts.allowedEmails;
|
|
350
|
+
if (opts.allowedDomains !== undefined)
|
|
351
|
+
body.allowed_domains = opts.allowedDomains;
|
|
352
|
+
if (opts.validUntil !== undefined)
|
|
353
|
+
body.valid_until = opts.validUntil;
|
|
354
|
+
if (opts.allowForward !== undefined)
|
|
355
|
+
body.allow_forward = opts.allowForward;
|
|
356
|
+
for (const field of cleared)
|
|
357
|
+
body[CLEARABLE[field].wire] = clearValueFor(field);
|
|
358
|
+
if (Object.keys(body).length === 0)
|
|
359
|
+
throw createCliError('VALIDATION', `dsr update needs at least one field to change (or --clear <${CLEARABLE_UPDATE_FIELDS.join('|')}>)`);
|
|
360
|
+
assertUpdateAuth(body);
|
|
361
|
+
return body;
|
|
362
|
+
}
|
|
363
|
+
export async function runDsrUpdate(opts, deps = {}) {
|
|
364
|
+
try {
|
|
365
|
+
if (!opts.id)
|
|
366
|
+
throw createCliError('VALIDATION', '--id is required for dsr update');
|
|
367
|
+
const body = assembleUpdateBody(opts);
|
|
368
|
+
const client = clientFor(opts, deps);
|
|
369
|
+
const warnings = [];
|
|
370
|
+
// assertUpdateAuth catches "set specified_emails AND clear the list" in one call. The same
|
|
371
|
+
// end state is reachable by clearing the list alone against a link ALREADY stored that way,
|
|
372
|
+
// which assertUpdateAuth cannot see. Both viewer gates treat an empty allow-list as deny, so
|
|
373
|
+
// this bricks the link rather than opening it — a usability guard, not a safety gate. That is
|
|
374
|
+
// why an unresolved lookup degrades to a warning here while the --if-exists preflight, which
|
|
375
|
+
// gates a write that would otherwise silently succeed differently, hard-fails instead.
|
|
376
|
+
const clearedLists = body.auth_strategy === undefined ? clearedListFields(body) : [];
|
|
377
|
+
if (clearedLists.length > 0) {
|
|
378
|
+
const stored = await readStoredAuthStrategy(client, opts.id);
|
|
379
|
+
const required = stored ? STRATEGY_REQUIRES[stored] : undefined;
|
|
380
|
+
if (stored === null) {
|
|
381
|
+
warnings.push(`could not read this share's stored auth strategy, so clearing ${clearedLists.join(' and ')} was not checked against it`);
|
|
382
|
+
}
|
|
383
|
+
else if (required && clearedLists.includes(required)) {
|
|
384
|
+
throw createCliError('VALIDATION', `this share's auth strategy is ${stored}, which requires ${required} — clearing it would lock every recipient out. ` +
|
|
385
|
+
'Pass --auth-strategy in the same call to relax the gate first.');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const url = `${ENDPOINT}/${encodeURIComponent(opts.id)}`;
|
|
389
|
+
const res = await client.patch(url, body, opts.instanceId ? { params: { instance_id: opts.instanceId } } : undefined);
|
|
390
|
+
return ok(res.data, warnings);
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
return fail(toCliError(e));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
export async function runDsrDelete(opts, deps = {}) {
|
|
397
|
+
try {
|
|
398
|
+
if (!opts.id)
|
|
399
|
+
throw createCliError('VALIDATION', '--id is required for dsr delete');
|
|
400
|
+
const client = clientFor(opts, deps);
|
|
401
|
+
const url = `${ENDPOINT}/${encodeURIComponent(opts.id)}`;
|
|
402
|
+
const res = await client.delete(url, opts.instanceId ? { params: { instance_id: opts.instanceId } } : undefined);
|
|
403
|
+
// the route answers 204 with an empty body — echo the id so the caller has something to log
|
|
404
|
+
return ok(res.data || { id: opts.id, deleted: true });
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
return fail(toCliError(e));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
186
410
|
export function createDsrCommand() {
|
|
187
411
|
const cmd = new Command('dsr').description('create and share a Digital Sales Room (a canvas share link)');
|
|
188
412
|
withAuthFlags(cmd.command('scaffold'))
|
|
@@ -193,7 +417,7 @@ export function createDsrCommand() {
|
|
|
193
417
|
emitEnvelope(await runDsrScaffold({ ...options, quiet: globals.quiet }), globals);
|
|
194
418
|
});
|
|
195
419
|
withAuthFlags(cmd.command('create'))
|
|
196
|
-
.description('create + share a DSR (canvas share link) and return the link')
|
|
420
|
+
.description('create + share a DSR (canvas share link) and return the link (id, url, short_url, canvas)')
|
|
197
421
|
.option('--instance-id <id>', 'Instance ID (required for --sharebox-file-ids)')
|
|
198
422
|
.option('--canvas-id <id>', 'Existing canvas to share (one source required)')
|
|
199
423
|
.option('--sharebox-file-ids <csv>', 'Build a canvas from these file IDs (one source required)', parseTags)
|
|
@@ -211,6 +435,11 @@ export function createDsrCommand() {
|
|
|
211
435
|
.option('--landing-template-id <id>', 'Landing template to bind (optional)')
|
|
212
436
|
.option('--landing-context <json>', 'Landing personalization merge fields (JSON object)')
|
|
213
437
|
.option('--config-file <path>', 'Full {source,security,landing} spec JSON (mutually exclusive with the flags above)')
|
|
438
|
+
.option('--if-exists <mode>', 'return|error. What to do when the canvas is already shared: `return` hands back the existing link (default ' +
|
|
439
|
+
'with individual flags), `error` refuses and points at `dsr update` (default with --config-file, whose ' +
|
|
440
|
+
'security block is sent as-is and would overwrite the existing share). The check is a preflight lookup, so ' +
|
|
441
|
+
'it is advisory rather than atomic — a link created between the check and the share is still returned. ' +
|
|
442
|
+
'Never applies to a ShareBox source, which always mints a new canvas.')
|
|
214
443
|
.action(async (options, command) => {
|
|
215
444
|
const globals = command.optsWithGlobals();
|
|
216
445
|
emitEnvelope(await runDsrCreate({ ...options, quiet: globals.quiet }), globals);
|
|
@@ -225,5 +454,32 @@ export function createDsrCommand() {
|
|
|
225
454
|
const globals = command.optsWithGlobals();
|
|
226
455
|
emitEnvelope(await runDsrBindLanding({ ...options, quiet: globals.quiet }), globals);
|
|
227
456
|
});
|
|
457
|
+
// --allow-forward is declared BEFORE --no-allow-forward on purpose: commander takes a
|
|
458
|
+
// boolean's default from whichever of the pair it sees first, so the reverse order would
|
|
459
|
+
// make `allowForward` default to `true` and every update would ship allow_forward: true,
|
|
460
|
+
// clobbering a rep's opt-out. `create` above relies on the same ordering.
|
|
461
|
+
withAuthFlags(cmd.command('update'))
|
|
462
|
+
.description('update an existing share: only the flags you pass are sent; --clear removes a value')
|
|
463
|
+
.option('--id <id>', 'Shared link ID (required)')
|
|
464
|
+
.option('--auth-strategy <v>', 'none|any_email|specified_emails|specified_domains|verified_emails|verified_domains')
|
|
465
|
+
.option('--allowed-emails <csv>', 'Allowed emails (required for specified/verified_emails)', parseTags)
|
|
466
|
+
.option('--allowed-domains <csv>', 'Allowed domains (required for specified/verified_domains)', parseTags)
|
|
467
|
+
.option('--valid-until <iso>', 'Expiry (ISO datetime)')
|
|
468
|
+
.option('--allow-forward', 'Allow recipients to forward')
|
|
469
|
+
.option('--no-allow-forward', 'Disallow forwarding')
|
|
470
|
+
.option('--clear <field>', `Remove a value instead of setting one — repeatable. One of: ${CLEARABLE_UPDATE_FIELDS.join(' | ')}`, (value, prev) => [...prev, value], [])
|
|
471
|
+
.option('--instance-id <id>', 'Instance ID (optional)')
|
|
472
|
+
.action(async (options, command) => {
|
|
473
|
+
const globals = command.optsWithGlobals();
|
|
474
|
+
emitEnvelope(await runDsrUpdate({ ...options, quiet: globals.quiet }), globals);
|
|
475
|
+
});
|
|
476
|
+
withAuthFlags(cmd.command('delete'))
|
|
477
|
+
.description('revoke a share link permanently (the canvas itself is kept)')
|
|
478
|
+
.option('--id <id>', 'Shared link ID (required)')
|
|
479
|
+
.option('--instance-id <id>', 'Instance ID (optional)')
|
|
480
|
+
.action(async (options, command) => {
|
|
481
|
+
const globals = command.optsWithGlobals();
|
|
482
|
+
emitEnvelope(await runDsrDelete({ ...options, quiet: globals.quiet }), globals);
|
|
483
|
+
});
|
|
228
484
|
return cmd;
|
|
229
485
|
}
|
|
@@ -177,11 +177,21 @@ export const ANNOUNCEMENT_SCAFFOLD_JSON = JSON.stringify(ANNOUNCEMENT_SCAFFOLD_D
|
|
|
177
177
|
/**
|
|
178
178
|
* Starter metadata-field schema for `p metadata-template scaffold`. A working
|
|
179
179
|
* example, not a blank template: one section demonstrating every field `type`
|
|
180
|
-
* (text/number/date/select/multi-select/checkbox
|
|
181
|
-
* serializer accepts. NOTE: `options` (select/multi-select)
|
|
182
|
-
* { label, value } objects — bare strings are rejected 400.
|
|
183
|
-
* cannot be `is_sortable`. A select/multi-select `default` is an
|
|
184
|
-
* Section-level `are_fields_dependent` and field-level
|
|
180
|
+
* that is usable end-to-end (text/number/date/select/multi-select/checkbox) and
|
|
181
|
+
* every attribute the serializer accepts. NOTE: `options` (select/multi-select)
|
|
182
|
+
* MUST be { label, value } objects — bare strings are rejected 400.
|
|
183
|
+
* `multi-select` cannot be `is_sortable`. A select/multi-select `default` is an
|
|
184
|
+
* option `value`. Section-level `are_fields_dependent` and field-level
|
|
185
|
+
* `help_text` are optional.
|
|
186
|
+
*
|
|
187
|
+
* Deliberately excludes `html`. Django's MetadataTemplateFieldSerializer still
|
|
188
|
+
* lists it in TYPE_CHOICES, so an upsert is accepted, but nothing downstream
|
|
189
|
+
* supports it for file/canvas metadata: the Admin field-type picker has no HTML
|
|
190
|
+
* entry, the template list renders such a field as "Unknown", and
|
|
191
|
+
* TYPE_FIELD_CLASSES_MAP (pitcher/serializers/metadataserializers.py) has no
|
|
192
|
+
* `html` key, so building the per-instance metadata value serializer raises
|
|
193
|
+
* KeyError. Shipping it in the scaffold handed users a field they could not
|
|
194
|
+
* edit or populate (PIT-7487 QA).
|
|
185
195
|
*/
|
|
186
196
|
const METADATA_TEMPLATE_SCAFFOLD_DATA = [
|
|
187
197
|
{
|
|
@@ -256,15 +266,6 @@ const METADATA_TEMPLATE_SCAFFOLD_DATA = [
|
|
|
256
266
|
is_pitch_master: false,
|
|
257
267
|
default: false,
|
|
258
268
|
},
|
|
259
|
-
{
|
|
260
|
-
name: 'notes',
|
|
261
|
-
label: 'Notes',
|
|
262
|
-
type: 'html',
|
|
263
|
-
is_required: false,
|
|
264
|
-
is_filterable: false,
|
|
265
|
-
is_sortable: false,
|
|
266
|
-
is_pitch_master: false,
|
|
267
|
-
},
|
|
268
269
|
],
|
|
269
270
|
},
|
|
270
271
|
];
|
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
|
}
|
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` keeps all
|
|
4
|
+
// None are relevant to a non-technical adopter installing the extension. The CLI `p mcp` keeps all 123.
|
|
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));
|
|
@@ -42,7 +42,19 @@ export const dsrPrompts = [
|
|
|
42
42
|
'3. (Optional) To brand the pre-auth page, list templates with `pitcher_dsr_landing_list` and bind one via ' +
|
|
43
43
|
'`landingTemplateId` — or point the user at `seed_dsr_landing` to author a new one first.',
|
|
44
44
|
'4. Call `pitcher_dsr_create` with the resolved fields. A denied/failed landing bind returns the link with a ' +
|
|
45
|
-
'warning (not a failure) — surface it. Report the returned `short_url
|
|
45
|
+
'warning (not a failure) — surface it. Report the returned `url` (the full share link; `short_url` is the ' +
|
|
46
|
+
'vanity form and may be absent). There is no `full_url` field.',
|
|
47
|
+
'5. **To change a share that already exists, use `pitcher_dsr_update`, not another create.** Creating with ' +
|
|
48
|
+
'individual fields is a get-or-create: it returns the existing link and only touches the fields you pass, ' +
|
|
49
|
+
'so it can never REMOVE a value. To take an expiry off a link, or to drop an allow-list, pass that field in `clear` ' +
|
|
50
|
+
'(e.g. `clear: ["valid-until"]`) — leaving the field out is a no-op, not a reset. `pitcher_dsr_delete` ' +
|
|
51
|
+
'revokes the link for good.',
|
|
52
|
+
'6. **Clearing an allow-list is refused while a strategy that needs it is in force.** ' +
|
|
53
|
+
'`specified_emails`/`verified_emails` need `allowedEmails`; `specified_domains`/`verified_domains` need ' +
|
|
54
|
+
'`allowedDomains`. That applies to the strategy already stored on the share AND to one you pass in the ' +
|
|
55
|
+
'same call, because an empty allow-list locks every recipient out. Relax the gate in the same call ' +
|
|
56
|
+
'(e.g. `authStrategy: "any_email"` together with `clear: ["allowed-emails"]`) — or just replace the list ' +
|
|
57
|
+
'by passing a new `allowedEmails` instead of clearing it. An EMPTY array is rejected: use `clear`.',
|
|
46
58
|
].join('\n');
|
|
47
59
|
return [{ role: 'user', content: { type: 'text', text } }];
|
|
48
60
|
},
|
|
@@ -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)'),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { runDsrCreate, runDsrBindLanding } from '../../commands/dsr.js';
|
|
2
|
+
import { runDsrCreate, runDsrBindLanding, runDsrUpdate, runDsrDelete, CLEARABLE_UPDATE_FIELDS, IF_EXISTS_MODES } from '../../commands/dsr.js';
|
|
3
3
|
const AUTH_STRATEGIES = [
|
|
4
4
|
'none', 'any_email', 'specified_emails', 'specified_domains', 'verified_emails', 'verified_domains',
|
|
5
5
|
];
|
|
@@ -7,7 +7,7 @@ export const dsrTools = [
|
|
|
7
7
|
{
|
|
8
8
|
name: 'pitcher_dsr_create',
|
|
9
9
|
title: 'Create and share a DSR',
|
|
10
|
-
description: 'Create a Digital Sales Room (a canvas share link) and get a clickable link back. Source is a canvas (canvasId) OR files (shareboxFileIds) - exactly one. Optional email/domain gating and a bound landing-page template.
|
|
10
|
+
description: 'Create a Digital Sales Room (a canvas share link) and get a clickable link back. Source is a canvas (canvasId) OR files (shareboxFileIds) - exactly one. Optional email/domain gating and a bound landing-page template. Returns the share: id, url (the full link), short_url, and a nested canvas object - there is no full_url field. This endpoint is a GET-OR-CREATE: with individual fields, sharing an already-shared canvas returns the existing link and only updates the fields you pass, so use pitcher_dsr_update to change or remove a value on an existing share. A configFile spec sends its whole security block (nulls and empty lists included), so for an already-shared canvas it refuses by default (ifExists "error"). A failed landing bind does not fail the call: it returns the link with a warning.',
|
|
11
11
|
inputShape: {
|
|
12
12
|
instanceId: z.string().optional().describe('Instance ID (required for the shareboxFileIds source)'),
|
|
13
13
|
canvasId: z.string().optional().describe('Canvas to share (exactly one of canvasId / shareboxFileIds)'),
|
|
@@ -16,8 +16,8 @@ export const dsrTools = [
|
|
|
16
16
|
accountId: z.string().optional(),
|
|
17
17
|
templateId: z.string().optional(),
|
|
18
18
|
authStrategy: z.enum(AUTH_STRATEGIES).optional().describe('Access gating (server default any_email)'),
|
|
19
|
-
allowedEmails: z.array(z.string()).optional().describe('Required for specified_emails / verified_emails'),
|
|
20
|
-
allowedDomains: z.array(z.string()).optional().describe('Required for specified_domains / verified_domains'),
|
|
19
|
+
allowedEmails: z.array(z.string()).optional().describe('Required for specified_emails / verified_emails. An EMPTY array is rejected'),
|
|
20
|
+
allowedDomains: z.array(z.string()).optional().describe('Required for specified_domains / verified_domains. An EMPTY array is rejected'),
|
|
21
21
|
validUntil: z.string().optional().describe('Expiry, ISO datetime'),
|
|
22
22
|
allowForward: z.boolean().optional(),
|
|
23
23
|
allowDownload: z.boolean().optional(),
|
|
@@ -25,6 +25,10 @@ export const dsrTools = [
|
|
|
25
25
|
landingTemplateId: z.string().optional().describe('Bind a landing-page template (discover via pitcher_dsr_landing_list)'),
|
|
26
26
|
landingContext: z.string().optional().describe('Landing-page merge fields, a JSON object as a string'),
|
|
27
27
|
configFile: z.string().optional().describe('Path on the MCP server host to a full {source,security,landing} spec JSON; mutually exclusive with the individual flags'),
|
|
28
|
+
ifExists: z
|
|
29
|
+
.enum(IF_EXISTS_MODES)
|
|
30
|
+
.optional()
|
|
31
|
+
.describe('Canvas already shared: "return" hands back the existing link (default with individual fields), "error" refuses and points at pitcher_dsr_update (default with configFile, whose security block would otherwise overwrite the existing share). Not checked for a ShareBox source, which always mints a new canvas'),
|
|
28
32
|
},
|
|
29
33
|
run: runDsrCreate,
|
|
30
34
|
readOnly: false,
|
|
@@ -44,4 +48,45 @@ export const dsrTools = [
|
|
|
44
48
|
readOnly: false,
|
|
45
49
|
write: true,
|
|
46
50
|
},
|
|
51
|
+
{
|
|
52
|
+
name: 'pitcher_dsr_update',
|
|
53
|
+
title: 'Update a DSR share',
|
|
54
|
+
description: 'Change the access settings of an EXISTING DSR share (expiry, gating, allowed emails/domains, forwarding). Only the fields you pass are sent, so anything you omit is left exactly as it is. To REMOVE a value rather than set one, name it in `clear` — omitting a field never clears it. Use this instead of re-running pitcher_dsr_create to change or clear a value on an existing share. Note tags and allowDownload are create-time only and cannot be changed here.',
|
|
55
|
+
inputShape: {
|
|
56
|
+
id: z.string().describe('Shared-link ID'),
|
|
57
|
+
authStrategy: z.enum(AUTH_STRATEGIES).optional().describe('Access gating'),
|
|
58
|
+
allowedEmails: z
|
|
59
|
+
.array(z.string())
|
|
60
|
+
.optional()
|
|
61
|
+
.describe('Required for specified_emails / verified_emails. An EMPTY array is rejected - use clear:["allowed-emails"] to remove the list'),
|
|
62
|
+
allowedDomains: z
|
|
63
|
+
.array(z.string())
|
|
64
|
+
.optional()
|
|
65
|
+
.describe('Required for specified_domains / verified_domains. An EMPTY array is rejected - use clear:["allowed-domains"] to remove the list'),
|
|
66
|
+
validUntil: z.string().optional().describe('Expiry, ISO datetime'),
|
|
67
|
+
allowForward: z.boolean().optional(),
|
|
68
|
+
clear: z
|
|
69
|
+
.array(z.enum(CLEARABLE_UPDATE_FIELDS))
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('Fields to REMOVE the value of, e.g. ["valid-until"] to make the link never expire. Conflicts with passing the same field a value. Clearing allowed-emails/allowed-domains is REJECTED while a strategy that needs that list is in force - either the one already stored, or one passed as authStrategy in this same call - because an empty allow-list locks every recipient out. Relax the gate first (e.g. authStrategy "any_email") in the same call or a prior one'),
|
|
72
|
+
instanceId: z.string().optional(),
|
|
73
|
+
},
|
|
74
|
+
run: runDsrUpdate,
|
|
75
|
+
readOnly: false,
|
|
76
|
+
write: true,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'pitcher_dsr_delete',
|
|
80
|
+
title: 'Revoke a DSR share',
|
|
81
|
+
description: 'Permanently revoke a DSR share link so the URL stops working. The canvas itself is kept. Irreversible - to relax or tighten access instead, use pitcher_dsr_update.',
|
|
82
|
+
inputShape: {
|
|
83
|
+
id: z.string().describe('Shared-link ID'),
|
|
84
|
+
instanceId: z.string().optional(),
|
|
85
|
+
},
|
|
86
|
+
run: runDsrDelete,
|
|
87
|
+
readOnly: false,
|
|
88
|
+
write: true,
|
|
89
|
+
// irreversible on every environment, so destructiveHint must not depend on env.prod
|
|
90
|
+
alwaysDestructive: true,
|
|
91
|
+
},
|
|
47
92
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pitcher/cli-scripts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.1",
|
|
4
4
|
"description": "The Pitcher CLI for various tasks in its ecosystem.",
|
|
5
5
|
"author": "Pitcher AG",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@types/node": "^24.13.2",
|
|
33
33
|
"esbuild": "^0.28.1",
|
|
34
34
|
"typescript": "^6.0.3",
|
|
35
|
-
"vitest": "^4.1.
|
|
35
|
+
"vitest": "^4.1.11"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@modelcontextprotocol/sdk": "^1",
|