@purveyors/cli 0.27.0 → 0.31.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.
Files changed (76) hide show
  1. package/README.md +42 -46
  2. package/dist/commands/auth.d.ts +6 -2
  3. package/dist/commands/auth.d.ts.map +1 -1
  4. package/dist/commands/auth.js +119 -71
  5. package/dist/commands/auth.js.map +1 -1
  6. package/dist/commands/inventory.js +1 -1
  7. package/dist/commands/inventory.js.map +1 -1
  8. package/dist/commands/roast.d.ts +3 -3
  9. package/dist/commands/roast.d.ts.map +1 -1
  10. package/dist/commands/roast.js +15 -18
  11. package/dist/commands/roast.js.map +1 -1
  12. package/dist/commands/sales.d.ts +17 -1
  13. package/dist/commands/sales.d.ts.map +1 -1
  14. package/dist/commands/sales.js +23 -20
  15. package/dist/commands/sales.js.map +1 -1
  16. package/dist/commands/tasting.d.ts.map +1 -1
  17. package/dist/commands/tasting.js +19 -7
  18. package/dist/commands/tasting.js.map +1 -1
  19. package/dist/lib/ai.d.ts +6 -9
  20. package/dist/lib/ai.d.ts.map +1 -1
  21. package/dist/lib/ai.js +8 -27
  22. package/dist/lib/ai.js.map +1 -1
  23. package/dist/lib/auth-client.d.ts +33 -0
  24. package/dist/lib/auth-client.d.ts.map +1 -0
  25. package/dist/lib/auth-client.js +63 -0
  26. package/dist/lib/auth-client.js.map +1 -0
  27. package/dist/lib/auth-guard.d.ts +3 -3
  28. package/dist/lib/auth-guard.d.ts.map +1 -1
  29. package/dist/lib/auth-guard.js +2 -2
  30. package/dist/lib/auth-guard.js.map +1 -1
  31. package/dist/lib/catalog.js +2 -2
  32. package/dist/lib/catalog.js.map +1 -1
  33. package/dist/lib/config.d.ts.map +1 -1
  34. package/dist/lib/config.js +23 -2
  35. package/dist/lib/config.js.map +1 -1
  36. package/dist/lib/interactive/forms.d.ts +3 -4
  37. package/dist/lib/interactive/forms.d.ts.map +1 -1
  38. package/dist/lib/interactive/forms.js +15 -36
  39. package/dist/lib/interactive/forms.js.map +1 -1
  40. package/dist/lib/interactive/watch.d.ts +5 -2
  41. package/dist/lib/interactive/watch.d.ts.map +1 -1
  42. package/dist/lib/interactive/watch.js +35 -15
  43. package/dist/lib/interactive/watch.js.map +1 -1
  44. package/dist/lib/inventory.d.ts +2 -2
  45. package/dist/lib/inventory.d.ts.map +1 -1
  46. package/dist/lib/inventory.js +4 -4
  47. package/dist/lib/inventory.js.map +1 -1
  48. package/dist/lib/manifest.d.ts.map +1 -1
  49. package/dist/lib/manifest.js +14 -12
  50. package/dist/lib/manifest.js.map +1 -1
  51. package/dist/lib/market.d.ts.map +1 -1
  52. package/dist/lib/parchment-base.d.ts.map +1 -1
  53. package/dist/lib/parchment-base.js +1 -3
  54. package/dist/lib/parchment-base.js.map +1 -1
  55. package/dist/lib/parchment.d.ts +6 -6
  56. package/dist/lib/parchment.js +6 -6
  57. package/dist/lib/sales.d.ts +6 -17
  58. package/dist/lib/sales.d.ts.map +1 -1
  59. package/dist/lib/sales.js +87 -129
  60. package/dist/lib/sales.js.map +1 -1
  61. package/dist/lib/tasting.d.ts +3 -4
  62. package/dist/lib/tasting.d.ts.map +1 -1
  63. package/dist/lib/tasting.js +9 -41
  64. package/dist/lib/tasting.js.map +1 -1
  65. package/dist/program.js +1 -1
  66. package/dist/types/index.d.ts +3 -3
  67. package/dist/types/index.d.ts.map +1 -1
  68. package/package.json +3 -5
  69. package/dist/lib/supabase.d.ts +0 -22
  70. package/dist/lib/supabase.d.ts.map +0 -1
  71. package/dist/lib/supabase.js +0 -108
  72. package/dist/lib/supabase.js.map +0 -1
  73. package/dist/types/database.types.d.ts +0 -13
  74. package/dist/types/database.types.d.ts.map +0 -1
  75. package/dist/types/database.types.js +0 -8
  76. package/dist/types/database.types.js.map +0 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@ Use `purvey --help` for quick command discovery, `purvey context` for the dense
11
11
  - Official binary: `purvey`
12
12
  - Package: `@purveyors/cli`
13
13
  - Runtime: Node.js 20+
14
- - No pre-existing session required: `auth`, `config`, `context`, `manifest`
14
+ - No pre-existing credentials required: `auth`, `config`, `context`, `manifest`
15
15
  - Viewer role required: `catalog` (excluding structured process filters on `catalog search`)
16
16
  - Member role required: `price-index`, `procurement`, `inventory`, `roast`, `sales`, `tasting`
17
17
  - Mixed public and entitled access: `market` public teaser slices are unauthenticated; filtered slices require Parchment Intelligence access
@@ -71,7 +71,7 @@ purvey auth login
71
71
 
72
72
  # If automatic browser callback handling fails or you paste a bad URL, paste the full callback URL back into the terminal. The CLI keeps waiting until a valid callback is received.
