@pitcher/cli-scripts 2.17.1 → 2.18.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
@@ -522,10 +522,13 @@ p dsr create --instance-id 01... --sharebox-file-ids 01A,01B --account-id 01...
522
522
  --auth-strategy specified_domains --allowed-domains acme.com --valid-until 2026-09-01T00:00:00Z \
523
523
  --landing-template-id 01... --landing-context '{"rep":"Sam"}'
524
524
  # bind/unbind a landing template on an existing share
525
- p dsr bind-landing --id 01... --landing-template-id 01...
526
- p dsr bind-landing --id 01... --landing-template-id none
525
+ p dsr bind-landing --id 01... --instance-id 01... --landing-template-id 01...
526
+ p dsr bind-landing --id 01... --instance-id 01... --landing-template-id none
527
527
  # refuse to reuse an existing share instead of silently returning it
528
528
  p dsr create --canvas-id 01... --if-exists error
529
+ # send the download preference explicitly — either direction
530
+ p dsr create --canvas-id 01... --allow-download
531
+ p dsr create --canvas-id 01... --no-allow-download
529
532
  # change an existing share — only the flags you pass are sent
530
533
  p dsr update --id 01... --valid-until 2026-12-01T00:00:00Z
531
534
  p dsr update --id 01... --auth-strategy specified_domains --allowed-domains acme.com
@@ -538,8 +541,29 @@ p dsr delete --id 01...
538
541
  p dsr scaffold --out share.json
539
542
  ```
540
543
 
544
+ ### The `--config-file` spec
545
+
546
+ `p dsr scaffold` prints the starter. The spec has three blocks — `source`, `security`, `landing` — and the CLI sends `source` and `security` **spread verbatim into the create POST body**, so their keys are the wire (snake_case) names, not flag names. `landing` is not part of that body: it becomes a follow-up PUT to `/{id}/landing-page` after the share exists.
547
+
548
+ The `source` block picks what is shared:
549
+
550
+ | Key | Type | Meaning |
551
+ |-----|------|---------|
552
+ | `canvas_id` | ULID string | Share an **existing** canvas. Exactly one of `canvas_id` / `sharebox_items`. |
553
+ | `sharebox_items` | `[{ file_id, page_index? }]` | Build a **new** ShareBox canvas from these files and share that. `file_id` is a ULID; an integer `page_index` shares that single page of the file instead of the whole file (`--sharebox-file-ids` can only send whole files, so a per-page share needs the spec). Exactly one of `canvas_id` / `sharebox_items`. |
554
+ | `canvas_name` | string | Name of the ShareBox-built canvas. Server default is `ShareBox <timestamp>`. |
555
+ | `account_id` | ULID string | Account attached to the ShareBox-built canvas. **Required** when the instance is configured to require an account on canvas creation. |
556
+ | `template_id` | ULID string | Canvas template the ShareBox-built canvas is built from. Omitted, the server looks for the instance's `share`-tagged template. |
557
+ | `instance_id` | ULID string | The instance to create in. `--instance-id` overrides it when both are given, and it is also what the landing-page PUT is scoped to. |
558
+
559
+ `canvas_name`, `account_id`, `template_id` and `security.tags` name or shape the canvas the server **mints**, so they only mean anything for a `sharebox_items` source — with `canvas_id` the canvas already exists and there is nothing to name. `tags` is the one the CLI enforces: a non-empty `tags` alongside `canvas_id` is rejected rather than sent to be dropped. The scaffold's empty `tags: []` is accepted, since it asks for nothing.
560
+
561
+ `security` holds the access settings (`auth_strategy`, `allowed_emails`, `allowed_domains`, `valid_until`, `allow_forward`, `allow_download`, `tags`) and every key in it is spread into the POST body **exactly as written**. The CLI checks three of them and no more: `auth_strategy` against the allowed values, the `allowed_emails`/`allowed_domains` that strategy requires, and `tags` (above). The rest — `allow_download` included — it forwards untouched and makes no claim about, so read the created share back if you need to confirm a value took. Sending the block as written also matters against an already-shared canvas — see the `--if-exists` note above. `landing` holds `landing_page_template_id` (discover ids with `p dsr-landing list`) and `landing_page_context`; left at the scaffold's `null` / `{}` no landing page is bound at all. Binding one — from the spec or from `--landing-template-id` — needs an instance: `--instance-id` or `source.instance_id`. Without it the CLI skips the bind and returns the share with a warning naming the flag.
562
+
541
563
  `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
