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 Configure {
92 backend: String,
94
95 #[arg(long)]
97 endpoint: Option<String>,
98
99 #[arg(long)]
101 workspace_id: Option<String>,
102
103 #[arg(long)]
105 initial_commit: bool,
106
107 #[arg(long)]
109 no_initial_commit: bool,
110 },
111
112 Status,
114}
115
116#[derive(Subcommand, Debug)]
118pub enum AuthCmd {
119 Login {
121 #[arg(long)]
123 api_key: bool,
124
125 #[arg(long, default_value = "PORTALS_CLOUD_API_KEY", requires = "api_key")]
127 api_key_env: String,
128
129 #[arg(long, conflicts_with = "api_key")]
131 no_browser: bool,
132 },
133 Status,
135 Logout,
137}
138
139#[derive(Subcommand, Debug)]
140pub enum Commands {
141 Auth {
143 #[command(subcommand)]
145 cmd: AuthCmd,
146 },
147
148 Install {
150 target: String,
152 },
153
154 Init {
165 repository: Option<String>,
167
168 #[arg(long)]
170 provider: Option<String>,
171
172 #[arg(long)]
174 remote_url: Option<String>,
175
176 #[arg(long)]
178 workspace_id: Option<String>,
179
180 #[arg(long = "origin")]
185 remote: Option<String>,
186
187 #[arg(long)]
189 reset: bool,
190 },
191
192 Choose {
194 #[command(subcommand)]
196 cmd: ChooseCmd,
197 },
198
199 Backend {
201 #[command(subcommand)]
203 cmd: BackendCmd,
204 },
205
206 Doctor {
208 #[arg(long)]
210 repair: bool,
211 },
212
213 Publish {
215 repository: String,
217 },
218
219 Status,
221
222 Sync {
224 repository: String,
226 },
227
228 Create {
230 entity_type: String,
232
233 entity_id: String,
235
236 #[arg(long, short = 'u')]
238 repository: String,
239
240 #[arg(long, short = 'n')]
242 name: String,
243
244 #[arg(long, short = 'a', default_value = "px")]
246 author: String,
247 },
248
249 Resolve {
254 uri: String,
256
257 #[arg(long)]
259 branch: Option<String>,
260
261 #[arg(long)]
263 commit: Option<String>,
264
265 #[arg(long, short = 'f', default_value = "yaml", env = "PX_OUTPUT")]
267 format: String,
268
269 #[arg(long)]
271 provenance: bool,
272
273 #[arg(long)]
275 include_blobs: bool,
276 },
277
278 #[command(
280 long_about = r#"Create a time-limited public URL for a committed representation.
281
282Pass the entity ID first and the representation name second:
283
284```bash
285px presign 25th-chapter/character/nathan-gunn item
286```
287
288- `25th-chapter/character/nathan-gunn` identifies the repository, entity type,
289 and entity ID. The `px://` prefix is optional.
290- `item` is the exact key under the entity manifest's `representations` map.
291 It is not a file path or the entity's display name.
292
293The equivalent fully qualified command is:
294
295```bash
296px presign px://25th-chapter/character/nathan-gunn item
297```
298
299### How the representation is located
300
301PX reads the entity manifest at the selected revision and looks up
302`representations.item`. For example:
303
304```yaml
305representations:
306 item:
307 hash: blake3:<content hash>
308 format: jpg
309 uri: item.jpg
310```
311
312Representation URIs are relative to the entity's asset directory, matching
313`px add`. For this entity, `uri: item.jpg` resolves to
314`character/nathan-gunn/item.jpg` within the repository. Keep `uri: item.jpg`;
315there is no need to put the entity ID into the representation URI.
316
317### Revision and lifetime
318
319```bash
320px presign 25th-chapter/character/nathan-gunn item \
321 --branch main \
322 --ttl-seconds 900
323```
324
325Use either `--branch` or `--commit`, never both. When neither is supplied, PX
326uses the repository's configured default branch, falling back to the global
327default branch. Branches are pinned to a commit before PX reads the manifest
328and content address. Lore applies its configured lifetime bounds and defaults
329when `--ttl-seconds` is omitted.
330
331The manifest and representation file must be committed at the selected
332revision, and the content must have been pushed to the Lore server.
333External URLs, linked repositories, absolute paths, path traversal, URI
334fragments, and unversioned working-tree files are not supported.
335
336### Output
337
338In a terminal, the command prints the URL, expiration, and pinned revision.
339When piped or redirected, it emits JSON with `url`, `expires_at`, `revision`,
340`repository_id`, `address`, `representation`, and `format`.
341
342The returned URL is a bearer capability: anyone who has it can download the
343immutable bytes until it expires. Do not place it in logs, analytics, exception
344messages, source control, or long-lived storage.
345
346### Automatic configuration
347
348PX records the Lore HTTP origin in `provider.toml` during backend setup and
349backfills older provider configurations automatically. Local Lore uses
350`http://127.0.0.1:41339`; standard remote Lore uses the same host on port 41339;
351TLS deployments behind port 443 use the same HTTPS origin. Portals Cloud uses
352`https://lore.portals.works`. The normal command needs no additional flags:
353
354```bash
355px presign 25th-chapter/character/nathan-gunn item
356```
357
358Authenticated requests reuse the active `px auth login` / Lore identity.
359Only unexpired repository-scoped tokens authorized for the HTTP recipient are
360used. Automatic credential reuse requires HTTPS for remote servers; loopback
361HTTP is supported for development. No separate HTTP token setup is needed.
362
363Operators with custom proxy layouts can set `http_url` in `provider.toml`.
364Explicit `--http-url` or `PX_LORE_HTTP_URL` overrides take precedence.
365Bearer-token environment overrides remain available for automation.
366
367### Server setup and signing-key security
368
369New PX-managed local installations create a unique 32-byte signing key in
370owner-only server configuration and bind to loopback. Existing managed configs
371receive a missing key without replacing existing keys or other settings.
372Restart an already running server after its configuration changes.
373
374Standalone development Lore provisions a persistent owner-only `presign.key`
375in its configuration directory when no signing key is supplied. Persist this
376directory across restarts. Never copy that key into client configuration.
377
378Only Lore uses the key, to sign and validate download capabilities. It is
379independent of login tokens, JWT signing keys, and API-key peppers; PX clients
380never need it. Keep the key stable across restarts and private to the server.
381Server logs omit signing keys and signed query tokens. Signed responses prevent
382caching and referrer leakage. Development URLs require network access to the host.
383
384Production / Portals Cloud presign is WIP. PX derives the Cloud HTTP origin,
385but deployment still needs a dedicated shared signing key, scoped HTTPS routes,
386and query-token-safe logging. Without a supplied production key, presign stays
387disabled. These are operator concerns, not end-user flags or secrets.
388
389### SDK methods
390
391All three methods take the entity ID and representation name as their first
392two arguments, using the same lookup as the CLI:
393
394- Rust: `Resolver::presign_representation(entity_id, representation, &options)`
395 is asynchronous.
396- Python: `presign_representation(entity_id, representation, **options)` is
397 synchronous.
398- TypeScript: `presignRepresentation(entityId, representation, options)` is
399 asynchronous.
400
401Python:
402
403```python
404from px_sdk import presign_representation
405
406result = presign_representation(
407 "25th-chapter/character/nathan-gunn", "item", branch="main", ttl_seconds=900
408)
409```
410
411TypeScript:
412
413```typescript
414import { presignRepresentation } from "@portalshq/px";
415
416const result = await presignRepresentation(
417 "25th-chapter/character/nathan-gunn",
418 "item",
419 { branch: "main", ttlSeconds: 900 },
420);
421```
422
423The SDKs return the same fields as the CLI JSON output.
424"#
425 )]
426 Presign {
427 #[arg(value_name = "ENTITY_ID")]
429 uri: String,
430
431 representation: String,
433
434 #[arg(long, conflicts_with = "commit")]
436 branch: Option<String>,
437
438 #[arg(long, conflicts_with = "branch")]
440 commit: Option<String>,
441
442 #[arg(long)]
444 ttl_seconds: Option<u64>,
445
446 #[arg(long)]
448 http_url: Option<String>,
449
450 #[arg(long)]
452 token_env: Option<String>,
453 },
454
455 Query {
457 uri: String,
459
460 path: String,
462
463 #[arg(long, short = 'f', default_value = "json", env = "PX_OUTPUT")]
465 format: String,
466 },
467
468 Commit {
470 repository: String,
472
473 #[arg(long, short = 'm')]
475 message: String,
476
477 #[arg(long, short = 'a', default_value = "px")]
479 author: String,
480 },
481
482 History {
484 uri: String,
486
487 #[arg(long, short = 'n', default_value = "20")]
489 limit: usize,
490 },
491
492 List {
494 repository: Option<String>,
496
497 #[arg(long, short = 't')]
499 entity_type: Option<String>,
500 },
501
502 Branch {
504 repository: String,
506
507 name: Option<String>,
509 },
510
511 Set {
513 uri: String,
515
516 key: String,
518
519 value: String,
521
522 #[arg(long, short = 'm', default_value = "set property")]
524 message: String,
525
526 #[arg(long, short = 'a', default_value = "px")]
528 author: String,
529 },
530
531 #[command(alias = "add-repr")]
533 Add {
534 uri: String,
536
537 key: String,
539
540 file: PathBuf,
542
543 #[arg(long)]
545 format: String,
546
547 #[arg(long, short = 'm', default_value = "add representation")]
549 message: String,
550
551 #[arg(long, short = 'a', default_value = "px")]
553 author: String,
554 },
555
556 Revert {
558 repository: String,
560
561 #[arg(long, short = 'c')]
563 commit: String,
564
565 #[arg(long, short = 'a', default_value = "px")]
567 author: String,
568 },
569
570 Pull {
576 url_or_name: String,
578 },
579
580 Push {
582 repository: String,
584
585 #[arg(long = "remote-name", default_value = "origin")]
590 remote: String,
591
592 #[arg(long)]
594 branch: Option<String>,
595 },
596
597 #[command(subcommand)]
599 Remote(RemoteCmd),
600
601 Sign {
603 uri: String,
605 },
606
607 Verify {
609 uri: String,
611 },
612
613 Switch {
615 repository: String,
617 name: String,
619 },
620
621 HeadHash {
623 repository: String,
625 },
626
627 Validate {
629 uri: Option<String>,
631 #[arg(long)]
633 file: Option<PathBuf>,
634 },
635
636 Schema {
638 name: String,
640 #[arg(long, short = 'f', default_value = "json")]
642 format: String,
643 },
644
645 Diff {
647 base_file: PathBuf,
649 candidate_file: PathBuf,
651 #[arg(long, short = 'f', default_value = "yaml")]
653 format: String,
654 },
655
656 Merge {
658 base: PathBuf,
660 current: PathBuf,
662 proposed: PathBuf,
664 #[arg(long, short = 'f', default_value = "yaml")]
666 format: String,
667 },
668
669 ContentHash {
671 file: PathBuf,
673 },
674}