73
73
 
74
- # 2. Confirm the session and role
74
+ # 2. Confirm the stored API key and role
75
75
  purvey auth status
76
76
 
77
77
  # 3. Search the catalog (viewer role for basic filters, member role for structured process filters)
@@ -133,22 +133,20 @@ Export discipline:
133
133
 
134
134
  ## Authentication and access model
135
135
 
136
- No pre-existing session is required for `auth`, `config`, `context`, or `manifest`.
136
+ No pre-existing credentials are required for `auth`, `config`, `context`, or `manifest`.
137
137
 
138
- Remote data commands require either a valid authenticated session or, for canonical
139
- Parchment API surfaces, an owner-bound API key with the required scope:
138
+ Remote data commands require a valid owner-bound API key with the required scope:
140
139
 
141
140
  - `catalog` requires the `viewer` role by default
142
- - `catalog search` structured process filters require the `member` role under the current session-authenticated CLI path
141
+ - `catalog search` structured process filters require the `member` role
143
142
  - `market` has public teaser slices for `signals --summary`, unfiltered retail `stats`, and process/retail/month `metadata`; all filtered or non-public slices require Parchment Intelligence access enforced server-side
144
- - `price-index`, `procurement`, `inventory`, `roast`, `sales`, and `tasting` require the `member` role under the session-authenticated CLI path
143
+ - `price-index`, `procurement`, `inventory`, `roast`, `sales`, and `tasting` require the `member` role
145
144
 
146
145
  `purvey` uses Google OAuth through purveyors.io.
147
146
 
148
147
  Set `PARCHMENT_API_KEY` (or `PURVEYORS_API_KEY`) to authenticate SDK-backed catalog,
149
- inventory, tasting-read, market, price-index, and procurement operations without using
150
- the stored session JWT. API-key credentials take precedence. Roast, sales, and
151
- `tasting rate` still use the session-backed migration path in this release.
148
+ inventory, roast, sales, tasting-read, market, price-index, and procurement operations
149
+ without using the API key created by `purvey auth login`. Environment credentials take precedence.
152
150
 
153
151
  Interactive login:
154
152
 
@@ -185,16 +183,16 @@ Credentials are stored at `~/.config/purvey/credentials.json`.
185
183
 
186
184
  ### Auth roles
187
185
 
188
- | Role | Access |
189
- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
190
- | `viewer` | `catalog search`, `catalog get`, `catalog stats`, excluding structured process filters |
191
- | `member` | All viewer commands, `catalog similar`, structured process filters on `catalog search`, plus `price-index`, `procurement`, `inventory`, `roast`, `sales`, `tasting` under the session-authenticated CLI path |
186
+ | Role | Access |
187
+ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
188
+ | `viewer` | `catalog search`, `catalog get`, `catalog stats`, excluding structured process filters |
189
+ | `member` | All viewer commands, `catalog similar`, structured process filters on `catalog search`, plus `price-index`, `procurement`, `inventory`, `roast`, `sales`, and `tasting` through the scoped key created by `purvey auth login` |
192
190
 
193
- Market Index teaser slices are public. Filtered `market signals`, origin/process/wholesale `market stats`, and non-public `market metadata` slices require Parchment Intelligence access; API-key and session-token denial is enforced by the canonical API.
191
+ Market Index teaser slices are public. Filtered `market signals`, origin/process/wholesale `market stats`, and non-public `market metadata` slices require Parchment Intelligence access; API-key denial is enforced by the canonical API. The stored login key carries `catalog:read`, which is also the canonical read scope for Market Index, Price Index, and procurement.
194
192
 
195
- `auth`, `config`, `context`, and `manifest` remain available without a pre-existing session.
193
+ `auth`, `config`, `context`, and `manifest` remain available without pre-existing credentials.
196
194
 
197
- Commands that require a higher role exit with code `3` on auth failure. That includes not being signed in, an expired session, or an insufficient role.
195
+ Commands that require a higher role exit with code `3` on auth failure. That includes missing, revoked, or invalid credentials and an insufficient role.
198
196
 
199
197
  ## Output contract and scripting
200
198
 
@@ -252,15 +250,15 @@ The JSON error envelope includes:
252
250
 
253
251
  All `purvey` commands exit with a numeric code your scripts can check with `$?`.
254
252
 
255
- | Code | Meaning |
256
- | ---- | ---------------------------------------------------------------- |
257
- | `0` | Success |
258
- | `1` | Unexpected or unclassified error |
259
- | `2` | Invalid argument or bad input |
260
- | `3` | Auth error: not logged in, expired session, or insufficient role |
261
- | `4` | Not found |
262
- | `5` | Dependency conflict |
263
- | `6` | Local config error |
253
+ | Code | Meaning |
254
+ | ---- | ------------------------------------------------------------------ |
255
+ | `0` | Success |
256
+ | `1` | Unexpected or unclassified error |
257
+ | `2` | Invalid argument or bad input |
258
+ | `3` | Auth error: missing, revoked, or invalid key; or insufficient role |
259
+ | `4` | Not found |
260
+ | `5` | Dependency conflict |
261
+ | `6` | Local config error |
264
262
 
265
263
  Scripting pattern:
266
264
 
@@ -408,11 +406,11 @@ purvey catalog get 1182 --include-proof --json
408
406
  Notes:
409
407
 
410
408
  - Catalog commands require an authenticated `viewer` role by default.
