1use clap::{Parser, Subcommand};
2use std::path::PathBuf;
3
4#[derive(Parser, Debug)]
5#[command(name = "px", version, about, long_about = None)]
6pub struct Cli {
7 #[arg(long, short = 'd', global = true, env = "PX_DIR")]
10 pub base_dir: Option<PathBuf>,
11
12 #[arg(long, short = 'v', global = true)]
14 pub verbose: bool,
15
16 #[arg(
20 id = "read_remote",
21 long = "remote",
22 global = true,
23 conflicts_with = "local"
24 )]
25 pub remote: bool,
26
27 #[arg(long, global = true, conflicts_with = "read_remote")]
29 pub local: bool,
30
31 #[command(subcommand)]
32 pub command: Commands,
33}
34
35#[derive(Subcommand, Debug)]
37pub enum RemoteCmd {
38 Add {
40 repository: String,
42 name: String,
44 url: String,
46 },
47 Ls {
49 repository: String,
51 },
52 Rm {
54 repository: String,
56 name: String,
58 },
59}
60
61#[derive(Subcommand, Debug)]
63pub enum ChooseCmd {
64 Backend {
66 provider: String,
68
69 #[arg(long)]
71 remote_url: Option<String>,
72
73 #[arg(long)]
75 workspace_id: Option<String>,
76
77 #[arg(long)]
79 reset: bool,
80 },
81}
82
83#[derive(Subcommand, Debug)]
85pub enum BackendCmd {
86 #[command(alias = "set")]
92 Configure {
93 backend: String,
95
96 #[arg(long, alias = "remote-url", alias = "remote_url")]
98 endpoint: Option<String>,
99
100 #[arg(long)]
102 workspace_id: Option<String>,
103
104 #[arg(long)]
106 initial_commit: bool,
107
108 #[arg(long)]
110 no_initial_commit: bool,
111 },
112
113 Status,
115}
116
117#[derive(Subcommand, Debug)]
126pub enum ConfigureCmd {
127 Status,
129}
130
131#[derive(Debug, Parser)]
132pub struct ConfigureArgs {
133 #[arg(value_name = "PROVIDER", conflicts_with = "provider_flag")]
136 pub provider: Option<String>,
137
138 #[arg(
140 long = "provider",
141 value_name = "PROVIDER",
142 hide = true,
143 conflicts_with = "provider"
144 )]
145 pub provider_flag: Option<String>,
146
147 #[arg(long, alias = "endpoint", alias = "remote_url", value_name = "URL")]
149 pub remote_url: Option<String>,
150
151 #[arg(long, alias = "workspace", value_name = "ID")]
153 pub workspace_id: Option<String>,
154
155 #[arg(long)]
157 pub reset: bool,
158
159 #[arg(long)]
161 pub initial_commit: bool,
162
163 #[arg(long)]
165 pub no_initial_commit: bool,
166
167 #[command(subcommand)]
168 pub cmd: Option<ConfigureCmd>,
169}
170
171#[derive(Subcommand, Debug)]
173pub enum AuthCmd {
174 Login {
176 #[arg(long)]
178 api_key: bool,
179
180 #[arg(long, default_value = "PORTALS_CLOUD_API_KEY", requires = "api_key")]
182 api_key_env: String,
183
184 #[arg(long, conflicts_with = "api_key")]
186 no_browser: bool,
187 },
188 Status,
190 Logout,
192}
193
194#[derive(Subcommand, Debug)]
195pub enum Commands {
196 Auth {
198 #[command(subcommand)]
200 cmd: AuthCmd,
201 },
202
203 Install {
205 target: String,
207 },
208
209 Init {
220 repository: Option<String>,
222
223 #[arg(long)]
225 provider: Option<String>,
226
227 #[arg(long)]
229 remote_url: Option<String>,
230
231 #[arg(long)]
233 workspace_id: Option<String>,
234
235 #[arg(long = "origin")]
240 remote: Option<String>,
241
242 #[arg(long)]
244 reset: bool,
245 },
246
247 #[command(alias = "config")]
257 Configure {
258 #[command(flatten)]
259 args: ConfigureArgs,
260 },
261
262 #[command(hide = true)]
264 Choose {
265 #[command(subcommand)]
267 cmd: ChooseCmd,
268 },
269
270 #[command(hide = true)]
272 Backend {
273 #[command(subcommand)]
275 cmd: BackendCmd,
276 },
277
278 Completions {
286 shell: clap_complete::Shell,
288 },
289
290 Doctor {
292 #[arg(long)]
294 repair: bool,
295 },
296
297 Status,
299
300 Sync {
302 repository: String,
304 },
305
306 Create {
308 entity_type: String,
310
311 entity_id: String,
313
314 #[arg(long, short = 'u')]
316 repository: String,
317
318 #[arg(long, short = 'n')]
320 name: String,
321
322 #[arg(long, short = 'a', default_value = "px")]
324 author: String,
325 },
326
327 Resolve {
332 uri: String,
334
335 #[arg(long)]
337 branch: Option<String>,
338
339 #[arg(long)]
341 commit: Option<String>,
342
343 #[arg(long, short = 'f', default_value = "yaml", env = "PX_OUTPUT")]
345 format: String,
346
347 #[arg(long)]
349 provenance: bool,
350
351 #[arg(long)]
353 include_blobs: bool,
354 },
355
356 #[command(
358 long_about = r#"Create a time-limited public URL for a committed representation.
359
360Pass the entity ID first and the representation name second:
361
362```bash
363px presign 25th-chapter/character/nathan-gunn item
364```
365
366- `25th-chapter/character/nathan-gunn` identifies the repository, entity type,
367 and entity ID. The `px://` prefix is optional.
368- `item` is the exact key under the entity manifest's `representations` map.
369 It is not a file path or the entity's display name.
370
371The equivalent fully qualified command is:
372
373```bash
374px presign px://25th-chapter/character/nathan-gunn item
375```
376
377### How the representation is located
378
379PX reads the entity manifest at the selected revision and looks up
380`representations.item`. For example:
381
382```yaml
383representations:
384 item:
385 hash: blake3:<content hash>
386 format: jpg
387 uri: item.jpg
388```
389
390Representation URIs are relative to the entity's asset directory, matching
391`px add`. For this entity, `uri: item.jpg` resolves to
392`character/nathan-gunn/item.jpg` within the repository. Keep `uri: item.jpg`;
393there is no need to put the entity ID into the representation URI.
394
395### Revision and lifetime
396
397```bash
398px presign 25th-chapter/character/nathan-gunn item \
399 --branch main \
400 --ttl-seconds 900
401```
402
403Use either `--branch` or `--commit`, never both. When neither is supplied, PX
404uses the repository's configured default branch, falling back to the global
405default branch. Branches are pinned to a commit before PX reads the manifest
406and content address. Lore applies its configured lifetime bounds and defaults
407when `--ttl-seconds` is omitted.
408
409The manifest and representation file must be committed at the selected
410revision, and the content must have been pushed to the Lore server.
411External URLs, linked repositories, absolute paths, path traversal, URI
412fragments, and unversioned working-tree files are not supported.
413
414### Output
415
416In a terminal, the command prints the URL, expiration, and pinned revision.
417When piped or redirected, it emits JSON with `url`, `expires_at`, `revision`,
418`repository_id`, `address`, `representation`, and `format`.
419
420The returned URL is a bearer capability: anyone who has it can download the
421immutable bytes until it expires. Do not place it in logs, analytics, exception
422messages, source control, or long-lived storage.
423
424### Automatic configuration
425
426PX records the Lore HTTP origin in `provider.toml` during backend setup and
427backfills older provider configurations automatically. Local Lore uses
428`http://127.0.0.1:41339`; standard remote Lore uses the same host on port 41339;
429TLS deployments behind port 443 use the same HTTPS origin. Portals Cloud uses
430`https://lore.portals.works`. The normal command needs no additional flags:
431
432```bash
433px presign 25th-chapter/character/nathan-gunn item
434```
435
436Authenticated requests reuse the active `px auth login` / Lore identity.
437Only unexpired repository-scoped tokens authorized for the HTTP recipient are
438used. Automatic credential reuse requires HTTPS for remote servers; loopback
439HTTP is supported for development. No separate HTTP token setup is needed.
440
441Operators with custom proxy layouts can set `http_url` in `provider.toml`.
442Explicit `--http-url` or `PX_LORE_HTTP_URL` overrides take precedence.
443Bearer-token environment overrides remain available for automation.
444
445### Server setup and signing-key security
446
447New PX-managed local installations create a unique 32-byte signing key in
448owner-only server configuration and bind to loopback. Existing managed configs
449receive a missing key without replacing existing keys or other settings.
450Restart an already running server after its configuration changes.
451
452Standalone development Lore provisions a persistent owner-only `presign.key`
453in its configuration directory when no signing key is supplied. Persist this
454directory across restarts. Never copy that key into client configuration.
455
456Only Lore uses the key, to sign and validate download capabilities. It is
457independent of login tokens, JWT signing keys, and API-key peppers; PX clients
458never need it. Keep the key stable across restarts and private to the server.
459Server logs omit signing keys and signed query tokens. Signed responses prevent
460caching and referrer leakage. Development URLs require network access to the host.
461
462Production / Portals Cloud presign is WIP. PX derives the Cloud HTTP origin,
463but deployment still needs a dedicated shared signing key, scoped HTTPS routes,
464and query-token-safe logging. Without a supplied production key, presign stays
465disabled. These are operator concerns, not end-user flags or secrets.
466
467### SDK methods
468
469All three methods take the entity ID and representation name as their first
470two arguments, using the same lookup as the CLI:
471
472- Rust: `Resolver::presign_representation(entity_id, representation, &options)`
473 is asynchronous.
474- Python: `presign_representation(entity_id, representation, **options)` is
475 synchronous.
476- TypeScript: `presignRepresentation(entityId, representation, options)` is
477 asynchronous.
478
479Python:
480
481```python
482from px_sdk import presign_representation
483
484result = presign_representation(
485 "25th-chapter/character/nathan-gunn", "item", branch="main", ttl_seconds=900
486)
487```
488
489TypeScript:
490
491```typescript
492import { presignRepresentation } from "@portalshq/px";
493
494const result = await presignRepresentation(
495 "25th-chapter/character/nathan-gunn",
496 "item",
497 { branch: "main", ttlSeconds: 900 },
498);
499```
500
501The SDKs return the same fields as the CLI JSON output.
502"#
503 )]
504 Presign {
505 #[arg(value_name = "ENTITY_ID")]
507 uri: String,
508
509 representation: String,
511
512 #[arg(long, conflicts_with = "commit")]
514 branch: Option<String>,
515
516 #[arg(long, conflicts_with = "branch")]
518 commit: Option<String>,
519
520 #[arg(long)]
522 ttl_seconds: Option<u64>,
523
524 #[arg(long)]
526 http_url: Option<String>,
527
528 #[arg(long)]
530 token_env: Option<String>,
531 },
532
533 Query {
535 uri: String,
537
538 path: String,
540
541 #[arg(long, short = 'f', default_value = "json", env = "PX_OUTPUT")]
543 format: String,
544 },
545
546 Commit {
548 repository: String,
550
551 #[arg(long, short = 'm')]
553 message: String,
554
555 #[arg(long, short = 'a', default_value = "px")]
557 author: String,
558 },
559
560 History {
562 uri: String,
564
565 #[arg(long, short = 'n', default_value = "20")]
567 limit: usize,
568 },
569
570 List {
572 repository: Option<String>,
574
575 #[arg(long, short = 't')]
577 entity_type: Option<String>,
578 },
579
580 Branch {
582 repository: String,
584
585 name: Option<String>,
587 },
588
589 Set {
591 uri: String,
593
594 key: String,
596
597 value: String,
599
600 #[arg(long, short = 'm', default_value = "set property")]
602 message: String,
603
604 #[arg(long, short = 'a', default_value = "px")]
606 author: String,
607 },
608
609 #[command(alias = "add-repr")]
611 Add {
612 uri: String,
614
615 key: String,
617
618 file: PathBuf,
620
621 #[arg(long)]
623 format: String,
624
625 #[arg(long, short = 'm', default_value = "add representation")]
627 message: String,
628
629 #[arg(long, short = 'a', default_value = "px")]
631 author: String,
632 },
633
634 Revert {
636 repository: String,
638
639 #[arg(long, short = 'c')]
641 commit: String,
642
643 #[arg(long, short = 'a', default_value = "px")]
645 author: String,
646 },
647
648 Pull {
654 url_or_name: String,
656 },
657
658 #[command(alias = "publish")]
660 Push {
661 repository: String,
663
664 #[arg(long = "remote-name", default_value = "origin")]
669 remote: String,
670
671 #[arg(long)]
673 branch: Option<String>,
674 },
675
676 #[command(subcommand)]
678 Remote(RemoteCmd),
679
680 #[command(hide = true)]
682 Sign {
683 uri: String,
685 },
686
687 #[command(hide = true)]
689 Verify {
690 uri: String,
692 },
693
694 Switch {
696 repository: String,
698 name: String,
700 },
701
702 #[command(alias = "head-hash", alias = "head_hash")]
704 Head {
705 repository: String,
707 },
708
709 Validate {
711 uri: Option<String>,
713 #[arg(long)]
715 file: Option<PathBuf>,
716 },
717
718 Schema {
720 name: String,
722 #[arg(long, short = 'f', default_value = "json")]
724 format: String,
725 },
726
727 Diff {
729 base_file: PathBuf,
731 candidate_file: PathBuf,
733 #[arg(long, short = 'f', default_value = "yaml")]
735 format: String,
736 },
737
738 Merge {
740 base: PathBuf,
742 current: PathBuf,
744 proposed: PathBuf,
746 #[arg(long, short = 'f', default_value = "yaml")]
748 format: String,
749 },
750
751 ContentHash {
753 file: PathBuf,
755 },
756}