@pitcher/cli-scripts 2.17.1 → 2.19.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 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.
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 one tool per CLI `run*` function: every command below (including `dsr create`/`get`/`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
@@ -26,7 +26,7 @@ Auth and org scope are resolved once at boot from environment variables. They ar
26
26
 
27
27
  - `NG_API_KEY` (required, secret): the API key the server authenticates as.
28
28
  - `NG_ORG` (default `dev`): organization subdomain, or set `NG_URL` directly for a full origin.
29
- - `NG_ENV` (`prod` or `test`): controls the destructive-hint guard on write tools (see below). Defaults to a guess based on `NG_ORG` when unset.
29
+ - `NG_ENV` (`prod` or `test`): controls the destructive-hint guard on write tools (see below). When unset, anything other than the `dev` org (or, if `NG_URL` is set, anything other than `https://dev.my.pitcher.com`) is treated as production.
30
30
 
31
31
  One config entry = one org, because the API key is org-specific. To work against multiple orgs, add multiple entries (e.g. `pitcher-dev`, `pitcher-prod`) with different env blocks; do not try to pass org per tool call.
32
32
 
@@ -43,7 +43,7 @@ One config entry = one org, because the API key is org-specific. To work against
43
43
  ### Tool annotations
44
44
 
45
45
  - Read commands are marked `readOnlyHint`. `dev-sync` counts as a write (it mutates remote dev-override state).
46
- - `publish-app` is always marked `destructiveHint` (it creates a live, org-visible release). Other write tools get `destructiveHint` only when the resolved environment is production (`NG_ENV=prod`, or `NG_ORG` other than `dev` with `NG_ENV` unset). This steers hosts toward requiring extra confirmation before mutating a production org.
46
+ - `publish-app` is always marked `destructiveHint` (it creates a live, org-visible release). Other write tools get `destructiveHint` only when the resolved environment is production (`NG_ENV=prod`, or — with `NG_ENV` unset — a target other than `dev` / `dev.my.pitcher.com`). This steers hosts toward requiring extra confirmation before mutating a production org.
47
47
 
48
48
  ### Desktop Extension (`.mcpb`)
49
49
 
@@ -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 (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`.
58
+ The bundle reuses the same server as `p mcp` but registers a **content-tool subset (121)** — 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
 
@@ -63,7 +63,7 @@ This section is a reference only. For app-development conventions (modules, sett
63
63
 
64
64
  ```bash
65
65
  # Basic usage
66
- p publish-app <folder> --url https://pitcher.my.pitcher.com --api-key myapikey
66
+ p publish-app <folder> --org pitcher --api-key myapikey
67
67
 
68
68
  # Publish to current folder and default org (dev)
69
69
  p publish-app --api-key myapikey
@@ -506,7 +506,7 @@ 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. 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`).
509
+ Create and share a Digital Sales Room (a canvas share link) and get a link back in one command. Six subcommands: `create` (build + share, with optional landing-page binding), `get` (read an existing share back), `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
510
 
511
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
512
 
@@ -519,13 +519,21 @@ p dsr create --instance-id 01... --canvas-id 01... --api-key myapikey
519
519
  p dsr create --instance-id 01... --canvas-id 01... --auth-strategy none
520
520
  # ShareBox + security + branded landing
521
521
  p dsr create --instance-id 01... --sharebox-file-ids 01A,01B --account-id 01... \
522
- --auth-strategy specified_domains --allowed-domains acme.com --valid-until 2026-09-01T00:00:00Z \
522
+ --auth-strategy specified_domains --allowed-domains acme.com --valid-until 2026-12-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
+ # merge fields from a FILE instead of inline JSON (the only spelling that works in PowerShell)
528
+ p dsr bind-landing --id 01... --instance-id 01... --landing-template-id 01... --landing-context-file ./ctx.json
529
+ p dsr create --instance-id 01... --canvas-id 01... --landing-template-id 01... --landing-context-file ./ctx.json
530
+ # read an existing share back
531
+ p dsr get --id 01...
527
532
  # refuse to reuse an existing share instead of silently returning it
528
533
  p dsr create --canvas-id 01... --if-exists error
534
+ # send the download preference explicitly — either direction
535
+ p dsr create --canvas-id 01... --allow-download
536
+ p dsr create --canvas-id 01... --no-allow-download
529
537
  # change an existing share — only the flags you pass are sent
530
538
  p dsr update --id 01... --valid-until 2026-12-01T00:00:00Z
531
539
  p dsr update --id 01... --auth-strategy specified_domains --allowed-domains acme.com
@@ -538,8 +546,33 @@ p dsr delete --id 01...
538
546
  p dsr scaffold --out share.json
539
547
  ```
540
548
 
549
+ ### The `--config-file` spec
550
+
551
+ `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.
552
+
553
+ The `source` block picks what is shared. The scaffold emits `canvas_id` (an example value) **and** an empty `sharebox_items: []`, so both source keys are visible in the printed JSON; an empty array asks for nothing, so the untouched scaffold shares `canvas_id`. To share files instead, fill `sharebox_items`, delete the `canvas_id` line, and add `instance_id` (or pass `--instance-id`). `p dsr create --help` lists the same field names.
554
+
555
+ ```json
556
+ "source": { "instance_id": "01...", "sharebox_items": [{ "file_id": "01..." }, { "file_id": "01...", "page_index": 2 }] }
557
+ ```
558
+
559
+ | Key | Type | Meaning |
560
+ |-----|------|---------|
561
+ | `canvas_id` | ULID string | Share an **existing** canvas. Exactly one of `canvas_id` / `sharebox_items`. |
562
+ | `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`. |
563
+ | `canvas_name` | string | Name of the ShareBox-built canvas. Server default is `ShareBox <timestamp>`. |
564
+ | `account_id` | ULID string | Account attached to the ShareBox-built canvas. **Required** when the instance is configured to require an account on canvas creation. |
565
+ | `template_id` | ULID string | Canvas template the ShareBox-built canvas is built from. Omitted, the server looks for the instance's `share`-tagged template. |
566
+ | `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. |
567
+
568
+ `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.
569
+
570
+ `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. The scaffold deliberately **omits** `allow_download`: it is stored on the canvas (every link on that canvas reads it), and the server writes it only when the key is present — omitted, an existing canvas keeps its current value and a new ShareBox canvas carries none, which the viewer treats as **downloads allowed**. Add `"allow_download": false` to the spec to turn downloads off. 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.
571
+
541
572
  `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
573
 
574
+ `--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.
575
+
543
576
  **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
577
 
545
578
  ```bash
@@ -549,9 +582,35 @@ p dsr update --id 01... --auth-strategy any_email --clear allowed-emails
549
582
  p dsr update --id 01... --allowed-emails new@acme.com
550
583
  ```
551
584
 
585
+ ### `--landing-context` from a file
586
+
587
+ `--landing-context` takes a JSON object as a single shell argument, which **PowerShell cannot express**: it strips the double quotes a JSON object cannot do without, so on Windows the flag has no usable spelling at all. `--landing-context-file <path>` is the same field read off disk, and it is available on **both** `dsr create` and `dsr bind-landing`:
588
+
589
+ ```bash
590
+ echo '{"rep":"Sam","account":"Acme"}' > ctx.json
591
+ p dsr bind-landing --id 01... --instance-id 01... --landing-template-id 01... --landing-context-file ./ctx.json
592
+ ```
593
+
594
+ Exactly one of the two may be given: passing **both** fails `VALIDATION` (exit 3) before any request, naming the pair. The file must hold a JSON **object** — valid JSON that is an array or a scalar is rejected with the same code, as is malformed JSON. `--landing-context-file` is a landing flag like the rest, so it is mutually exclusive with `--config-file` (whose `landing.landing_page_context` block is that spec's own way of saying the same thing).
595
+
596
+ ### `p dsr get` — read a share back
597
+
598
+ `p dsr get --id <id>` returns the share's current settings. The protected shared-links surface has **no GET**, so this wraps the public pre-auth config the sharing page itself loads (`GET /core/api/shared-links/{id}/config`) and returns that body **unchanged**. Nothing is reshaped, so the field names are the wire names.
599
+
600
+ That body is the share's **access config**, and nothing else: `auth_strategy`, `allowed_domains`, `allowed_emails`, `valid_until`, `allow_forward` (plus `allow_forward_raw`, the rep's own toggle before the admin `allow_dsr_forwarding` setting is applied), the landing-page binding as `landing_page_template_id` / `landing_page_template_snapshot` / `landing_page_context`, and `created_at` / `modified_at`. **There is no `canvas` or `file` field** — the endpoint selects neither, so hold on to the `canvas` that `create` returned if you need it.
601
+
602
+ ```bash
603
+ p dsr get --id 01...
604
+ # confirm what a create actually stored
605
+ p dsr create --instance-id 01... --canvas-id 01... --auth-strategy specified_domains --allowed-domains acme.com
606
+ p dsr get --id 01...
607
+ ```
608
+
609
+ Use it to confirm what `create` or `update` stored (`create` is a get-or-create, so what you passed is not always what the share ends up with) and to check a link before changing it. There is **one** id failure mode: any id that matches no share answers `404` → `NOT_FOUND` (exit 5). The endpoint does not validate the id's format — it matches `pitcher_sharedlink.id`, a plain varchar — so a malformed id is a miss like any other and returns `NOT_FOUND` too, not `VALIDATION`.
610
+
552
611
  `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
612
 
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`.
613
+ `get` reads a public, pre-auth endpoint, so it needs no special permission beyond a valid key. 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. Sending `--allow-download` / `--no-allow-download` (or `allow_download` in a spec) with an existing `--canvas-id` needs you to own that canvas or hold `canvas.update` on it — the setting is stored on the canvas — otherwise the create is refused as `AUTH` and nothing is written; a ShareBox source is always yours, so it never hits this. Discover landing template IDs via `p dsr-landing list`.
555
614
 
556
615
  ## To run the `ld` command:
557
616
 
package/dist/index.js CHANGED
File without changes
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import type { AxiosInstance } from 'axios';
3
+ import { type Credential } from '../core/credential.js';
3
4
  import { type Envelope } from '../core/envelope.js';
4
5
  interface AppJson {
5
6
  name: string;
@@ -17,6 +18,7 @@ interface SyncResponse {
17
18
  interface DevSyncOptions {
18
19
  fileId: string;
19
20
  apiKey?: string;
21
+ credential?: Credential;
20
22
  org?: string;
21
23
  quiet?: boolean;
22
24
  output?: string;
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import FormData from 'form-data';
5
5
  import chokidar from 'chokidar';
6
- import { resolveApiKey } from '../core/auth.js';
6
+ import { credentialOf } from '../core/credential.js';
7
7
  import { resolveBaseUrl } from '../core/baseUrl.js';
8
8
  import { makeClient, toCliError } from '../core/http.js';
9
9
  import { zipDirectoryToBuffer } from '../core/archive.js';
@@ -32,11 +32,11 @@ export function readAppJson(distPath) {
32
32
  }
33
33
  export async function runDevSync(opts, deps = {}) {
34
34
  try {
35
- const apiKey = resolveApiKey(opts);
35
+ const cred = credentialOf(opts);
36
36
  const baseUrl = resolveBaseUrl(opts);
37
37
  const distPath = findDistPath(deps.cwd);
38
38
  const appJson = readAppJson(distPath);
39
- const client = deps.client ?? makeClient(baseUrl, apiKey);
39
+ const client = deps.client ?? makeClient(baseUrl, cred);
40
40
  log(`Syncing ${appJson.name} from ${distPath}...`, { quiet: opts.quiet });
41
41
  const zip = await zipDirectoryToBuffer(distPath);
42
42
  log(`Zip created: ${(zip.length / 1024).toFixed(2)} KB`, { quiet: opts.quiet });
@@ -57,9 +57,9 @@ export async function runDevSync(opts, deps = {}) {
57
57
  }
58
58
  export async function runRevert(opts, deps = {}) {
59
59
  try {
60
- const apiKey = resolveApiKey(opts);
60
+ const cred = credentialOf(opts);
61
61
  const baseUrl = resolveBaseUrl(opts);
62
- const client = deps.client ?? makeClient(baseUrl, apiKey);
62
+ const client = deps.client ?? makeClient(baseUrl, cred);
63
63
  const res = await client.delete(SYNC_ENDPOINT, {
64
64
  headers: { 'Content-Type': 'application/json' },
65
65
  data: { file_id: opts.fileId },
@@ -12,6 +12,7 @@ export declare function runDsrBindLanding(opts: CommonOpts & {
12
12
  id?: string;
13
13
  landingTemplateId?: string;
14
14
  landingContext?: string;
15
+ landingContextFile?: string;
15
16
  instanceId?: string;
16
17
  }, deps?: DsrDeps): Promise<Envelope<unknown>>;
17
18
  export interface DsrCreateOpts extends CommonOpts {
@@ -30,6 +31,7 @@ export interface DsrCreateOpts extends CommonOpts {
30
31
  tags?: string[];
31
32
  landingTemplateId?: string;
32
33
  landingContext?: string;
34
+ landingContextFile?: string;
33
35
  configFile?: string;
34
36
  ifExists?: string;
35
37
  }
@@ -48,6 +50,9 @@ export interface DsrUpdateOpts extends CommonOpts {
48
50
  allowForward?: boolean;
49
51
  clear?: string[];
50
52
  }
53
+ export declare function runDsrGet(opts: CommonOpts & {
54
+ id?: string;
55
+ }, deps?: DsrDeps): Promise<Envelope<unknown>>;
51
56
  export declare function assembleUpdateBody(opts: DsrUpdateOpts): Record<string, unknown>;
52
57
  export declare function runDsrUpdate(opts: DsrUpdateOpts, deps?: DsrDeps): Promise<Envelope<unknown>>;
53
58
  export declare function runDsrDelete(opts: CommonOpts & {
@@ -4,7 +4,7 @@ import { ok, fail } from '../core/envelope.js';
4
4
  import { createCliError } from '../core/errors.js';
5
5
  import { emitEnvelope } from '../core/output.js';
6
6
  import { parseJsonObject, parseTags, isPlainObject } from '../schema/content.js';
7
- import { readTextFile } from '../core/readInput.js';
7
+ import { readTextFile, parseJsonInput } from '../core/readInput.js';
8
8
  import { withAuthFlags } from './withAuth.js';
9
9
  import { emitScaffold, DSR_SHARE_SCAFFOLD_JSON } from './scaffolds.js';
10
10
  const ENDPOINT = '/core/api/protected/shared-links';
@@ -19,9 +19,60 @@ function resolveTemplateId(raw) {
19
19
  return null;
20
20
  return raw;
21
21
  }
22
+ // ── landing context: inline OR file (PIT-7521 QA Q2) ────────────────────────
23
+ // `--landing-context` is a JSON object passed as a shell argument, which PowerShell mangles — it
24
+ // eats the double quotes a JSON object cannot do without, so on Windows the flag has no usable
25
+ // spelling at all. `--landing-context-file` is the way in: same field, read off disk, no quoting.
26
+ //
27
+ // The exclusion check is PRESENCE-based (`!== undefined`), not truthiness, and it runs BEFORE
28
+ // `parseJsonInput`: that helper's own XOR (`resolveTextInput`) is truthy-based, so it would let
29
+ // `--landing-context '' --landing-context-file ctx.json` through and silently read the file. Same
30
+ // reasoning as `metadata-template`'s `--sections` / `--sections-file` pair.
31
+ function resolveLandingContext(inline, file) {
32
+ if (inline !== undefined && file !== undefined)
33
+ throw createCliError('VALIDATION', 'pass exactly one of --landing-context or --landing-context-file');
34
+ if (inline === undefined && file === undefined)
35
+ return undefined;
36
+ // Exactly one flag is present from here on. An EMPTY string for either flag still short-circuits
37
+ // inside `resolveTextInput`, whose XOR is truthy-based, so `parseJsonInput` would answer `provide
38
+ // --landing-context (inline JSON) or --landing-context-file` -- naming the flag the caller just
39
+ // passed. That is the wrong advice for the accident this command family documents: `--flag "$MAYBE_UNSET"`
40
+ // expanding to an empty string is why `--clear` exists. Name the real problem instead. The
41
+ // genuinely-neither case is returned above and never reaches here, so it keeps that message.
42
+ if (inline === '')
43
+ throw createCliError('VALIDATION', '--landing-context must be valid JSON');
44
+ if (file === '')
45
+ throw createCliError('VALIDATION', '--landing-context-file requires a file path');
46
+ const parsed = parseJsonInput(inline, file, 'landing-context');
47
+ if (!isPlainObject(parsed))
48
+ throw createCliError('VALIDATION', '--landing-context must be a JSON object');
49
+ return parsed;
50
+ }
51
+ // The protected shared-links surface has NO GET — the only readback the platform offers is the
52
+ // PUBLIC pre-auth config the sharing page itself reads. Both callers go through this one function
53
+ // so the URL, the encoding and the error mapping have a single definition: `dsr get`, which returns
54
+ // the body as-is, and `dsr update`'s stored-strategy lookup, which swallows the failure.
55
+ function shareConfigUrl(id) {
56
+ return `/core/api/shared-links/${encodeURIComponent(id)}/config`;
57
+ }
58
+ async function fetchShareConfig(client, id) {
59
+ const res = await client.get(shareConfigUrl(id));
60
+ return res.data;
61
+ }
62
+ // The landing-page PUT is guarded by next-core's `withAtLeastInstanceAdmin`, which resolves the
63
+ // instance from the URL path, then the request body, then the `instance_id` query param. This route
64
+ // has no instance in its path and the body the CLI sends carries only the landing fields, so the
65
+ // query param is the ONLY source there is: without it the server answers 400 "Instance ID is
66
+ // required" before it ever looks at the share. Catching it here names the missing flag instead of
67
+ // handing back that opaque server error.
68
+ 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';
69
+ // `instanceId` is REQUIRED, not optional-with-a-ternary: both call sites now refuse an empty one
70
+ // before they get here (`runDsrBindLanding` throws, `runDsrCreate` returns the share with a warning),
71
+ // so a params-less PUT is unreachable — and it was never anything but the guaranteed 400 above.
72
+ // Typing it away is what stops a future caller from quietly reintroducing that call.
22
73
  async function bindLandingRequest(client, id, body, instanceId) {
23
74
  const url = `${ENDPOINT}/${encodeURIComponent(id)}/landing-page`;
24
- const res = await client.put(url, body, instanceId ? { params: { instance_id: instanceId } } : undefined);
75
+ const res = await client.put(url, body, { params: { instance_id: instanceId } });
25
76
  return res.data;
26
77
  }
27
78
  export async function runDsrBindLanding(opts, deps = {}) {
@@ -31,9 +82,12 @@ export async function runDsrBindLanding(opts, deps = {}) {
31
82
  const templateId = resolveTemplateId(opts.landingTemplateId);
32
83
  if (templateId === undefined)
33
84
  throw createCliError('VALIDATION', '--landing-template-id is required (use "none" to unbind)');
85
+ if (!opts.instanceId)
86
+ throw createCliError('VALIDATION', LANDING_INSTANCE_REQUIRED);
34
87
  const body = { landing_page_template_id: templateId };
35
- if (opts.landingContext !== undefined)
36
- body.landing_page_context = parseJsonObject(opts.landingContext, 'landing-context');
88
+ const context = resolveLandingContext(opts.landingContext, opts.landingContextFile);
89
+ if (context !== undefined)
90
+ body.landing_page_context = context;
37
91
  const client = clientFor(opts, deps);
38
92
  const data = await bindLandingRequest(client, opts.id, body, opts.instanceId);
39
93
  return ok(data);
@@ -48,6 +102,7 @@ const AUTH_STRATEGIES = [
48
102
  const SPEC_FLAG_KEYS = [
49
103
  'canvasId', 'shareboxFileIds', 'canvasName', 'accountId', 'templateId', 'authStrategy', 'allowedEmails',
50
104
  'allowedDomains', 'validUntil', 'allowForward', 'allowDownload', 'tags', 'landingTemplateId', 'landingContext',
105
+ 'landingContextFile',
51
106
  ];
52
107
  // config-file blocks must be JSON objects — a bare cast would silently spread a string/array into the request body
53
108
  function requirePlainObjectField(v, field) {
@@ -105,9 +160,28 @@ function assertCreateBody(body, instanceId) {
105
160
  const items = body.sharebox_items;
106
161
  const hasSharebox = Array.isArray(items) && items.length > 0;
107
162
  if (hasCanvas === hasSharebox)
108
- throw createCliError('VALIDATION', 'pass exactly one source: --canvas-id or --sharebox-file-ids');
163
+ throw createCliError('VALIDATION', 'pass exactly one source: --canvas-id or --sharebox-file-ids (in a --config-file spec: source.canvas_id or a non-empty source.sharebox_items)');
109
164
  if (hasSharebox && !instanceId && body.instance_id === undefined)
110
- throw createCliError('VALIDATION', '--instance-id is required for --sharebox-file-ids');
165
+ throw createCliError('VALIDATION', '--instance-id is required for --sharebox-file-ids (in a --config-file spec: --instance-id or source.instance_id)');
166
+ const tags = body.tags;
167
+ // SHAPE first: a hand-written spec can put anything under `security.tags`. The server would refuse a
168
+ // malformed value too (its ZodError catch answers `400 Invalid request data`), so this is not closing
169
+ // a hole — it fails before any request, with a message that names the field. This guard already owns
170
+ // the key, so it owns the shape too — and shape means the ELEMENTS as well: the server field is
171
+ // `z.array(z.string())`, so `tags: [1]` clears `Array.isArray` and still has to be refused. An EMPTY
172
+ // array passes both checks, by design.
173
+ if (tags !== undefined && (!Array.isArray(tags) || tags.some((t) => typeof t !== 'string')))
174
+ throw createCliError('VALIDATION', 'tags must be an array of strings');
175
+ // SOURCE second: `tags` is read by exactly one branch of the server route — the ShareBox branch,
176
+ // where it names the canvas the server MINTS from sharebox_items. With an existing canvas there is
177
+ // nothing to name: the POST schema still accepts the key and then drops it on the floor, so the
178
+ // tags vanish with no error at all. Refuse the combination instead of shipping a silent no-op.
179
+ // An EMPTY array is NOT a request to tag anything: `dsr scaffold` emits `tags: []` in its starter
180
+ // security block, so rejecting that would break every untouched `--config-file` create.
181
+ if (hasCanvas && Array.isArray(tags) && tags.length > 0)
182
+ throw createCliError('VALIDATION', 'tags apply only to a ShareBox-built canvas — they name the canvas the server creates from ' +
183
+ '--sharebox-file-ids, and with --canvas-id the server ignores them. Drop the tags, or share the ' +
184
+ 'files with --sharebox-file-ids.');
111
185
  const strat = body.auth_strategy;
112
186
  if (strat !== undefined) {
113
187
  if (!AUTH_STRATEGIES.includes(strat))
@@ -149,11 +223,14 @@ function resolveLandingBody(opts, cfgLanding) {
149
223
  }
150
224
  return b;
151
225
  }
152
- if (opts.landingTemplateId === undefined && opts.landingContext === undefined)
226
+ // resolved FIRST so `--landing-context X --landing-context-file Y` names the real mistake
227
+ // rather than being masked by the missing-template-id error below
228
+ const context = resolveLandingContext(opts.landingContext, opts.landingContextFile);
229
+ if (opts.landingTemplateId === undefined && context === undefined)
153
230
  return null;
154
231
  const b = { landing_page_template_id: requireCreateTemplateId(opts.landingTemplateId) };
155
- if (opts.landingContext !== undefined)
156
- b.landing_page_context = parseJsonObject(opts.landingContext, 'landing-context');
232
+ if (context !== undefined)
233
+ b.landing_page_context = context;
157
234
  return b;
158
235
  }
159
236
  // ── create collision policy (PIT-7521) ──────────────────────────────────────
@@ -223,6 +300,16 @@ export async function runDsrCreate(opts, deps = {}) {
223
300
  const shareId = share?.id;
224
301
  if (!shareId)
225
302
  return ok({ ...share, landing: null }, ['landing not attached: no share id in create response']);
303
+ // Same missing-instance failure as `dsr bind-landing`, reached from the other call site. It stays
304
+ // a WARNING rather than a hard failure because the share itself was created successfully and is
305
+ // the caller's primary output — dropping it would strand a live link with nothing referencing it.
306
+ // What changes is that the PUT is not attempted at all (it cannot succeed) and the warning names
307
+ // the flag plus the exact follow-up command, instead of echoing an opaque 400 from the server.
308
+ if (!effectiveInstanceId)
309
+ return ok({ ...share, landing: null }, [
310
+ `landing not attached: ${LANDING_INSTANCE_REQUIRED}. The share itself was created — bind the ` +
311
+ `landing page with \`p dsr bind-landing --id ${shareId} --landing-template-id <template-id> --instance-id <instance-id>\``,
312
+ ]);
226
313
  try {
227
314
  const landing = await bindLandingRequest(client, shareId, landingBody, effectiveInstanceId);
228
315
  return ok({ ...share, landing });
@@ -327,14 +414,37 @@ function clearedListFields(body) {
327
414
  // that is a warning here and a hard failure in the --if-exists preflight.
328
415
  async function readStoredAuthStrategy(client, id) {
329
416
  try {
330
- const res = await client.get(`/core/api/shared-links/${encodeURIComponent(id)}/config`);
331
- const strategy = res.data?.auth_strategy;
417
+ const data = (await fetchShareConfig(client, id));
418
+ const strategy = data?.auth_strategy;
332
419
  return typeof strategy === 'string' ? strategy : null;
333
420
  }
334
421
  catch {
335
422
  return null;
336
423
  }
337
424
  }
425
+ // ── read a share back (PIT-7521 QA Q5) ──────────────────────────────────────
426
+ // The only readback the platform offers. The body is returned verbatim — the CLI reshapes nothing,
427
+ // so what the caller sees is exactly what the sharing page is served: the ACCESS CONFIG only —
428
+ // auth_strategy, allowed_emails / allowed_domains, valid_until, allow_forward (+ allow_forward_raw,
429
+ // the rep's own toggle before the admin setting is applied), the landing binding
430
+ // (landing_page_template_id / _snapshot / landing_page_context) and created_at / modified_at.
431
+ // There is NO canvas or file field: the route selects none (see next-core
432
+ // `src/app/api/shared-links/[id]/config/route.ts`), so use the id from `dsr create` for those.
433
+ //
434
+ // ONE id failure mode, not two. The route has no id validation and no 400 path: it matches
435
+ // `pitcher_sharedlink.id` (a varchar) and answers 404 on zero rows, so ANY id that does not match
436
+ // a share — malformed or merely unknown — comes back 404 → NOT_FOUND via `toCliError`.
437
+ export async function runDsrGet(opts, deps = {}) {
438
+ try {
439
+ if (!opts.id)
440
+ throw createCliError('VALIDATION', '--id is required for dsr get');
441
+ const client = clientFor(opts, deps);
442
+ return ok(await fetchShareConfig(client, opts.id));
443
+ }
444
+ catch (e) {
445
+ return fail(toCliError(e));
446
+ }
447
+ }
338
448
  export function assembleUpdateBody(opts) {
339
449
  const cleared = normalizeClearFields(opts.clear);
340
450
  assertNoEmptyLists(opts);
@@ -407,8 +517,40 @@ export async function runDsrDelete(opts, deps = {}) {
407
517
  return fail(toCliError(e));
408
518
  }
409
519
  }
520
+ // PIT-7521 QA Q8: the spec's field names were documented only in the README, so a user reading
521
+ // `--help` had to guess the ShareBox source keys. The keys are the wire (snake_case) names.
522
+ const CREATE_HELP = `
523
+ --config-file spec (start from \`p dsr scaffold\`). Keys are the wire (snake_case)
524
+ names; source and security are spread into the create body as written.
525
+ source exactly one of:
526
+ canvas_id existing canvas ULID
527
+ sharebox_items [{ "file_id": "<ULID>", "page_index"?: 0 }] — builds a
528
+ new ShareBox canvas; page_index is optional (omit it
529
+ to share the whole file)
530
+ ShareBox-only extras: instance_id (required unless --instance-id),
531
+ canvas_name, account_id, template_id
532
+ security auth_strategy, allowed_emails [], allowed_domains [], valid_until,
533
+ allow_forward, allow_download, tags [] (tags: ShareBox only)
534
+ allow_download is stored on the CANVAS: omit it (the scaffold does) to
535
+ leave the canvas unchanged; unset, the viewer offers downloads
536
+ landing landing_page_template_id, landing_page_context {}
537
+ Example ShareBox source:
538
+ "source": { "instance_id": "01...", "sharebox_items": [{ "file_id": "01..." }] }`;
539
+ const GET_HELP = `
540
+ Reads the share back through the same public pre-auth config the sharing page
541
+ itself loads (the protected shared-links surface has no GET). The body is
542
+ returned unchanged, and it is the ACCESS CONFIG only: auth_strategy,
543
+ allowed_emails / allowed_domains, valid_until, allow_forward (plus
544
+ allow_forward_raw, the rep's own toggle), the landing-page binding
545
+ (landing_page_template_id, landing_page_template_snapshot,
546
+ landing_page_context) and created_at / modified_at. The endpoint returns NO
547
+ canvas or file — keep the id \`dsr create\` handed back for those.
548
+ --id the shared link ID (required)
549
+ Any id that matches no share answers 404 -> NOT_FOUND (exit 5). That is the
550
+ only id failure mode: the route does not validate the id's format, so a
551
+ malformed id is a miss like any other, not a 400.`;
410
552
  export function createDsrCommand() {
411
- const cmd = new Command('dsr').description('create and share a Digital Sales Room (a canvas share link)');
553
+ const cmd = new Command('dsr').description('create, read and share a Digital Sales Room (a canvas share link)');
412
554
  withAuthFlags(cmd.command('scaffold'))
413
555
  .description('print a {source,security,landing} share spec to fill in and pass to `dsr create --config-file`')
414
556
  .option('--out <path>', 'Write the scaffold to a file instead of stdout')
@@ -418,7 +560,7 @@ export function createDsrCommand() {
418
560
  });
419
561
  withAuthFlags(cmd.command('create'))
420
562
  .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)')
563
+ .option('--instance-id <id>', 'Instance ID (required for --sharebox-file-ids, and to bind a landing page)')
422
564
  .option('--canvas-id <id>', 'Existing canvas to share (one source required)')
423
565
  .option('--sharebox-file-ids <csv>', 'Build a canvas from these file IDs (one source required)', parseTags)
424
566
  .option('--canvas-name <name>', 'Name for the ShareBox-built canvas')
@@ -430,16 +572,20 @@ export function createDsrCommand() {
430
572
  .option('--valid-until <iso>', 'Expiry (ISO datetime)')
431
573
  .option('--allow-forward', 'Allow recipients to forward')
432
574
  .option('--no-allow-forward', 'Disallow forwarding')
433
- .option('--allow-download', 'Allow download')
434
- .option('--tags <csv>', 'Tags', parseTags)
575
+ .option('--allow-download', 'Allow download (with --canvas-id you must own the canvas or hold canvas.update on it, else AUTH)')
576
+ .option('--no-allow-download', 'Disallow download (same ownership rule as --allow-download)')
577
+ .option('--tags <csv>', 'Tags for the ShareBox-built canvas (rejected with --canvas-id, which ignores them)', parseTags)
435
578
  .option('--landing-template-id <id>', 'Landing template to bind (optional)')
436
579
  .option('--landing-context <json>', 'Landing personalization merge fields (JSON object)')
437
- .option('--config-file <path>', 'Full {source,security,landing} spec JSON (mutually exclusive with the flags above)')
580
+ .option('--landing-context-file <path>', 'Path to a JSON file with the landing merge fields — the Windows-safe alternative to --landing-context (exactly one of the two)')
581
+ .option('--config-file <path>', 'Full {source,security,landing} spec JSON — source is {canvas_id} or {sharebox_items: [{file_id, page_index?}], ' +
582
+ 'instance_id}; field list below (mutually exclusive with the flags above)')
438
583
  .option('--if-exists <mode>', 'return|error. What to do when the canvas is already shared: `return` hands back the existing link (default ' +
439
584
  'with individual flags), `error` refuses and points at `dsr update` (default with --config-file, whose ' +
440
585
  'security block is sent as-is and would overwrite the existing share). The check is a preflight lookup, so ' +
441
586
  'it is advisory rather than atomic — a link created between the check and the share is still returned. ' +
442
587
  'Never applies to a ShareBox source, which always mints a new canvas.')
588
+ .addHelpText('after', CREATE_HELP)
443
589
  .action(async (options, command) => {
444
590
  const globals = command.optsWithGlobals();
445
591
  emitEnvelope(await runDsrCreate({ ...options, quiet: globals.quiet }), globals);
@@ -449,15 +595,25 @@ export function createDsrCommand() {
449
595
  .option('--id <id>', 'Shared link ID (required)')
450
596
  .option('--landing-template-id <id>', "Landing template ID, or 'none' to unbind (required)")
451
597
  .option('--landing-context <json>', 'Personalization merge fields (JSON object)')
452
- .option('--instance-id <id>', 'Instance ID (optional)')
598
+ .option('--landing-context-file <path>', 'Path to a JSON file with the merge fields — the Windows-safe alternative to --landing-context (exactly one of the two)')
599
+ .option('--instance-id <id>', 'Instance ID (required — the endpoint resolves the instance from it)')
453
600
  .action(async (options, command) => {
454
601
  const globals = command.optsWithGlobals();
455
602
  emitEnvelope(await runDsrBindLanding({ ...options, quiet: globals.quiet }), globals);
456
603
  });
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.
604
+ withAuthFlags(cmd.command('get'))
605
+ .description('read an existing share back: gating, allow-lists, expiry, forwarding and the bound landing page')
606
+ .option('--id <id>', 'Shared link ID (required)')
607
+ .addHelpText('after', GET_HELP)
608
+ .action(async (options, command) => {
609
+ const globals = command.optsWithGlobals();
610
+ emitEnvelope(await runDsrGet({ ...options, quiet: globals.quiet }), globals);
611
+ });
612
+ // Every --x is declared BEFORE its --no-x on purpose: commander takes a boolean's default
613
+ // from whichever of the pair it sees first, so the reverse order would make the option
614
+ // default to `true` and every call would ship it. For allow_forward that clobbers a rep's
615
+ // opt-out; for allow_download it would turn an unpassed flag into an active grant.
616
+ // `create` above relies on the same ordering for BOTH pairs.
461
617
  withAuthFlags(cmd.command('update'))
462
618
  .description('update an existing share: only the flags you pass are sent; --clear removes a value')
463
619
  .option('--id <id>', 'Shared link ID (required)')
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import type { AxiosInstance } from 'axios';
3
+ import { type Credential } from '../core/credential.js';
3
4
  import { type Envelope } from '../core/envelope.js';
4
5
  interface PublishResult {
5
6
  app?: {
@@ -14,6 +15,7 @@ interface PublishResult {
14
15
  interface PublishOptions {
15
16
  folder?: string;
16
17
  apiKey?: string;
18
+ credential?: Credential;
17
19
  org?: string;
18
20
  url?: string;
19
21
  incrementVersion?: boolean;
@@ -4,7 +4,7 @@ import os from 'os';
4
4
  import path from 'path';
5
5
  import { randomUUID } from 'crypto';
6
6
  import FormData from 'form-data';
7
- import { resolveApiKey } from '../core/auth.js';
7
+ import { credentialOf } from '../core/credential.js';
8
8
  import { resolveBaseUrl } from '../core/baseUrl.js';
9
9
  import { makeClient, toCliError } from '../core/http.js';
10
10
  import { zipDirectoryToFile } from '../core/archive.js';
@@ -92,7 +92,7 @@ async function installToInstances(client, appName, instances, quiet) {
92
92
  }
93
93
  export async function runPublishApp(opts, deps = {}) {
94
94
  try {
95
- const apiKey = resolveApiKey(opts);
95
+ const cred = credentialOf(opts);
96
96
  const baseUrl = resolveBaseUrl({ org: opts.org, url: opts.url });
97
97
  const folderPath = path.resolve(opts.folder ?? '.');
98
98
  const appJsonPath = path.join(folderPath, 'app.json');
@@ -124,7 +124,7 @@ export async function runPublishApp(opts, deps = {}) {
124
124
  fs.writeFileSync(appJsonPath, JSON.stringify(existingAppJson, null, 2));
125
125
  log(`Updated app.json version to ${nextVersion}`, { quiet: opts.quiet });
126
126
  }
127
- const client = deps.client ?? makeClient(baseUrl, apiKey);
127
+ const client = deps.client ?? makeClient(baseUrl, cred);
128
128
  const zipPath = path.join(deps.tmpDir ?? os.tmpdir(), `cli-publish-${process.pid}-${randomUUID()}.zip`);
129
129
  try {
130
130
  log(`Zipping ${folderPath}...`, { quiet: opts.quiet });
@@ -272,9 +272,14 @@ const METADATA_TEMPLATE_SCAFFOLD_DATA = [
272
272
  export const METADATA_TEMPLATE_SCAFFOLD_JSON = JSON.stringify(METADATA_TEMPLATE_SCAFFOLD_DATA, null, 2);
273
273
  const DSR_SHARE_SCAFFOLD_DATA = {
274
274
  source: {
275
- // provide ONE of the following:
275
+ // provide ONE source: canvas_id (share an existing canvas) OR a non-empty sharebox_items (build a
276
+ // new ShareBox canvas from files) — delete the canvas_id line when you fill sharebox_items.
277
+ // Emitted as a real key, not a comment, because comments never reach the printed JSON (PIT-7521 QA Q8);
278
+ // an EMPTY array asks for nothing, so the untouched scaffold still shares canvas_id.
276
279
  canvas_id: '01EXAMPLECANVASID000000000',
277
- // sharebox_items: [{ file_id: '01EXAMPLEFILEID0000000000', page_index: 0 }],
280
+ sharebox_items: [], // [{ "file_id": "<file ULID>", "page_index"?: 0 }] — page_index optional (one page, not the whole file)
281
+ // ShareBox-only, and NOT emitted because the server refuses null for them — add them when needed:
282
+ // instance_id: '01EXAMPLEINSTANCEID0000000', (required for sharebox_items unless --instance-id is passed)
278
283
  // canvas_name: 'Acme QBR',
279
284
  // account_id: '01EXAMPLEACCOUNTID00000000',
280
285
  // template_id: '01EXAMPLECANVASTEMPLATE0000',
@@ -285,7 +290,10 @@ const DSR_SHARE_SCAFFOLD_DATA = {
285
290
  allowed_domains: [],
286
291
  valid_until: null,
287
292
  allow_forward: true,
288
- allow_download: false,
293
+ // allow_download: false, — NOT emitted. It is stored on the CANVAS (read by every link on it), and
294
+ // the server writes it only when present: omitted, an existing canvas keeps its value and a new
295
+ // ShareBox canvas carries none, which the viewer treats as downloads allowed. Emitting a default
296
+ // here would silently flip an existing canvas's setting on every untouched run (PIT-7521 review).
289
297
  tags: [],
290
298
  },
291
299
  landing: {