411
- - Structured process filters on `catalog search` require an authenticated `member` role under the current session-authenticated CLI path.
409
+ - Structured process filters on `catalog search` require an authenticated `member` role.
412
410
  - Structured process filters use the canonical `/v1/catalog` query contract names while preserving the legacy `--process` label filter.
413
411
  - `--include-proof` is an opt-in API-backed catalog read. It consumes the canonical proof summary returned by `/v1/catalog?include=proof`; the CLI does not compute proof fields locally or duplicate web/API proof logic.
414
412
  - `--include-proof` rejects CLI-only filters that `/v1/catalog` cannot yet preserve exactly, such as `--flavor`, `--supplier`, `--drying-method`, and `--sort newest`.
415
- - If you want proof output against an API-key deployment, set `PARCHMENT_API_KEY` or `PURVEYORS_API_KEY` before running the command. Otherwise the CLI uses your logged-in Purveyors session token.
413
+ - If you want proof output against a specific API-key deployment, set `PARCHMENT_API_KEY` or `PURVEYORS_API_KEY`. Otherwise the CLI uses the key created by `purvey auth login`.
416
414
  - `catalog get` and `catalog similar` both take `coffee_catalog.catalog_id`.
417
415
  - `catalog rank` and `catalog rank-premium` read `coffee_catalog.purveyor_score` as the canonical quality signal; the CLI does not recompute the upstream Purveyor Score model.
418
416
  - `catalog facets` and `catalog rank` are generic agent/client intelligence surfaces. Facet counts come from the canonical API across the selected stocked/all-visible scope; ranking responses include sample metadata so callers do not mistake sampled rarity for whole-catalog guarantees.
@@ -451,7 +449,7 @@ Notes:
451
449
 
452
450
  - `price-index` is backed by the canonical Parchment API `GET /v1/price-index` through `@purveyors/sdk`.
453
451
  - Session-token use requires the local `member` role; API-key use is accepted via `PARCHMENT_API_KEY` or `PURVEYORS_API_KEY` and PPI entitlement is enforced server-side.
454
- - `PARCHMENT_API_BASE_URL` overrides the canonical API base for this SDK-backed command. `PURVEYORS_BASE_URL` is also accepted for shared environment compatibility.
452
+ - `PARCHMENT_API_BASE_URL` overrides the canonical API base for this SDK-backed command.
455
453
 
456
454
  ### market
457
455
 
@@ -502,7 +500,7 @@ Notes:
502
500
 
503
501
  - `market` commands are backed by canonical Parchment API endpoints through `@purveyors/sdk`; the CLI does not compute Market Index intelligence locally.
504
502
  - Public teaser slices: `signals --summary`, `stats` with no origin/process and `market=retail`, and `metadata` at dimension=process/no-origin/market=retail/grain=month.
505
- - Any filtered or non-public market slice requires Parchment Intelligence access, enforced server-side for both API-key and session-token calls.
503
+ - Any filtered or non-public market slice requires Parchment Intelligence access, enforced server-side for API-key calls.
506
504
  - `--json` returns the API response verbatim.
507
505
 
508
506
  ### procurement
@@ -583,8 +581,7 @@ purvey inventory delete 7 --yes
583
581
 
584
582
  Notes:
585
583
 
586
- - Inventory commands require a member session or an owner-bound API key with the
587
- corresponding inventory scope.
584
+ - Inventory commands require an owner-bound member API key with the corresponding inventory scope.
588
585
  - Inventory `id` is `green_coffee_inv.id`, not `catalog_id`.
589
586
  - `inventory delete` refuses to cascade. Delete dependent roasts or sales explicitly first.
590
587
 
@@ -671,6 +668,7 @@ Notes:
671
668
  - `--coffee-id` uses inventory IDs.
672
669
  - `roast import` and `roast watch` normalize pasted paths by trimming whitespace, removing one layer of matching quotes, and accepting common shell-escaped characters.
673
670
  - `roast watch --auto-match` is mutually exclusive with `--coffee-id`.
671
+ - `roast watch --auto-match` sends roast metadata and the current stocked-inventory candidates to the canonical Parchment `POST /v1/roasts/classify` endpoint via `@purveyors/sdk`; it never calls an AI provider directly.
674
672
  - `roast watch --commit-mode` defaults to `batch`.
675
673
 
676
674
  ### sales
@@ -691,7 +689,7 @@ Notes:
691
689
 
692
690
  `sales record` flags:
693
691
 
694
- - `--roast-id <id>`; exact selector mode
692
+ - `--roast-id <id>`; resolve the sale inventory and batch from a roast profile
695
693
  - `--coffee-id <id>`; resolved selector mode, requires `--batch-name`
696
694
  - `--batch-name <name>`; resolved selector mode, requires `--coffee-id`
697
695
  - `--oz <amount>`; required in flag mode
@@ -724,8 +722,8 @@ purvey sales delete 5 --yes
724
722
  Notes:
725
723
 
726
724
  - Sales commands require an authenticated `member` role.
727
- - Use exactly one selector mode for `sales record`: exact `--roast-id`, or resolved `--coffee-id` plus `--batch-name`.
728
- - If resolved mode matches multiple roasts, rerun with `--roast-id`.
725
+ - Use exactly one selector mode for `sales record`: `--roast-id`, or `--coffee-id` plus `--batch-name`.
726
+ - Sales retain inventory and batch, not roast ID. Duplicate batch names on one inventory item are rejected even when selected through `--roast-id`.
729
727
  - `--price` is total sale price, not per-ounce price.
730
728
 
731
729
  ### tasting
@@ -764,10 +762,9 @@ purvey tasting rate --form
764
762
 
