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