564
 
565
+ `--allow-download` has a `--no-allow-download` twin, so **both** directions are sendable (PIT-7521). Without it commander could only ever produce `true` or nothing: passing the flag said “allow”, and there was no way to say “do not allow” — the OFF state could not be expressed at all, only left out. Declaration order is load-bearing: the positive flag of each pair is declared first, because commander takes a boolean's default from whichever it sees first. ⚠️ The value records the sharer's **intent** and drives the viewer's UI; it is not a server-side download block — the file URL is already in the payload the browser receives.
566
+
543
567
  **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
568
 
545
569
  ```bash
@@ -551,7 +575,7 @@ p dsr update --id 01... --allowed-emails new@acme.com
551
575
 
552
576
  `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
577
 
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`.
578
+ 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** needs `--instance-id`, and an admin or editor key to bind any share in the org — a member key can bind only the shares that member created (PIT-7525). On `create`, a denied bind returns the link with a warning. Discover landing template IDs via `p dsr-landing list`.
555
579
 
556
580
  ## To run the `ld` command:
557
581
 
@@ -19,9 +19,20 @@ function resolveTemplateId(raw) {
19
19
  return null;
20
20
  return raw;
21
21
  }
22
+ // The landing-page PUT is guarded by next-core's `withAtLeastInstanceAdmin`, which resolves the
23
+ // instance from the URL path, then the request body, then the `instance_id` query param. This route
24
+ // has no instance in its path and the body the CLI sends carries only the landing fields, so the
25
+ // query param is the ONLY source there is: without it the server answers 400 "Instance ID is
26
+ // required" before it ever looks at the share. Catching it here names the missing flag instead of
27
+ // handing back that opaque server error.
28
+ const LANDING_INSTANCE_REQUIRED = '--instance-id is required to bind a landing page: the landing-page endpoint resolves the instance from that query param and refuses the call without it';
29
+ // `instanceId` is REQUIRED, not optional-with-a-ternary: both call sites now refuse an empty one
30
+ // before they get here (`runDsrBindLanding` throws, `runDsrCreate` returns the share with a warning),
31
+ // so a params-less PUT is unreachable — and it was never anything but the guaranteed 400 above.
32
+ // Typing it away is what stops a future caller from quietly reintroducing that call.
22
33
  async function bindLandingRequest(client, id, body, instanceId) {
23
34
  const url = `${ENDPOINT}/${encodeURIComponent(id)}/landing-page`;
24
- const res = await client.put(url, body, instanceId ? { params: { instance_id: instanceId } } : undefined);
35
+ const res = await client.put(url, body, { params: { instance_id: instanceId } });
25
36
  return res.data;
26
37
  }
27
38
  export async function runDsrBindLanding(opts, deps = {}) {
@@ -31,6 +42,8 @@ export async function runDsrBindLanding(opts, deps = {}) {
31
42
  const templateId = resolveTemplateId(opts.landingTemplateId);
32
43
  if (templateId === undefined)
33
44
  throw createCliError('VALIDATION', '--landing-template-id is required (use "none" to unbind)');
45
+ if (!opts.instanceId)
46
+ throw createCliError('VALIDATION', LANDING_INSTANCE_REQUIRED);
34
47
  const body = { landing_page_template_id: templateId };
35
48
  if (opts.landingContext !== undefined)
36
49
  body.landing_page_context = parseJsonObject(opts.landingContext, 'landing-context');
@@ -108,6 +121,25 @@ function assertCreateBody(body, instanceId) {
108
121
  throw createCliError('VALIDATION', 'pass exactly one source: --canvas-id or --sharebox-file-ids');
109
122
  if (hasSharebox && !instanceId && body.instance_id === undefined)
110
123
  throw createCliError('VALIDATION', '--instance-id is required for --sharebox-file-ids');
124
+ const tags = body.tags;
125
+ // SHAPE first: a hand-written spec can put anything under `security.tags`. The server would refuse a
126
+ // malformed value too (its ZodError catch answers `400 Invalid request data`), so this is not closing
127
+ // a hole — it fails before any request, with a message that names the field. This guard already owns
128
+ // the key, so it owns the shape too — and shape means the ELEMENTS as well: the server field is
129
+ // `z.array(z.string())`, so `tags: [1]` clears `Array.isArray` and still has to be refused. An EMPTY
130
+ // array passes both checks, by design.
131
+ if (tags !== undefined && (!Array.isArray(tags) || tags.some((t) => typeof t !== 'string')))
132
+ throw createCliError('VALIDATION', 'tags must be an array of strings');
133
+ // SOURCE second: `tags` is read by exactly one branch of the server route — the ShareBox branch,
134
+ // where it names the canvas the server MINTS from sharebox_items. With an existing canvas there is
135
+ // nothing to name: the POST schema still accepts the key and then drops it on the floor, so the
136
+ // tags vanish with no error at all. Refuse the combination instead of shipping a silent no-op.
137
+ // An EMPTY array is NOT a request to tag anything: `dsr scaffold` emits `tags: []` in its starter
138
+ // security block, so rejecting that would break every untouched `--config-file` create.
139
+ if (hasCanvas && Array.isArray(tags) && tags.length > 0)
140
+ throw createCliError('VALIDATION', 'tags apply only to a ShareBox-built canvas — they name the canvas the server creates from ' +
141
+ '--sharebox-file-ids, and with --canvas-id the server ignores them. Drop the tags, or share the ' +
142
+ 'files with --sharebox-file-ids.');
111
143
  const strat = body.auth_strategy;
112
144
  if (strat !== undefined) {
113
145
  if (!AUTH_STRATEGIES.includes(strat))
@@ -223,6 +255,16 @@ export async function runDsrCreate(opts, deps = {}) {
223
255
  const shareId = share?.id;
224
256
  if (!shareId)
225
257
  return ok({ ...share, landing: null }, ['landing not attached: no share id in create response']);
258
+ // Same missing-instance failure as `dsr bind-landing`, reached from the other call site. It stays
259
+ // a WARNING rather than a hard failure because the share itself was created successfully and is
260
+ // the caller's primary output — dropping it would strand a live link with nothing referencing it.
261
+ // What changes is that the PUT is not attempted at all (it cannot succeed) and the warning names
262
+ // the flag plus the exact follow-up command, instead of echoing an opaque 400 from the server.
263
+ if (!effectiveInstanceId)
264
+ return ok({ ...share, landing: null }, [
265
+ `landing not attached: ${LANDING_INSTANCE_REQUIRED}. The share itself was created — bind the ` +
266
+ `landing page with \`p dsr bind-landing --id ${shareId} --landing-template-id <template-id> --instance-id <instance-id>\``,
267
+ ]);
226
268
  try {
227
269
  const landing = await bindLandingRequest(client, shareId, landingBody, effectiveInstanceId);
228
270
  return ok({ ...share, landing });
@@ -418,7 +460,7 @@ export function createDsrCommand() {
418
460
  });