765
763
  Notes:
766
764
 
767
- - Tasting reads accept a member session or an owner-bound `tasting:read` API key.
765
+ - Tasting reads accept an owner-bound `tasting:read` API key.
768
766
  - `tasting get` combines supplier notes with your own notes when available.
769
- - `tasting rate` writes scores back to your inventory row through the remaining direct
770
- Supabase path until the canonical tasting-write endpoint ships.
767
+ - `tasting rate` writes through the canonical Parchment tasting endpoint.
771
768
 
772
769
  ### config
773
770
 
@@ -881,17 +878,16 @@ Use the right ID for the right command.
881
878
  - `catalog_id`: `coffee_catalog` rows; used by `catalog get`, `catalog similar`, `inventory add --catalog-id`, `tasting get`, `roast list --catalog-id`
882
879
  - `inventory id`: `green_coffee_inv` rows; used by `inventory get/update/delete`, `roast --coffee-id`, `tasting rate`, `roast list --coffee-id`
883
880
  - `roast_id`: `roast_data` rows; used by `roast get/delete`, `sales record --roast-id`, `roast list --roast-id`
884
- - `sales record` also supports resolving a roast from `inventory id` plus `--batch-name`; if that selector is ambiguous, use exact `roast_id`
881
+ - `sales record` also supports resolving a roast from `inventory id` plus `--batch-name`; because sales retain inventory + batch rather than roast ID, duplicate batch names on one inventory item are rejected
885
882
  - `sale id`: `coffee_sales` rows; used by `sales update/delete`
886
883
 
887
884
  ## Environment variables
888
885
 
889
- - `PURVEYORS_SUPABASE_URL`: override the Supabase project URL
890
- - `PURVEYORS_SUPABASE_ANON_KEY`: override the Supabase anon key
886
+ - `PURVEYORS_SUPABASE_URL`: override the Supabase Auth issuer used only for OAuth bootstrap
891
887
  - `PURVEYORS_BASE_URL`: override the Purveyors web base URL
892
- - `PURVEYORS_API_KEY`: API-key token used by API-backed catalog proof reads, paid-tier similarity reads, and SDK-backed Parchment commands when you want to call canonical API contracts without relying on the local OAuth session
888
+ - `PURVEYORS_API_KEY`: explicit API-key override for canonical Parchment commands
893
889
  - `PARCHMENT_API_KEY`: preferred API-key variable for SDK-backed Parchment commands; also accepted for API-backed proof and paid-tier similarity paths
894
- - `PARCHMENT_API_BASE_URL`: override the SDK-backed Parchment API base URL for `market`, `price-index`, and `procurement` commands
890
+ - `PARCHMENT_API_BASE_URL`: override the SDK-backed Parchment API base URL, including `market`, `price-index`, `procurement`, and roast auto-classification requests
895
891
  - `PURVEY_DEBUG`: enable verbose error output
896
892
 
897
893
  ## For AI agents
@@ -924,7 +920,7 @@ Agent integration rules of thumb:
924
920
 
925
921
  - Discover first with `purvey manifest`, then call the narrowest command or package subpath that fits the job.
926
922
  - Use `purvey context` when a human-readable operator summary is useful before tool selection.
927
- - Prefer OAuth session tokens for normal user workflows; use `PURVEYORS_API_KEY` or `PARCHMENT_API_KEY` only when you intentionally need an API-key-backed Parchment path such as catalog proof, paid-tier similarity, market intelligence, price-index, or procurement reads.
923
+ - Use `purvey auth login` for normal user workflows. OAuth is used only to bootstrap a machine-scoped Parchment API key; the CLI does not retain Supabase session or refresh tokens. Environment `PURVEYORS_API_KEY` or `PARCHMENT_API_KEY` values remain available for explicit automation overrides.
928
924
  - Treat `--include-proof` as API output, not a local scoring feature. If a filter cannot round-trip through `/v1/catalog?include=proof`, the CLI rejects that invocation instead of returning misleading proof data.
929
925
  - Treat `catalog similar` as the canonical `/v1/catalog/{id}/similar` contract. Preserve the distinction between `canonical_candidates` and `similar_recommendations`; do not flatten or re-sort grouped results unless you have a specific downstream reason.
930
926
  - Treat `market`, `price-index`, and `procurement` as SDK-backed canonical API reads. Do not add procurement create/write behavior to this command group until the Phase 2 write contract ships.
@@ -941,7 +937,7 @@ purvey auth login --headless
941
937
 
942
938
  **Catalog commands fail after logging in**
943
939
 
944
- Run `purvey auth status` to confirm you have a `viewer` or `member` role. If the session is stale, run `purvey auth logout` and log in again.
940
+ Run `purvey auth status` to confirm the stored CLI key is active and has a `viewer` or `member` role. If it is stale or revoked, run `purvey auth logout` and log in again.
945
941
 
946
942
  **Wrong ID type passed to a command**
947
943
 
@@ -993,7 +989,7 @@ Key files:
993
989
  - `src/index.ts`: executable entrypoint
994
990
  - `src/program.ts`: top-level CLI registration and global options
995
991
  - `src/commands/`: command definitions and help text
996
- - `src/lib/`: business logic and Supabase integration
992
+ - `src/lib/`: business logic and Parchment SDK integration
997
993
  - `src/commands/context.ts`: dense human-readable agent reference
998
994
  - `src/commands/manifest.ts`: machine-readable CLI manifest command
999
995
  - `src/lib/manifest.ts`: shared manifest contract, package export list, command metadata, ID guidance, and context renderer
