@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 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 121 tools, one per CLI `run*` function: every command above (including `dsr create` + `dsr 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.
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 (118)** — 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`.
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. Three subcommands: `create` (build + share, with optional landing-page binding), `bind-landing` (bind/unbind a landing template on an existing share), and `scaffold` (print a starter `{source,security,landing}` spec for `--config-file`).
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
- Creating needs a share-capable key; **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`.
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 default `type=all` returns a shape with no
87
- * `.items`, which would otherwise silently shape to an empty row set. The
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 default `type=all` returns a shape with no
150
- * `.items`, which would otherwise silently shape to an empty row set. The
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;
@@ -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/html) and every attribute the
181
- * serializer accepts. NOTE: `options` (select/multi-select) MUST be
182
- * { label, value } objects — bare strings are rejected 400. `multi-select`
183
- * cannot be `is_sortable`. A select/multi-select `default` is an option `value`.
184
- * Section-level `are_fields_dependent` and field-level `help_text` are optional.
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
  ];
@@ -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 app config (api_base_url) via the platform client. Cached per (org, instanceId).
21
- * The org key can't be region-inferred, so app config (region-correct) is the default.
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. */
@@ -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
- const LD_APP_CONFIG_PATH = '/core/api/protected/apps/ld-app/config';
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 app config (api_base_url) via the platform client. Cached per (org, instanceId).
42
- * The org key can't be region-inferred, so app config (region-correct) is the default.
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
- const cacheKey = `${opts.org ?? 'dev'}:${opts.instanceId}`;
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 res = await platform.get(LD_APP_CONFIG_PATH, { params: { instance_id: opts.instanceId } });
57
- const apiBaseUrl = res.data?.api_base_url;
58
- if (typeof apiBaseUrl !== 'string' || !apiBaseUrl) {
59
- throw createCliError('NOT_FOUND', 'ld-app is not configured in this instance (no api_base_url). Set --ld-url or NG_LD_API_URL.');
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
  }
@@ -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 121.
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. Never hard-fails once a link exists: a failed landing bind returns the link with a warning. Additive.',
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.16.0",
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.9"
35
+ "vitest": "^4.1.11"
36
36
  },
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "^1",