419
461
  withAuthFlags(cmd.command('create'))
420
462
  .description('create + share a DSR (canvas share link) and return the link (id, url, short_url, canvas)')
421
- .option('--instance-id <id>', 'Instance ID (required for --sharebox-file-ids)')
463
+ .option('--instance-id <id>', 'Instance ID (required for --sharebox-file-ids, and to bind a landing page)')
422
464
  .option('--canvas-id <id>', 'Existing canvas to share (one source required)')
423
465
  .option('--sharebox-file-ids <csv>', 'Build a canvas from these file IDs (one source required)', parseTags)
424
466
  .option('--canvas-name <name>', 'Name for the ShareBox-built canvas')
@@ -431,7 +473,8 @@ export function createDsrCommand() {
431
473
  .option('--allow-forward', 'Allow recipients to forward')
432
474
  .option('--no-allow-forward', 'Disallow forwarding')
433
475
  .option('--allow-download', 'Allow download')
434
- .option('--tags <csv>', 'Tags', parseTags)
476
+ .option('--no-allow-download', 'Disallow download')
477
+ .option('--tags <csv>', 'Tags for the ShareBox-built canvas (rejected with --canvas-id, which ignores them)', parseTags)
435
478
  .option('--landing-template-id <id>', 'Landing template to bind (optional)')
436
479
  .option('--landing-context <json>', 'Landing personalization merge fields (JSON object)')
437
480
  .option('--config-file <path>', 'Full {source,security,landing} spec JSON (mutually exclusive with the flags above)')
@@ -449,15 +492,16 @@ export function createDsrCommand() {
449
492
  .option('--id <id>', 'Shared link ID (required)')
450
493
  .option('--landing-template-id <id>', "Landing template ID, or 'none' to unbind (required)")
451
494
  .option('--landing-context <json>', 'Personalization merge fields (JSON object)')
452
- .option('--instance-id <id>', 'Instance ID (optional)')
495
+ .option('--instance-id <id>', 'Instance ID (required — the endpoint resolves the instance from it)')
453
496
  .action(async (options, command) => {
454
497
  const globals = command.optsWithGlobals();
455
498
  emitEnvelope(await runDsrBindLanding({ ...options, quiet: globals.quiet }), globals);
456
499
  });
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.
500
+ // Every --x is declared BEFORE its --no-x on purpose: commander takes a boolean's default
501
+ // from whichever of the pair it sees first, so the reverse order would make the option
502
+ // default to `true` and every call would ship it. For allow_forward that clobbers a rep's
503
+ // opt-out; for allow_download it would turn an unpassed flag into an active grant.
504
+ // `create` above relies on the same ordering for BOTH pairs.
461
505
  withAuthFlags(cmd.command('update'))
462
506
  .description('update an existing share: only the flags you pass are sent; --clear removes a value')
463
507
  .option('--id <id>', 'Shared link ID (required)')
@@ -24,7 +24,8 @@ export declare function clickhouseClientFor(opts: CommonOpts, deps?: {
24
24
  /**
25
25
  * Build a human-readable message from a server error body. Handles DRF shapes:
26
26
  * a `detail`/`error` string, `non_field_errors`, and field-level error arrays
27
- * (e.g. `{ version: ["already exists"] }`). Falls back to the raw body so the
27
+ * (e.g. `{ version: ["already exists"] }`), plus next-core's
28
+ * `{ error, details }` validation shape. Falls back to the raw body so the
28
29
  * full server response is visible in logs instead of a bare status line.
29
30
  */
30
31
  export declare function extractApiMessage(data: unknown, fallback: string): string;
@@ -35,10 +35,158 @@ export function clickhouseClientFor(opts, deps = {}) {
35
35
  },
36
36
  });
37
37
  }
38
+ /** Max number of `details` entries folded into a message before it is summarised. */
39
+ const MAX_DETAIL_PARTS = 3;
40
+ /** Max characters of joined `details` text, before the `(+N more)` marker is appended. */
41
+ const MAX_DETAIL_CHARS = 200;
42
+ /** How deep to walk a nested `details` value before falling back to JSON. */
43
+ const MAX_DETAIL_DEPTH = 3;
44
+ /**
45
+ * DRF envelope keys: the two places a serializer puts a message that names no field.
46
+ * next-core's `upstreamErrorMessage()` (`app/api/utils/upstream-error.ts`) reads `detail`
47
+ * first and sorts `non_field_errors` ahead of everything else, so a summary derived from a
48
+ * relayed upstream body is usually one of these two verbatim. Every other key IS a field
49
+ * name, and naming the field is the whole point of folding `details` in — so only a line
50
+ * from one of these keys may be dropped for merely repeating the summary.
51
+ */
52
+ const ENVELOPE_KEYS = new Set(['detail', 'non_field_errors']);
53
+ /**
54
+ * `JSON.stringify` throws on a circular structure and on a BigInt. This runs inside the
55
+ * error formatter, so a throw here would lose the API error entirely and print a raw
56
+ * stack instead — every failure has to collapse to a string.
57
+ */
58
+ function safeDetailJson(val) {
59
+ try {
60
+ const text = JSON.stringify(val, (_key, entry) => (typeof entry === 'bigint' ? String(entry) : entry));
61
+ return text ?? '[unserializable]';
62
+ }
63
+ catch {
64
+ return '[unserializable]';
65
+ }
66
+ }
67
+ /**
68
+ * Whether an array entry already identifies itself in the output. A zod issue carries
69
+ * its own `path`, and an entry that renders as a single scalar (`{ title: ['Required'] }`)
70
+ * reads better bare; anything else gets an `[i]` so entries do not blur together.
71
+ */
72
+ function labelsItself(entry) {
73
+ if (typeof entry !== 'object' || entry === null)
74
+ return true;
75
+ return typeof entry.message === 'string';
76
+ }
77
+ /**
78
+ * Flatten one `details` value into human-readable, path-prefixed lines.
79
+ *
80
+ * next-core emits several shapes for the same field: zod issues
81
+ * (`[{ path, message }]`, from `_4xx('Invalid request data', 400, { details: error.errors })`),
82
+ * a `zod.flatten()` object, a relayed upstream body (`{ details: error.response.data }`),
83
+ * a plain object (`{ details: { id } }`) and a bare string. All of them collapse to
84
+ * `path: message` lines here.
85
+ *
86
+ * The walk is total: every recursive branch sits below the depth guard, and the guard's
87
+ * own fallback cannot throw.
88
+ *
89
+ * `envelope` marks the lines that descend from a top-level `ENVELOPE_KEYS` key; it is what
90
+ * lets `renderDetails` drop a relay echo without dropping a labelled field error.
91
+ */
92
+ function detailParts(val, prefix = '', depth = 0, envelope = false) {
93
+ if (val == null)
94
+ return [];
95
+ const part = (value) => value ? [{ text: prefix ? `${prefix}: ${value}` : value, value, envelope }] : [];
96
+ if (typeof val === 'string')
97
+ return part(val.trim());
98
+ if (typeof val === 'number' || typeof val === 'boolean')
99
+ return part(String(val));
100
+ if (typeof val !== 'object')
101
+ return [];
102
+ // zod issue shape: `{ path: ['canvas_id'], message: 'Invalid ULID format for canvas_id' }`.
103
+ // A leaf, so it is answered above the depth guard.
104
+ if (!Array.isArray(val)) {
105
+ const obj = val;
106
+ if (typeof obj.message === 'string' && obj.message.trim()) {
107
+ const path = Array.isArray(obj.path) ? obj.path.join('.') : '';
108
+ const scope = [prefix, path].filter(Boolean).join('.');
109
+ const value = obj.message.trim();
110
+ return [{ text: scope ? `${scope}: ${value}` : value, value, envelope }];
111
+ }
112
+ }
113
+ if (depth >= MAX_DETAIL_DEPTH)
114
+ return part(safeDetailJson(val));
115
+ if (Array.isArray(val)) {
116
+ return val.flatMap((entry, i) => detailParts(entry, labelsItself(entry) ? prefix : `${prefix}[${i}]`, depth + 1, envelope));
117
+ }
118
+ const obj = val;
119
+ return Object.entries(obj).flatMap(([key, entry]) =>
120
+ // Only a key at the root of `details` can be the envelope — `{ canvas: { detail: … } }`
121
+ // is a field named `canvas`, not a DRF envelope.
122
+ detailParts(entry, prefix ? `${prefix}.${key}` : key, depth + 1, envelope || (!prefix && ENVELOPE_KEYS.has(key))));
123
+ }
124
+ /** Cut `text` to `max` code points — never splitting a surrogate pair — and mark the cut. */
125
+ function truncateDetails(text, max) {
126
+ const points = [...text];
127
+ if (points.length <= max)
128
+ return text;
129
+ return `${points.slice(0, max - 1).join('').trimEnd()}\u2026`;
130
+ }
131
+ /**
132
+ * Flatten a `details` value into one capped, de-duplicated line.
133
+ *
134
+ * When `summary` is given, two kinds of echo are suppressed, and only these two:
135
+ * - a whole rendered line equal to the summary (`_400Validation` in next-core answers a
136
+ * failed parse with `"<path>: <message>"`, which is exactly how the same issue renders
137
+ * here), and
138
+ * - a line from a DRF envelope key (`ENVELOPE_KEYS`) whose message equals the summary —
139
+ * the relay path, where `next-core .../shared-links/route.ts` builds its summary with
140
+ * `upstreamErrorMessage(body)` and then relays that same body as `details`, so the most
141
+ * common `p dsr` 400 would otherwise print `Not found. | detail: Not found.`
142
+ *
143
+ * A *field* entry is never dropped for matching the summary, however: in
144
+ * `{ error: 'This field is required.', details: { canvas: [...], expires_at: [...] } }` the
145
+ * field path is the only thing telling the user which field, and dropping `canvas` there
146
+ * left the message actively misleading (PR #5570 review).
147
+ *
148
+ * Output is capped so a large validation payload cannot become a wall of text. The
149
+ * `(+N more)` marker is appended after the cap, so it is never cut into a wrong count.
150
+ */
151
+ function renderDetails(details, summary) {
152
+ const seen = new Set();
153
+ const parts = [];
154
+ let dropped = 0;
155
+ for (const { text, value, envelope } of detailParts(details)) {
156
+ if (summary !== undefined && (text === summary || (envelope && value === summary)))
157
+ continue;
158
+ if (seen.has(text))
159
+ continue;
160
+ seen.add(text);
161
+ if (parts.length < MAX_DETAIL_PARTS)
162
+ parts.push(text);
163
+ else
164
+ dropped += 1;
165
+ }
166
+ if (!parts.length)
167
+ return '';
168
+ let text = truncateDetails(parts.join('; '), MAX_DETAIL_CHARS);
169
+ if (dropped > 0)
170
+ text += ` (+${dropped} more)`;
171
+ return text;
172
+ }
173
+ /**
174
+ * Fold an error body's `details` into the top-level summary so the user sees both
175
+ * halves on one line — next-core answers a failed schema parse with a generic
176
+ * `"Invalid request data"` and puts the only actionable text (the field and why it
177
+ * was rejected) in `details`, which used to be dropped entirely (PIT-7521).
178
+ */
179
+ function withDetails(summary, details) {
180
+ if (details === undefined)
181
+ return summary;
182
+ const text = renderDetails(details, summary);
183
+ return text ? `${summary} | ${text}` : summary;
184
+ }
38
185
  /**
39
186
  * Build a human-readable message from a server error body. Handles DRF shapes:
40
187
  * a `detail`/`error` string, `non_field_errors`, and field-level error arrays
41
- * (e.g. `{ version: ["already exists"] }`). Falls back to the raw body so the
188
+ * (e.g. `{ version: ["already exists"] }`), plus next-core's
189
+ * `{ error, details }` validation shape. Falls back to the raw body so the
42
190
  * full server response is visible in logs instead of a bare status line.
43
191
  */
44
192
  export function extractApiMessage(data, fallback) {
@@ -50,11 +198,22 @@ export function extractApiMessage(data, fallback) {
50
198
  return String(data);
51
199
  const obj = data;
52
200
  if (typeof obj.detail === 'string' && obj.detail)
53
- return obj.detail;
201
+ return withDetails(obj.detail, obj.details);
54
202
  if (typeof obj.error === 'string' && obj.error)
55
- return obj.error;
203
+ return withDetails(obj.error, obj.details);
56
204
  const parts = [];
57
205
  for (const [key, val] of Object.entries(obj)) {
206
+ // A body carrying `details` but no string `detail`/`error` summary lands here. next-core
207
+ // cannot produce that shape (`_4xx` always emits `{ error: message, ...data }`), but Django
208
+ // or another upstream can — and the generic rendering below would `join('; ')` structured
209
+ // entries into `details: [object Object]`, so walk it the same way `withDetails` does.
210
+ if (key === 'details') {
211
+ const rendered = renderDetails(val);
212
+ if (rendered) {
213
+ parts.push(rendered);
214
+ continue;
215
+ }
216
+ }
58
217
  const text = Array.isArray(val) ? val.join('; ') : typeof val === 'string' ? val : JSON.stringify(val);
59
218
  parts.push(key === 'non_field_errors' ? text : `${key}: ${text}`);
60
219
  }
@@ -20,6 +20,8 @@ export const dsrPrompts = [
20
20
  '',
21
21
  '## DSR sharing guidance',
22
22
  '- **Source is exactly one of** a canvas (`canvasId`) OR files (`shareboxFileIds`).',
23
+ '- **`tags` are ShareBox-only.** They name the canvas the server builds from `shareboxFileIds`, so non-empty ' +
24
+ '`tags` together with `canvasId` is rejected rather than silently ignored.',
23
25
  `- **Pick the gating (\`authStrategy\`) by audience.** For ${audience}: internal/quick demo → \`none\` ` +
24
26
  '(open link); a known individual → `specified_emails` (+ `allowedEmails`); a company → ' +
25
27
  '`specified_domains` (+ `allowedDomains`); the server default is `any_email`. Add `validUntil` ' +
@@ -27,6 +29,8 @@ export const dsrPrompts = [
27
29
  '- **Branding the pre-auth page is a SEPARATE step:** this recipe only **binds** an existing ' +
28
30
  'landing template (`landingTemplateId`, discover via `pitcher_dsr_landing_list`; pass ' +
29
31
  '`landingContext` merge fields **as a JSON object serialized to a string**, not as an object). ' +
32
+ '**A landing bind needs `instanceId`** — the endpoint resolves the instance from it, so without it the bind ' +
33
+ 'is skipped and the share comes back with a warning. ' +
30
34
  'If no suitable template exists and the user wants a ' +
31
35
  'branded landing, author one FIRST with the **`seed_dsr_landing`** recipe, then bind its id here. ' +
32
36
  'Do NOT hand-write landing HTML in this recipe.',
@@ -7,9 +7,9 @@ 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. 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.',
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 - and binding a landing page needs instanceId, without which the bind is skipped with that warning. tags are a ShareBox-only field (they name the canvas the server builds from shareboxFileIds), so non-empty tags together with canvasId are REJECTED rather than silently dropped by the server.',
11
11
  inputShape: {
12
- instanceId: z.string().optional().describe('Instance ID (required for the shareboxFileIds source)'),
12
+ instanceId: z.string().optional().describe('Instance ID (required for the shareboxFileIds source, and to bind a landing page)'),
13
13
  canvasId: z.string().optional().describe('Canvas to share (exactly one of canvasId / shareboxFileIds)'),
14
14
  shareboxFileIds: z.array(z.string()).optional().describe('File IDs to build a share from (exactly one of canvasId / shareboxFileIds)'),
15
15
  canvasName: z.string().optional().describe('Name for the ShareBox-built canvas'),
@@ -20,9 +20,18 @@ export const dsrTools = [
20
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
- allowDownload: z.boolean().optional(),
24
- tags: z.array(z.string()).optional(),
25
- landingTemplateId: z.string().optional().describe('Bind a landing-page template (discover via pitcher_dsr_landing_list)'),
23
+ allowDownload: z
24
+ .boolean()
25
+ .optional()
26
+ .describe('Let recipients download the shared files. Create-time only (pitcher_dsr_update does not accept it). Sent to the server exactly as given for either source - unlike tags, the CLI applies no source-specific guard to it, so read the created share back if you need to confirm the value took'),
27
+ tags: z
28
+ .array(z.string())
29
+ .optional()
30
+ .describe('Tags for the ShareBox-built canvas. ShareBox source ONLY - a non-empty array together with canvasId is rejected, because the server reads tags only when it builds the canvas itself and would otherwise ignore them. An empty array is accepted (it asks for nothing)'),
31
+ landingTemplateId: z
32
+ .string()
33
+ .optional()
34
+ .describe('Bind a landing-page template (discover via pitcher_dsr_landing_list). Needs instanceId: without it the bind is skipped and the share comes back with a warning'),
26
35
  landingContext: z.string().optional().describe('Landing-page merge fields, a JSON object as a string'),
27
36
  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
37
  ifExists: z
@@ -37,12 +46,14 @@ export const dsrTools = [
37
46
  {
38
47
  name: 'pitcher_dsr_bind_landing',
39
48
  title: 'Bind a DSR landing page',
40
- description: 'Attach, re-brand, or unbind a landing-page template on an existing DSR share. Use landingTemplateId "none" to unbind. Requires an editor-level key (hard-fails AUTH otherwise).',
49
+ description: 'Attach, re-brand, or unbind a landing-page template on an existing DSR share. Use landingTemplateId "none" to unbind. instanceId is REQUIRED: the endpoint resolves the instance from that query param and refuses the call without it. An admin or editor key can bind any share in the org; a member key only shares that member created (hard-fails AUTH otherwise).',
41
50
  inputShape: {
42
51
  id: z.string().describe('Shared-link ID'),
43
52
  landingTemplateId: z.string().describe('Landing template ID, or "none" to unbind'),
44
53
  landingContext: z.string().optional().describe('Landing-page merge fields, a JSON object as a string'),
45
- instanceId: z.string().optional(),
54
+ instanceId: z
55
+ .string()
56
+ .describe('Instance ID. The endpoint resolves the instance from this query param and rejects the call without it'),
46
57
  },
47
58
  run: runDsrBindLanding,
48
59
  readOnly: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitcher/cli-scripts",
3
- "version": "2.17.1",
3
+ "version": "2.18.0",
4
4
  "description": "The Pitcher CLI for various tasks in its ecosystem.",
5
5
  "author": "Pitcher AG",
6
6
  "license": "MIT",