@@ -1,4 +1,6 @@
1
1
  import { Command } from 'commander';
2
+ import type { StoredCredentials } from '../types/index.js';
3
+ import { type ParchmentClient } from '@purveyors/sdk';
2
4
  interface CallbackResult {
3
5
  accessToken: string;
4
6
  refreshToken: string;
@@ -9,12 +11,14 @@ interface ManualCallbackReader {
9
11
  close: () => void;
10
12
  }
11
13
  type ManualCallbackQuestion = (query: string, callback: (answer: string) => void) => void;
14
+ export declare function createOAuthUrl(redirectTo: string): string;
15
+ export declare function exchangeOAuthSessionForApiKey(accessToken: string, clientOverride?: ParchmentClient): Promise<StoredCredentials>;
12
16
  /**
13
17
  * Extract Supabase OAuth tokens from a full callback URL, URL fragment, or
14
18
  * query string. Exported for auth-flow contract tests.
15
19
  */
16
- export declare function parseOAuthCallbackUrl(callbackUrl: string): CallbackResult;
17
- export declare function createManualCallbackReaderForQuestion(question: ManualCallbackQuestion, closeQuestion: () => void, canRead: boolean): ManualCallbackReader | null;
20
+ export declare function parseOAuthCallbackUrl(callbackUrl: string, expectedState?: string): CallbackResult;
21
+ export declare function createManualCallbackReaderForQuestion(question: ManualCallbackQuestion, closeQuestion: () => void, canRead: boolean, expectedState?: string): ManualCallbackReader | null;
18
22
  /**
19
23
  * Build and return the `auth` command subtree.
20
24
  */
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/commands/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAcpC,UAAU,cAAc;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACnB;AAQD,UAAU,oBAAoB;IAC5B,OAAO,EAAE,OAAO,CAAC,cAAc,CAAC,CAAC;IACjC,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED,KAAK,sBAAsB,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,KAAK,IAAI,CAAC;AA6G1F;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,cAAc,CAoCzE;AAED,wBAAgB,qCAAqC,CACnD,QAAQ,EAAE,sBAAsB,EAChC,aAAa,EAAE,MAAM,IAAI,EACzB,OAAO,EAAE,OAAO,GACf,oBAAoB,GAAG,IAAI,CAwC7B;AAoOD;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAkF1C"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/commands/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAMpC,OAAO,KAAK,EAAiB,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAI1E,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAwB7E,UAAU,cAAc;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACnB;AASD,UAAU,oBAAoB;IAC5B,OAAO,EAAE,OAAO,CAAC,cAAc,CAAC,CAAC;IACjC,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED,KAAK,sBAAsB,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,KAAK,IAAI,CAAC;AAE1F,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAKzD;AAiBD,wBAAsB,6BAA6B,CACjD,WAAW,EAAE,MAAM,EACnB,cAAc,CAAC,EAAE,eAAe,GAC/B,OAAO,CAAC,iBAAiB,CAAC,CAwC5B;AAsHD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,cAAc,CA2CjG;AAED,wBAAgB,qCAAqC,CACnD,QAAQ,EAAE,sBAAsB,EAChC,aAAa,EAAE,MAAM,IAAI,EACzB,OAAO,EAAE,OAAO,EAChB,aAAa,CAAC,EAAE,MAAM,GACrB,oBAAoB,GAAG,IAAI,CAwC7B;AAsLD;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAmF1C"}
@@ -1,14 +1,90 @@
1
1
  import { Command } from 'commander';
2
2
  import { createServer } from 'http';
3
- import { createAnonClient, validateSession } from '../lib/supabase.js';
3
+ import { validateSession } from '../lib/auth-client.js';
4
4
  import { writeCredentials, deleteCredentials } from '../lib/config.js';
5
5
  import { outputData, shouldUseInteractiveOutput, success, info, warn } from '../lib/output.js';
6
6
  import { withErrorHandling, AuthError, exitCodeForError } from '../lib/errors.js';
7
7
  import chalk from 'chalk';
8
8
  import ora from 'ora';
9
9
  import { createInterface } from 'readline';
10
+ import { createParchmentClient } from '@purveyors/sdk';
11
+ import { getParchmentBaseUrl } from '../lib/parchment-base.js';
12
+ import { hostname } from 'os';
13
+ import { randomUUID } from 'crypto';
10
14
  const DEFAULT_CALLBACK_HOST = 'localhost';
11
15
  const CALLBACK_PATH = '/auth/callback';
