@pitcher/cli-scripts 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
  }
@@ -11,6 +11,7 @@ export declare function runFilesList(opts: CommonOpts & {
11
11
  instanceId?: string;
12
12
  tags?: string;
13
13
  idIn?: string;
14
+ type?: string;
14
15
  page?: number;
15
16
  pageSize?: number;
16
17
  }, deps?: FilesDeps): Promise<Envelope<{
@@ -5,7 +5,7 @@ import { createCliError } from '../core/errors.js';
5
5
  import { emitEnvelope } from '../core/output.js';
6
6
  import { uploadFile } from '../core/upload.js';
7
7
  import { listPageParams, listPaginationEcho } from '../core/pagination.js';
8
- import { parseTags, parseJsonObject, validateAccessType, deepMerge, assertIntFlags } from '../schema/content.js';
8
+ import { parseTags, parseJsonObject, validateAccessType, validateFileType, deepMerge, assertIntFlags } from '../schema/content.js';
9
9
  import { withAuthFlags } from './withAuth.js';
10
10
  export async function runFilesList(
11
11
  // NOTE: no folderId — the Django files list has no folder filter (listing a
@@ -15,12 +15,25 @@ opts, deps = {}) {
15
15
  if (!opts.instanceId)
16
16
  throw createCliError('VALIDATION', '--instance-id is required for files list');
17
17
  assertIntFlags(opts);
18
+ const type = validateFileType(opts.type);
18
19
  const client = clientFor(opts, deps);
19
20
  const params = { instance_id: opts.instanceId, ...listPageParams(opts) };
20
21
  if (opts.idIn)
21
22
  params.id__in = opts.idIn;
22
23
  if (opts.tags)
23
24
  params.tags__in = opts.tags;
25
+ // Installable apps are file records too (type=app). Exclude them by default so a "list files"
26
+ // request returns real content, not the instance's installed apps. `--type <content|asset|app>`
27
+ // isolates one type and always wins; `--type all` restores the unfiltered listing. An explicit
28
+ // --id-in lookup bypasses the default exclusion — fetching a file by id is a targeted lookup, not
29
+ // a browse, so it must return that record whatever its type (an explicit --type still applies).
30
+ // Uses type__in to match the tags__in/id__in CSV convention above (a single value is a valid filter).
31
+ if (type && type !== 'all') {
32
+ params.type__in = type;
33
+ }
34
+ else if (!type && !opts.idIn) {
35
+ params.type__in = 'content,asset';
36
+ }
24
37
  const res = await client.get('/api/v1/files/', { params });
25
38
  return ok({ results: res.data.results, pagination: listPaginationEcho(opts, res.data) });
26
39
  }
@@ -122,6 +135,7 @@ export function createFilesCommand() {
122
135
  .option('--instance-id <id>', 'Instance ID (required)')
123
136
  .option('--id-in <ids>', 'Comma-separated file IDs')
124
137
  .option('--tags <tags>', 'Comma-separated tags (filters via tags__in)')
138
+ .option('--type <type>', 'File type: content, asset, app, or all (default excludes installable apps)')
125
139
  .option('--page <n>', 'Page number', (v) => parseInt(v, 10))
126
140
  .option('--page-size <n>', 'Page size (max 500)', (v) => parseInt(v, 10))
127
141
  .action(async (options, command) => {
@@ -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
  ];
@@ -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
  },
@@ -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
  ];
@@ -1,14 +1,21 @@
1
1
  import { z } from 'zod';
2
2
  import { runFilesList, runFilesGet, runFilesCreate, runFilesUpdate } from '../../commands/files.js';
3
+ import { FILE_TYPES } from '../../schema/content.js';
3
4
  export const filesTools = [
4
5
  {
5
6
  name: 'pitcher_files_list',
6
7
  title: 'List files',
7
- description: 'List content files in an instance. Filter by tags or specific IDs.',
8
+ description: 'List content files in an instance. Installed apps are stored as file records too, but are ' +
9
+ 'EXCLUDED by default (content + assets only) so a "list files" request returns real content. ' +
10
+ 'Pass type="app" for installed apps only, or type="all" to include everything. Filter by tags or specific IDs.',
8
11
  inputShape: {
9
12
  instanceId: z.string().describe('Instance ID (required)'),
10
13
  idIn: z.string().optional().describe('Comma-separated file IDs'),
11
14
  tags: z.string().optional().describe('Comma-separated tags'),
15
+ type: z
16
+ .enum(FILE_TYPES)
17
+ .optional()
18
+ .describe('File type filter. Omit to list content + assets (installed apps excluded); "app" = apps only; "all" = everything including apps.'),
12
19
  page: z.number().int().optional(),
13
20
  pageSize: z.number().int().optional(),
14
21
  },
@@ -1,8 +1,10 @@
1
+ export declare const FILE_TYPES: readonly ["content", "asset", "app", "all"];
1
2
  export declare function isPlainObject(v: unknown): v is Record<string, unknown>;
2
3
  export declare function parseJsonObject(raw: string, flagName: string): Record<string, unknown>;
3
4
  export declare function parseJsonArray(raw: string, flagName: string): unknown[];
4
5
  export declare function parseTags(raw: string): string[];
5
6
  export declare function validateAccessType(v: string | undefined): string | undefined;
7
+ export declare function validateFileType(v: string | undefined): string | undefined;
6
8
  export declare function assertIntFlags(opts: {
7
9
  page?: number;
8
10
  pageSize?: number;
@@ -1,5 +1,10 @@
1
1
  import { createCliError } from '../core/errors.js';
2
2
  const ACCESS_TYPES = ['public', 'restricted', 'personal'];
3
+ // File record types on Django's `pitcher_file.type` column, plus the `all` escape hatch.
4
+ // Installable apps are stored as file records (type=app), so `p files list` excludes them by default;
5
+ // `all` opts back into the unfiltered listing. See validateFileType / runFilesList.
6
+ // Exported so the MCP `pitcher_files_list` tool sources its zod enum from the same list (no drift).
7
+ export const FILE_TYPES = ['content', 'asset', 'app', 'all'];
3
8
  export function isPlainObject(v) {
4
9
  return typeof v === 'object' && v !== null && !Array.isArray(v);
5
10
  }
@@ -37,6 +42,14 @@ export function validateAccessType(v) {
37
42
  }
38
43
  return v;
39
44
  }
45
+ export function validateFileType(v) {
46
+ if (v === undefined)
47
+ return undefined;
48
+ if (!FILE_TYPES.includes(v)) {
49
+ throw createCliError('VALIDATION', `--type must be one of: ${FILE_TYPES.join(', ')}`);
50
+ }
51
+ return v;
52
+ }
40
53
  // Guards against NaN reaching the request (commander's parseInt yields NaN on
41
54
  // non-numeric input, e.g. `--page abc`).
42
55
  export function assertIntFlags(opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitcher/cli-scripts",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
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",