16
+ const SUPABASE_AUTH_URL = process.env.PURVEYORS_SUPABASE_URL || 'https://bjblfzfdtfvuitqdbodn.supabase.co';
17
+ const CLI_API_KEY_SCOPES = [
18
+ // The canonical API uses catalog:read for catalog, Market Index,
19
+ // Price Index, and procurement reads; those data planes do not define
20
+ // separate key scopes.
21
+ 'catalog:read',
22
+ 'inventory:read',
23
+ 'inventory:write',
24
+ 'roast:read',
25
+ 'roast:write',
26
+ 'sales:read',
27
+ 'sales:write',
28
+ 'tasting:read',
29
+ 'tasting:write',
30
+ ];
31
+ export function createOAuthUrl(redirectTo) {
32
+ const url = new URL('/auth/v1/authorize', SUPABASE_AUTH_URL);
33
+ url.searchParams.set('provider', 'google');
34
+ url.searchParams.set('redirect_to', redirectTo);
35
+ return url.toString();
36
+ }
37
+ function decodeSessionIdentity(accessToken) {
38
+ try {
39
+ const payload = JSON.parse(Buffer.from(accessToken.split('.')[1] ?? '', 'base64url').toString('utf8'));
40
+ return {
41
+ id: typeof payload.sub === 'string' ? payload.sub : 'unknown',
42
+ email: typeof payload.email === 'string' ? payload.email : 'unknown',
43
+ role: typeof payload.role === 'string' ? payload.role : undefined,
44
+ };
45
+ }
46
+ catch {
47
+ return { id: 'unknown', email: 'unknown' };
48
+ }
49
+ }
50
+ export async function exchangeOAuthSessionForApiKey(accessToken, clientOverride) {
51
+ const client = clientOverride ?? createParchmentClient({ baseUrl: getParchmentBaseUrl(), token: accessToken });
52
+ const me = await client.me();
53
+ if (!me.response.ok || me.error || !me.data?.authenticated || !me.data.userId) {
54
+ throw new AuthError('OAuth session could not be verified by Parchment.');
55
+ }
56
+ const keyName = `purvey-cli-${hostname()}`;
57
+ const existing = await client.apiKeys.list();
58
+ if (!existing.response.ok || existing.error) {
59
+ throw new AuthError('Failed to inspect existing CLI API keys.', existing.error);
60
+ }
61
+ const supersededKeys = (existing.data?.data ?? []).filter((key) => key.name === keyName && key.isActive);
62
+ const result = await client.apiKeys.create({
63
+ name: keyName,
64
+ scopes: [...CLI_API_KEY_SCOPES],
65
+ });
66
+ if (!result.response.ok || result.error || !result.data?.apiKey) {
67
+ throw new AuthError('Failed to create a scoped Parchment API key.', result.error);
68
+ }
69
+ for (const key of supersededKeys) {
70
+ const revoked = await client.apiKeys.revoke(key.id);
71
+ if (!revoked.response.ok || revoked.error) {
72
+ await client.apiKeys.revoke(result.data.key.id).catch(() => undefined);
73
+ throw new AuthError('Failed to replace the existing CLI API key.', revoked.error);
74
+ }
75
+ }
76
+ const identity = decodeSessionIdentity(accessToken);
77
+ return {
78
+ apiKey: result.data.apiKey,
79
+ keyId: result.data.key.id,
80
+ createdAt: result.data.key.createdAt ?? new Date().toISOString(),
81
+ user: {
82
+ ...identity,
83
+ id: me.data.userId,
84
+ role: me.data.primaryAppRole ?? identity.role,
85
+ },
86
+ };
87
+ }
12
88
  /**
13
89
  * Start a one-shot local HTTP server to receive the Supabase OAuth callback.
14
90
  *
@@ -18,6 +94,7 @@ const CALLBACK_PATH = '/auth/callback';
18
94
  */
19
95
  function startCallbackServer() {
20
96
  return new Promise((resolve, reject) => {
97
+ const state = randomUUID();
21
98
  let tokenResolve;
22
99
  let tokenReject;
23
100
  const tokenPromise = new Promise((res, rej) => {
@@ -26,7 +103,7 @@ function startCallbackServer() {
26
103
  });
27
104
  const server = createServer((req, res) => {
28
105
  const url = new URL(req.url ?? '/', `http://${DEFAULT_CALLBACK_HOST}`);
29
- if (url.pathname === CALLBACK_PATH) {
106
+ if (url.pathname === CALLBACK_PATH && url.searchParams.get('state') === state) {
30
107
  res.writeHead(200, { 'Content-Type': 'text/html' });
31
108
  res.end(`<!DOCTYPE html>
32
109
  <html>
@@ -43,6 +120,7 @@ function startCallbackServer() {
43
120
  access_token: params.get('access_token'),
44
121
  refresh_token: params.get('refresh_token'),
45
122
  expires_in: params.get('expires_in'),
123
+ state: new URLSearchParams(window.location.search).get('state'),
46
124
  })
47
125
  }).then(() => {
48
126
  document.body.innerHTML =
@@ -61,8 +139,8 @@ function startCallbackServer() {
61
139
  res.end('ok');
62
140
  server.close();
63
141
  try {
64
- const { access_token, refresh_token, expires_in } = JSON.parse(body);
65
- if (!access_token || !refresh_token) {
142
+ const { access_token, refresh_token, expires_in, state: callbackState, } = JSON.parse(body);
143
+ if (!access_token || !refresh_token || callbackState !== state) {
66
144
  tokenReject(new AuthError('OAuth callback did not include tokens. Try again.'));
67
145
  return;
68
146
  }
@@ -89,6 +167,7 @@ function startCallbackServer() {
89
167
  }
90
168
  resolve({
91
169
  port: addr.port,
170
+ state,
92
171
  tokenPromise,
93
172
  close: () => {
94
173
  try {
@@ -107,7 +186,7 @@ function startCallbackServer() {
107
186
  * Extract Supabase OAuth tokens from a full callback URL, URL fragment, or
108
187
  * query string. Exported for auth-flow contract tests.
109
188
  */
110
- export function parseOAuthCallbackUrl(callbackUrl) {
189
+ export function parseOAuthCallbackUrl(callbackUrl, expectedState) {
111
190
  const trimmed = callbackUrl.trim();
112
191
  if (!trimmed) {
113
192
  throw new AuthError('No URL provided.');
@@ -129,6 +208,12 @@ export function parseOAuthCallbackUrl(callbackUrl) {
129
208
  const accessToken = params.get('access_token');
130
209
  const refreshToken = params.get('refresh_token');
131
210
  const expiresIn = params.get('expires_in');
211
+ if (expectedState) {
212
+ const parsedUrl = new URL(trimmed, 'http://localhost');
213
+ if (parsedUrl.searchParams.get('state') !== expectedState) {
214
+ throw new AuthError('OAuth callback state did not match this login attempt.');
215
+ }
216
+ }
132
217
  if (!accessToken || !refreshToken) {
133
218
  throw new AuthError('Could not extract tokens from the URL.\n' +
134
219
  ' Make sure you copied the full callback URL including the #access_token=... part.');
@@ -139,7 +224,7 @@ export function parseOAuthCallbackUrl(callbackUrl) {
139
224
  expiresIn: parseInt(expiresIn ?? '3600', 10),
140
225
  };
141
226
  }
142
- export function createManualCallbackReaderForQuestion(question, closeQuestion, canRead) {
227
+ export function createManualCallbackReaderForQuestion(question, closeQuestion, canRead, expectedState) {
143
228
  if (!canRead)
144
229
  return null;
145
230
  let closed = false;
@@ -152,7 +237,7 @@ export function createManualCallbackReaderForQuestion(question, closeQuestion, c
152
237
  if (closed)
153
238
  return;
154
239
  try {
155
- const callbackResult = parseOAuthCallbackUrl(answer);
240
+ const callbackResult = parseOAuthCallbackUrl(answer, expectedState);
156
241
  closed = true;
157
242
  closeQuestion();
158
243
  resolve(callbackResult);
@@ -177,14 +262,14 @@ export function createManualCallbackReaderForQuestion(question, closeQuestion, c
177
262
  },
178
263
  };
179
264
  }
180
- function createManualCallbackReader() {
265
+ function createManualCallbackReader(expectedState) {
181
266
  if (!process.stdin.isTTY)
182
267
  return null;
183
268
  const rl = createInterface({
184
269
  input: process.stdin,
185
270
  output: process.stdout,
186
271
  });
187
- return createManualCallbackReaderForQuestion(rl.question.bind(rl), () => rl.close(), process.stdin.isTTY);
272
+ return createManualCallbackReaderForQuestion(rl.question.bind(rl), () => rl.close(), process.stdin.isTTY, expectedState);
188
273
  }
189
274
  /**
190
275
  * `purvey auth login`
@@ -192,19 +277,12 @@ function createManualCallbackReader() {
192
277
  */
193
278
  const loginAction = withErrorHandling(async () => {
194
279
  const callbackServer = await startCallbackServer();
195
- const { port, tokenPromise } = callbackServer;
196
- const redirectTo = `http://${DEFAULT_CALLBACK_HOST}:${port}${CALLBACK_PATH}`;
280
+ const { port, state, tokenPromise } = callbackServer;
281
+ const redirectTo = `http://${DEFAULT_CALLBACK_HOST}:${port}${CALLBACK_PATH}?state=${encodeURIComponent(state)}`;
197
282
  try {
198
- const supabase = createAnonClient();
199
- const { data, error } = await supabase.auth.signInWithOAuth({
200
- provider: 'google',
201
- options: { redirectTo },
202
- });
203
- if (error || !data.url) {
204
- throw new AuthError('Failed to generate OAuth URL.', error);
205
- }
283
+ const oauthUrl = createOAuthUrl(redirectTo);
206
284
  console.log(chalk.bold('\n Opening browser for authentication...'));
207
- console.log(chalk.dim(` If the browser does not open, visit:\n ${data.url}\n`));
285
+ console.log(chalk.dim(` If the browser does not open, visit:\n ${oauthUrl}\n`));
208
286
  console.log(chalk.dim(' Waiting for the browser callback. If the browser lands on a localhost URL but\n' +
209
287
  ' the CLI does not finish, copy that full URL and paste it below.\n'));
210
288
  // Open browser cross-platform (graceful fallback for headless)
@@ -215,7 +293,7 @@ const loginAction = withErrorHandling(async () => {
215
293
  : process.platform === 'win32'
216
294
  ? 'start'
217
295
  : 'xdg-open';
218
- const child = spawn(openCmd, [data.url], { detached: true, stdio: 'ignore' });
296
+ const child = spawn(openCmd, [oauthUrl], { detached: true, stdio: 'ignore' });
219
297
  child.on('error', () => {
220
298
  // Browser open failed (headless environment) — user can visit URL manually
221
299
  });
@@ -224,7 +302,7 @@ const loginAction = withErrorHandling(async () => {
224
302
  catch {
225
303
  // Gracefully ignore — URL is already printed above
226
304
  }
227
- const manualReader = createManualCallbackReader();
305
+ const manualReader = createManualCallbackReader(state);
228
306
  const spinner = ora({ text: 'Waiting for authentication...', stream: process.stderr }).start();
229
307
  let callbackResult;
230
308
  try {
@@ -236,22 +314,9 @@ const loginAction = withErrorHandling(async () => {
236
314
  finally {
237
315
  manualReader?.close();
238
316
  }
239
- const { accessToken, refreshToken, expiresIn } = callbackResult;
317
+ const { accessToken } = callbackResult;
240
318
  spinner.succeed('Authentication received');
241
- // Fetch user info to confirm identity
242
- const client = createAnonClient();
243
- await client.auth.setSession({ access_token: accessToken, refresh_token: refreshToken });
244
- const { data: { user }, } = await client.auth.getUser();
245
- const creds = {
246
- accessToken,
247
- refreshToken,
248
- expiresAt: Date.now() + expiresIn * 1000,
249
- user: {
250
- id: user?.id ?? 'unknown',
251
- email: user?.email ?? 'unknown',
252
- role: user?.role,
253
- },
254
- };
319
+ const creds = await exchangeOAuthSessionForApiKey(accessToken);
255
320
  await writeCredentials(creds);
256
321
  success(`Logged in as ${chalk.bold(creds.user.email)}`);
257
322
  }
@@ -289,12 +354,14 @@ const statusAction = withErrorHandling(async (_, cmd) => {
289
354
  authenticated: true,
290
355
  email: session.email,
291
356
  role: session.role ?? 'authenticated',
292
- tokenExpires: new Date(session.expiresAt).toISOString(),
357
+ keyId: session.keyId,
358
+ keyCreated: session.createdAt,
293
359
  };
294
360
  if (isInteractive) {
295
361
  success(`Logged in as ${chalk.bold(session.email)}`);
296
362
  info(`Role: ${result.role}`);
297
- info(`Token expires: ${result.tokenExpires}`);
363
+ info(`API key: ${result.keyId}`);
364
+ info(`Created: ${result.keyCreated}`);
298
365
  return;
299
366
  }
300
367
  outputData(result, opts);
@@ -305,18 +372,13 @@ const statusAction = withErrorHandling(async (_, cmd) => {
305
372
  */
306
373
  const headlessLoginAction = withErrorHandling(async () => {
307
374
  // Generate OAuth URL with purveyors.io callback page as redirect
308
- const redirectTo = 'https://purveyors.io/auth/cli-callback';
309
- const supabase = createAnonClient();
310
- const { data, error } = await supabase.auth.signInWithOAuth({
311
- provider: 'google',
312
- options: { redirectTo, skipBrowserRedirect: true },
313
- });
314
- if (error || !data.url) {
315
- throw new AuthError('Failed to generate OAuth URL.');
316
- }
375
+ const state = randomUUID();
376
+ const redirectTo = new URL('https://purveyors.io/auth/cli-callback');
377
+ redirectTo.searchParams.set('state', state);
378
+ const oauthUrl = createOAuthUrl(redirectTo.toString());
317
379
  console.log(chalk.bold('\n Headless Login\n'));
318
380
  console.log(' 1. Open this URL in a browser and sign in:\n');
319
- console.log(` ${chalk.cyan(data.url)}\n`);
381
+ console.log(` ${chalk.cyan(oauthUrl)}\n`);
320
382
  console.log(" 2. After login, you'll see a page with a callback URL.");
321
383
  console.log(' 3. Copy the full callback URL and paste it below.\n');
322
384
  // Read the callback URL from stdin
@@ -331,24 +393,9 @@ const headlessLoginAction = withErrorHandling(async () => {
331
393
  if (!callbackUrl) {
332
394
  throw new AuthError('No URL provided.');
333
395
  }
334
- const { accessToken, refreshToken, expiresIn } = parseOAuthCallbackUrl(callbackUrl);
396
+ const { accessToken } = parseOAuthCallbackUrl(callbackUrl, state);
335
397
  const spinner = ora({ text: 'Validating session...', stream: process.stderr }).start();
336
- const client = createAnonClient();
337
- const { data: { user }, error: userError, } = await client.auth.getUser(accessToken);
338
- if (userError || !user) {
339
- spinner.fail('Token validation failed');
340
- throw new AuthError('Invalid or expired token. Try the login flow again.');
341
- }
342
- const creds = {
343
- accessToken,
344
- refreshToken,
345
- expiresAt: Date.now() + expiresIn * 1000,
346
- user: {
347
- id: user.id,
348
- email: user.email ?? 'unknown',
349
- role: user.role,
350
- },
351
- };
398
+ const creds = await exchangeOAuthSessionForApiKey(accessToken);
352
399
  await writeCredentials(creds);
353
400
  spinner.succeed(`Logged in as ${chalk.bold(creds.user.email)}`);
354
401
  });
@@ -358,7 +405,7 @@ const headlessLoginAction = withErrorHandling(async () => {
358
405
  */
359
406
  const logoutAction = withErrorHandling(async () => {
360
407
  await deleteCredentials();
361
- success('Logged out successfully.');
408
+ warn('Local credentials cleared. The server API key remains active; revoke it from the Purveyors account key dashboard if this machine is lost or compromised.');
362
409
  });
363
410
  /**
364
411
  * Build and return the `auth` command subtree.
@@ -390,8 +437,8 @@ Examples:
390
437
 
391
438
  Notes:
392
439
  Credentials are stored at ~/.config/purvey/credentials.json (mode 0600).
393
- Tokens auto-refresh; no need to re-login between sessions.
394
- Uses Google OAuth via Supabase same account as purveyors.io web app.
440
+ OAuth is used once to mint a scoped Parchment API key; session tokens are not retained.
441
+ Re-login replaces the prior CLI key for this machine.
395
442
  `);
396
443
  auth
397
444
  .command('status')
@@ -407,10 +454,11 @@ Examples:
407
454
  purvey auth status --json 2>/dev/null | jq .
408
455
 
409
456
  Output fields:
410
- authenticated boolean — true if a valid session exists
457
+ authenticated boolean — true if the stored API key is valid
411
458
  email your Google account email
412
459
  role your purveyors.io role (viewer or member)
413
- tokenExpires ISO timestamp when the access token expires
460
+ keyId identifier of the stored Parchment API key
461
+ keyCreated ISO timestamp when the key was created
414
462
 
415
463
  Notes:
416
464
  When stdout is a TTY (interactive terminal), output is human-readable unless