@purveyors/cli 0.20.1 → 0.23.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Coffee intelligence from your terminal.
4
4
 
5
- `purvey` is the official CLI for [purveyors.io](https://purveyors.io). It gives coffee professionals, developers, and AI agents direct access to the Purveyors platform from the terminal: catalog search, inventory tracking, roast logging, sales records, tasting notes, and Artisan `.alog` import.
5
+ `purvey` is the official CLI for [purveyors.io](https://purveyors.io). It gives coffee professionals, developers, and AI agents direct access to the Purveyors platform from the terminal: catalog search, price-index snapshots, procurement brief reads, inventory tracking, roast logging, sales records, tasting notes, and Artisan `.alog` import.
6
6
 
7
7
  Use `purvey --help` for quick command discovery, `purvey context` for the dense human-readable operator reference, `purvey manifest` for the preferred machine-readable contract, or `@purveyors/cli/manifest` in-process.
8
8
 
@@ -13,7 +13,7 @@ Use `purvey --help` for quick command discovery, `purvey context` for the dense
13
13
  - Runtime: Node.js 20+
14
14
  - No pre-existing session required: `auth`, `config`, `context`, `manifest`
15
15
  - Viewer role required: `catalog` (excluding structured process filters on `catalog search`)
16
- - Member role required: `inventory`, `roast`, `sales`, `tasting`
16
+ - Member role required: `price-index`, `procurement`, `inventory`, `roast`, `sales`, `tasting`
17
17
  - Preferred machine-readable contract: `purvey manifest`
18
18
  - Dense human-readable reference: `purvey context`
19
19
  - Compatibility JSON alias: `purvey context --json`
@@ -62,7 +62,7 @@ The CLI is an agent-first product surface. Treat the binary, exported functions,
62
62
  ## Quick Start
63
63
 
64
64
  ```bash
65
- # 1. Authenticate before using catalog, inventory, roast, sales, or tasting commands
65
+ # 1. Authenticate before using catalog, market intelligence, inventory, roast, sales, or tasting commands
66
66
  purvey auth login
67
67
 
68
68
  # For agents, CI, or remote machines, use headless flow:
@@ -135,7 +135,7 @@ All remote data commands require a valid authenticated session:
135
135
 
136
136
  - `catalog` requires the `viewer` role by default
137
137
  - `catalog search` structured process filters require the `member` role under the current session-authenticated CLI path
138
- - `inventory`, `roast`, `sales`, and `tasting` require the `member` role
138
+ - `price-index`, `procurement`, `inventory`, `roast`, `sales`, and `tasting` require the `member` role under the session-authenticated CLI path
139
139
 
140
140
  `purvey` uses Google OAuth through purveyors.io.
141
141
 
@@ -174,10 +174,10 @@ Credentials are stored at `~/.config/purvey/credentials.json`.
174
174
 
175
175
  ### Auth roles
176
176
 
177
- | Role | Access |
178
- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
179
- | `viewer` | `catalog search`, `catalog get`, `catalog stats`, excluding structured process filters |
180
- | `member` | All viewer commands, `catalog similar`, structured process filters on `catalog search`, plus `inventory`, `roast`, `sales`, `tasting` under the session-authenticated CLI path |
177
+ | Role | Access |
178
+ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
179
+ | `viewer` | `catalog search`, `catalog get`, `catalog stats`, excluding structured process filters |
180
+ | `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 |
181
181
 
182
182
  `auth`, `config`, `context`, and `manifest` remain available without a pre-existing session.
183
183
 
@@ -412,6 +412,61 @@ Notes:
412
412
  - The command preserves `classification_version`, `query_strategy`, score dimensions, proof summaries, pricing metadata, and classification/blocker details supplied by the API.
413
413
  - `catalog stats` returns aggregate catalog metrics, not your personal inventory metrics.
414
414
 
415
+ ### price-index
416
+
417
+ - `purvey price-index`
418
+
419
+ `price-index` filters:
420
+
421
+ - `--origin <origin>`
422
+ - `--process <method>`
423
+ - `--grade <grade>`
424
+ - `--from <YYYY-MM-DD>`
425
+ - `--to <YYYY-MM-DD>`
426
+ - `--wholesale <true|false>`
427
+ - `--page <n>`; 1-based page number
428
+ - `--limit <n>`; results per page
429
+
430
+ Examples:
431
+
432
+ ```bash
433
+ purvey price-index --pretty
434
+ purvey price-index --origin "Ethiopia" --from 2026-01-01 --to 2026-06-30 --json
435
+ PARCHMENT_API_KEY="$PURVEYORS_API_KEY" purvey price-index --limit 25 --json
436
+ ```
437
+
438
+ Notes:
439
+
440
+ - `price-index` is backed by the canonical Parchment API `GET /v1/price-index` through `@purveyors/sdk`.
441
+ - 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.
442
+ - `PARCHMENT_API_BASE_URL` overrides the canonical API base for this SDK-backed command. `PURVEYORS_BASE_URL` is also accepted for shared environment compatibility.
443
+
444
+ ### procurement
445
+
446
+ - `purvey procurement list`
447
+ - `purvey procurement get <id>`
448
+ - `purvey procurement matches <id>`
449
+
450
+ `procurement matches <id>` options:
451
+
452
+ - `--page <n>`; 1-based page number
453
+ - `--limit <n>`; matches per page
454
+
455
+ Examples:
456
+
457
+ ```bash
458
+ purvey procurement list --pretty
459
+ purvey procurement get <brief-id> --pretty
460
+ purvey procurement matches <brief-id> --page 2 --limit 50 --json
461
+ PARCHMENT_API_KEY="$PURVEYORS_API_KEY" purvey procurement list --json
462
+ ```
463
+
464
+ Notes:
465
+
466
+ - Procurement reads are backed by the canonical Parchment API `/v1/procurement/briefs` endpoints through `@purveyors/sdk`.
467
+ - Session-token use requires the local `member` role; API-key use is accepted via `PARCHMENT_API_KEY` or `PURVEYORS_API_KEY` and procurement access is enforced server-side.
468
+ - Brief creation is intentionally not exposed here. It is a write path and belongs to the Phase 2 write build-out.
469
+
415
470
  ### inventory
416
471
 
417
472
  - `purvey inventory list`
@@ -769,8 +824,9 @@ Use the right ID for the right command.
769
824
  - `PURVEYORS_SUPABASE_URL`: override the Supabase project URL
770
825
  - `PURVEYORS_SUPABASE_ANON_KEY`: override the Supabase anon key
771
826
  - `PURVEYORS_BASE_URL`: override the Purveyors web base URL
772
- - `PURVEYORS_API_KEY`: API-key token used by API-backed catalog proof reads and paid-tier similarity reads when you want to call canonical API contracts without relying on the local OAuth session
773
- - `PARCHMENT_API_KEY`: alternate API-key variable accepted for the same API-backed proof and paid-tier similarity path
827
+ - `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
828
+ - `PARCHMENT_API_KEY`: preferred API-key variable for SDK-backed Parchment commands; also accepted for API-backed proof and paid-tier similarity paths
829
+ - `PARCHMENT_API_BASE_URL`: override the SDK-backed Parchment API base URL for `price-index` and `procurement` commands
774
830
  - `PURVEY_DEBUG`: enable verbose error output
775
831
 
776
832
  ## For AI agents
@@ -803,9 +859,10 @@ Agent integration rules of thumb:
803
859
 
804
860
  - Discover first with `purvey manifest`, then call the narrowest command or package subpath that fits the job.
805
861
  - Use `purvey context` when a human-readable operator summary is useful before tool selection.
806
- - Prefer OAuth session tokens for normal user workflows; use `PURVEYORS_API_KEY` or `PARCHMENT_API_KEY` only when you intentionally need the API-key catalog proof path or a paid-tier similarity path.
862
+ - 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, price-index, or procurement reads.
807
863
  - 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.
808
864
  - 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.
865
+ - Treat `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.
809
866
 
810
867
  ## Troubleshooting
811
868
 
@@ -0,0 +1,7 @@
1
+ import { Command } from 'commander';
2
+ /**
3
+ * `purvey price-index` — Parchment Price Index aggregate snapshots.
4
+ * Requires an authenticated session or API key with price-index entitlement.
5
+ */
6
+ export declare function buildPriceIndexCommand(): Command;
7
+ //# sourceMappingURL=price-index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"price-index.d.ts","sourceRoot":"","sources":["../../src/commands/price-index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA4BpC;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,OAAO,CAgDhD"}
@@ -0,0 +1,71 @@
1
+ import { Command } from 'commander';
2
+ import { outputData } from '../lib/output.js';
3
+ import { withErrorHandling, PrvrsError } from '../lib/errors.js';
4
+ import { createParchmentClient, unwrapParchment } from '../lib/parchment.js';
5
+ function parsePositiveInt(rawValue, flag) {
6
+ const parsed = Number(rawValue.trim());
7
+ if (!Number.isInteger(parsed) || parsed <= 0) {
8
+ throw new PrvrsError('INVALID_ARGUMENT', `Invalid ${flag}: "${rawValue}". Must be a positive integer.`);
9
+ }
10
+ return parsed;
11
+ }
12
+ function parseWholesale(rawValue) {
13
+ const normalized = rawValue.trim().toLowerCase();
14
+ if (normalized === 'true')
15
+ return 'true';
16
+ if (normalized === 'false')
17
+ return 'false';
18
+ throw new PrvrsError('INVALID_ARGUMENT', `Invalid --wholesale: "${rawValue}". Must be "true" or "false".`);
19
+ }
20
+ /**
21
+ * `purvey price-index` — Parchment Price Index aggregate snapshots.
22
+ * Requires an authenticated session or API key with price-index entitlement.
23
+ */
24
+ export function buildPriceIndexCommand() {
25
+ const priceIndex = new Command('price-index')
26
+ .description('Fetch Parchment Price Index aggregate snapshots (requires price-index access)')
27
+ .option('--origin <origin>', 'Filter by origin')
28
+ .option('--process <method>', 'Filter by process method')
29
+ .option('--grade <grade>', 'Filter by grade')
30
+ .option('--from <date>', 'Include snapshots on/after this ISO date (YYYY-MM-DD)')
31
+ .option('--to <date>', 'Include snapshots on/before this ISO date (YYYY-MM-DD)')
32
+ .option('--wholesale <true|false>', 'Filter by wholesale pricing scope')
33
+ .option('--page <n>', '1-based page number')
34
+ .option('--limit <n>', 'Results per page')
35
+ .addHelpText('after', `
36
+ Examples:
37
+ purvey price-index --pretty
38
+ purvey price-index --origin "Ethiopia" --json
39
+ purvey price-index --from 2026-01-01 --to 2026-06-30 --pretty
40
+ purvey price-index --wholesale true --limit 25 --json
41
+
42
+ Notes:
43
+ Requires a Purveyors session or API key with price-index (PPI) access.
44
+ Set PARCHMENT_API_KEY or PURVEYORS_API_KEY to use an API key instead of the logged-in session.`)
45
+ .action(withErrorHandling(async (opts, cmd) => {
46
+ const globalOpts = cmd.optsWithGlobals();
47
+ const query = {};
48
+ if (opts.origin !== undefined)
49
+ query.origin = opts.origin;
50
+ if (opts.process !== undefined)
51
+ query.process = opts.process;
52
+ if (opts.grade !== undefined)
53
+ query.grade = opts.grade;
54
+ if (opts.from !== undefined)
55
+ query.from = opts.from;
56
+ if (opts.to !== undefined)
57
+ query.to = opts.to;
58
+ if (opts.wholesale !== undefined)
59
+ query.wholesale = parseWholesale(opts.wholesale);
60
+ if (opts.page !== undefined)
61
+ query.page = parsePositiveInt(opts.page, '--page');
62
+ if (opts.limit !== undefined)
63
+ query.limit = parsePositiveInt(opts.limit, '--limit');
64
+ const client = await createParchmentClient('member');
65
+ const result = await client.priceIndex.list(query);
66
+ const data = unwrapParchment(result, 'price-index');
67
+ outputData(data, globalOpts);
68
+ }));
69
+ return priceIndex;
70
+ }
71
+ //# sourceMappingURL=price-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"price-index.js","sourceRoot":"","sources":["../../src/commands/price-index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ import { Command } from 'commander';
2
+ /**
3
+ * `purvey procurement` — read saved sourcing briefs from the canonical API.
4
+ * Brief creation is a write and is handled by the Phase 2 write build-out
5
+ * (PADR-0016), not by this read surface.
6
+ */
7
+ export declare function buildProcurementCommand(): Command;
8
+ //# sourceMappingURL=procurement.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"procurement.d.ts","sourceRoot":"","sources":["../../src/commands/procurement.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAkBpC;;;;GAIG;AACH,wBAAgB,uBAAuB,IAAI,OAAO,CA4EjD"}
@@ -0,0 +1,72 @@
1
+ import { Command } from 'commander';
2
+ import { outputData } from '../lib/output.js';
3
+ import { withErrorHandling, PrvrsError } from '../lib/errors.js';
4
+ import { createParchmentClient, unwrapParchment } from '../lib/parchment.js';
5
+ function parsePositiveInt(rawValue, flag) {
6
+ const parsed = Number(rawValue.trim());
7
+ if (!Number.isInteger(parsed) || parsed <= 0) {
8
+ throw new PrvrsError('INVALID_ARGUMENT', `Invalid ${flag}: "${rawValue}". Must be a positive integer.`);
9
+ }
10
+ return parsed;
11
+ }
12
+ /**
13
+ * `purvey procurement` — read saved sourcing briefs from the canonical API.
14
+ * Brief creation is a write and is handled by the Phase 2 write build-out
15
+ * (PADR-0016), not by this read surface.
16
+ */
17
+ export function buildProcurementCommand() {
18
+ const procurement = new Command('procurement').description('Read saved sourcing briefs and their catalog matches');
19
+ // procurement list
20
+ procurement
21
+ .command('list')
22
+ .description('List your saved sourcing briefs')
23
+ .addHelpText('after', `
24
+ Examples:
25
+ purvey procurement list --pretty
26
+ purvey procurement list --json | jq '.data'`)
27
+ .action(withErrorHandling(async (_opts, cmd) => {
28
+ const globalOpts = cmd.optsWithGlobals();
29
+ const client = await createParchmentClient('member');
30
+ const result = await client.procurement.briefs.list();
31
+ const data = unwrapParchment(result, 'procurement list');
32
+ outputData(data, globalOpts);
33
+ }));
34
+ // procurement get <id>
35
+ procurement
36
+ .command('get <id>')
37
+ .description('Get a single saved sourcing brief by id')
38
+ .addHelpText('after', `
39
+ Examples:
40
+ purvey procurement get 3f9a2c10-... --pretty`)
41
+ .action(withErrorHandling(async (id, _opts, cmd) => {
42
+ const globalOpts = cmd.optsWithGlobals();
43
+ const client = await createParchmentClient('member');
44
+ const result = await client.procurement.briefs.get(id);
45
+ const data = unwrapParchment(result, 'procurement get');
46
+ outputData(data, globalOpts);
47
+ }));
48
+ // procurement matches <id>
49
+ procurement
50
+ .command('matches <id>')
51
+ .description('Run a saved brief against the catalog and page through matches')
52
+ .option('--page <n>', '1-based page number (default 1)')
53
+ .option('--limit <n>', 'Matches per page (max 100; default 25)')
54
+ .addHelpText('after', `
55
+ Examples:
56
+ purvey procurement matches 3f9a2c10-... --pretty
57
+ purvey procurement matches 3f9a2c10-... --page 2 --limit 50 --json`)
58
+ .action(withErrorHandling(async (id, opts, cmd) => {
59
+ const globalOpts = cmd.optsWithGlobals();
60
+ const query = {};
61
+ if (opts.page !== undefined)
62
+ query.page = parsePositiveInt(opts.page, '--page');
63
+ if (opts.limit !== undefined)
64
+ query.limit = parsePositiveInt(opts.limit, '--limit');
65
+ const client = await createParchmentClient('member');
66
+ const result = await client.procurement.briefs.matches(id, query);
67
+ const data = unwrapParchment(result, 'procurement matches');
68
+ outputData(data, globalOpts);
69
+ }));
70
+ return procurement;
71
+ }
72
+ //# sourceMappingURL=procurement.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"procurement.js","sourceRoot":"","sources":["../../src/commands/procurement.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,32 @@
1
1
  import { Command } from 'commander';
2
- import type { RoastProfile, TemperatureEntry, RoastEventEntry } from '../lib/roast.js';
2
+ import type { RoastProfile, TemperatureEntry, RoastEventEntry, ImportRoastResult } from '../lib/roast.js';
3
+ import type { WatchRoastImporter } from '../lib/interactive/watch.js';
4
+ import type { SupabaseClient } from '@supabase/supabase-js';
5
+ import type { components } from '@purveyors/sdk';
6
+ /** Canonical `POST /v1/roasts/imports` response payload. */
7
+ type RoastImportPayload = components['schemas']['RoastImportResponse'];
3
8
  export type { RoastProfile, TemperatureEntry, RoastEventEntry };
9
+ /**
10
+ * Map the canonical Parchment `roasts.import` response onto the CLI's existing
11
+ * {@link ImportRoastResult} output shape, so `--pretty` and machine output stay
12
+ * stable now that Artisan parsing and persistence happen server-side.
13
+ *
14
+ * `RoastDetailResource` surfaces only charge / first-crack / drop as top-level
15
+ * time columns, but the full Artisan milestone set (dry-end, second-crack
16
+ * start/end, cool) is persisted as milestone roast events. Recover those from
17
+ * `roast.events` so JSON and `--pretty` output keep the same milestone timing
18
+ * the pre-SDK local importer exposed, without re-parsing the `.alog` locally.
19
+ */
20
+ export declare function mapSdkImportResult(payload: RoastImportPayload, fallbackCoffeeId: number, fallbackBatchName: string): ImportRoastResult;
21
+ /**
22
+ * Build the roast importer used by `purvey roast watch`. Each import resolves
23
+ * the current member session token at call time (so long-running watch sessions
24
+ * survive token refresh) and forwards the raw `.alog` to the canonical Parchment
25
+ * API via the SDK, which parses and persists the roast server-side. The session
26
+ * JWT is pinned per request so an exported PARCHMENT_API_KEY/PURVEYORS_API_KEY
27
+ * for another account cannot authorize a write against the session user's beans.
28
+ */
29
+ export declare function createWatchRoastImporter(supabase: SupabaseClient): WatchRoastImporter;
4
30
  /**
5
31
  * `purvey roast` — Browse and manage your roast profiles.
6
32
  * Requires member+ authentication.
@@ -1 +1 @@
1
- {"version":3,"file":"roast.d.ts","sourceRoot":"","sources":["../../src/commands/roast.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgBpC,OAAO,KAAK,EACV,YAAY,EACZ,gBAAgB,EAChB,eAAe,EAEhB,MAAM,iBAAiB,CAAC;AAQzB,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,CAAC;AAIhE;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAw6B3C"}
1
+ {"version":3,"file":"roast.d.ts","sourceRoot":"","sources":["../../src/commands/roast.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AASpC,OAAO,KAAK,EACV,YAAY,EACZ,gBAAgB,EAChB,eAAe,EACf,iBAAiB,EAClB,MAAM,iBAAiB,CAAC;AAIzB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AACtE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAO5D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAGjD,4DAA4D;AAC5D,KAAK,kBAAkB,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,qBAAqB,CAAC,CAAC;AAGvE,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,CAAC;AAEhE;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,kBAAkB,EAC3B,gBAAgB,EAAE,MAAM,EACxB,iBAAiB,EAAE,MAAM,GACxB,iBAAiB,CAyDnB;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,cAAc,GAAG,kBAAkB,CAwBrF;AAID;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAy9B3C"}
@@ -3,14 +3,110 @@ import * as p from '@clack/prompts';
3
3
  import { access, readFile } from 'fs/promises';
4
4
  import { basename } from 'path';
5
5
  import { outputData, info, success } from '../lib/output.js';
6
- import { withErrorHandling, PrvrsError } from '../lib/errors.js';
6
+ import { withErrorHandling, PrvrsError, AuthError } from '../lib/errors.js';
7
7
  import { requireAuth } from '../lib/auth-guard.js';
8
8
  import { confirm, todayIso } from '../lib/prompts.js';
9
- import { listRoasts, getRoast, createRoast, deleteRoast, updateRoast, importRoastFromFile, } from '../lib/roast.js';
9
+ import { listRoasts, getRoast, createRoast, deleteRoast, updateRoast } from '../lib/roast.js';
10
10
  import { pickBean, guardCancel } from '../lib/interactive/forms.js';
11
11
  import { normalizePathInput } from '../lib/path-input.js';
12
12
  import { startWatch, loadWatchSession } from '../lib/interactive/watch.js';
13
13
  import { getConfigValue } from '../lib/config.js';
14
+ import { createParchmentClient, resolveParchmentSessionTokenIfAvailable, unwrapParchment, } from '../lib/parchment.js';
15
+ /**
16
+ * Map the canonical Parchment `roasts.import` response onto the CLI's existing
17
+ * {@link ImportRoastResult} output shape, so `--pretty` and machine output stay
18
+ * stable now that Artisan parsing and persistence happen server-side.
19
+ *
20
+ * `RoastDetailResource` surfaces only charge / first-crack / drop as top-level
21
+ * time columns, but the full Artisan milestone set (dry-end, second-crack
22
+ * start/end, cool) is persisted as milestone roast events. Recover those from
23
+ * `roast.events` so JSON and `--pretty` output keep the same milestone timing
24
+ * the pre-SDK local importer exposed, without re-parsing the `.alog` locally.
25
+ */
26
+ export function mapSdkImportResult(payload, fallbackCoffeeId, fallbackBatchName) {
27
+ const roast = payload.data.roast;
28
+ const summary = payload.data.import;
29
+ const milestones = {};
30
+ if (roast.charge_time != null)
31
+ milestones.charge = roast.charge_time;
32
+ if (roast.fc_start_time != null)
33
+ milestones.fc_start = roast.fc_start_time;
34
+ if (roast.fc_end_time != null)
35
+ milestones.fc_end = roast.fc_end_time;
36
+ if (roast.drop_time != null)
37
+ milestones.drop = roast.drop_time;
38
+ // Fill any remaining markers (notably dry_end / sc_start / sc_end / cool,
39
+ // which have no dedicated column) from the canonical milestone events. The
40
+ // top-level columns above win; events only backfill gaps.
41
+ const MILESTONE_KEYS = [
42
+ 'charge',
43
+ 'dry_end',
44
+ 'fc_start',
45
+ 'fc_end',
46
+ 'sc_start',
47
+ 'sc_end',
48
+ 'drop',
49
+ 'cool',
50
+ ];
51
+ for (const event of roast.events ?? []) {
52
+ const key = event.event_string;
53
+ if (key != null &&
54
+ MILESTONE_KEYS.includes(key) &&
55
+ milestones[key] == null &&
56
+ event.time_seconds != null) {
57
+ milestones[key] = event.time_seconds;
58
+ }
59
+ }
60
+ const totalTime = roast.total_roast_time ?? 0;
61
+ const temperatureUnit = roast.temperature_unit === 'C' ? 'C' : 'F';
62
+ return {
63
+ success: true,
64
+ message: `Imported ${summary.temperaturePoints} data points, ${summary.milestoneEvents} milestones, ${summary.controlEvents} control events`,
65
+ milestones,
66
+ phases: {
67
+ drying_percent: roast.dry_percent ?? 0,
68
+ maillard_percent: roast.maillard_percent ?? 0,
69
+ development_percent: roast.development_percent ?? 0,
70
+ total_time_seconds: totalTime,
71
+ },
72
+ total_time: totalTime,
73
+ temperature_unit: temperatureUnit,
74
+ milestone_events: summary.milestoneEvents,
75
+ control_events: summary.controlEvents,
76
+ roast_id: roast.roast_id,
77
+ batch_name: roast.batch_name ?? fallbackBatchName,
78
+ coffee_name: roast.coffee_name ?? '',
79
+ coffee_id: roast.coffee_id ?? fallbackCoffeeId,
80
+ };
81
+ }
82
+ /**
83
+ * Build the roast importer used by `purvey roast watch`. Each import resolves
84
+ * the current member session token at call time (so long-running watch sessions
85
+ * survive token refresh) and forwards the raw `.alog` to the canonical Parchment
86
+ * API via the SDK, which parses and persists the roast server-side. The session
87
+ * JWT is pinned per request so an exported PARCHMENT_API_KEY/PURVEYORS_API_KEY
88
+ * for another account cannot authorize a write against the session user's beans.
89
+ */
90
+ export function createWatchRoastImporter(supabase) {
91
+ return async (args) => {
92
+ const { data: { session }, } = await supabase.auth.getSession();
93
+ if (!session?.access_token) {
94
+ throw new AuthError('Session expired mid-watch. Run `purvey auth login` and retry.');
95
+ }
96
+ const client = await createParchmentClient('member', session.access_token);
97
+ const payload = unwrapParchment(await client.roasts.import({
98
+ fileContent: args.fileContent,
99
+ fileName: args.fileName,
100
+ coffeeId: args.coffeeId,
101
+ batchName: args.batchName,
102
+ ozIn: args.ozIn,
103
+ roastNotes: args.roastNotes,
104
+ roastTargets: args.roastTargets,
105
+ fileSize: Buffer.byteLength(args.fileContent, 'utf-8'),
106
+ }), 'roast import');
107
+ return mapSdkImportResult(payload, args.coffeeId, args.batchName);
108
+ };
109
+ }
14
110
  // ─── Command builder ──────────────────────────────────────────────────────────
15
111
  /**
16
112
  * `purvey roast` — Browse and manage your roast profiles.
@@ -433,7 +529,19 @@ Required: <file> path and --coffee-id (unless using --form)
433
529
  const targetsStr = String(roastTargetsRaw).trim();
434
530
  const spin = p.spinner();
435
531
  spin.start('Importing roast data...');
436
- const result = await importRoastFromFile(supabase, userId, {
532
+ // Parse + persist happen server-side via the canonical Parchment
533
+ // API. `bean` was resolved through the authenticated Supabase
534
+ // session above (pickBean), so the import must run under that same
535
+ // session identity. Pin the request to the session access token
536
+ // instead of letting an exported PARCHMENT_API_KEY/PURVEYORS_API_KEY
537
+ // for another account authorize an import against a coffeeId owned
538
+ // by the session user.
539
+ const { data: { session: formSession }, } = await supabase.auth.getSession();
540
+ if (!formSession?.access_token) {
541
+ throw new AuthError('Session expired mid-import. Run `purvey auth login` and retry.');
542
+ }
543
+ const client = await createParchmentClient('member', formSession.access_token);
544
+ const payload = unwrapParchment(await client.roasts.import({
437
545
  fileContent,
438
546
  fileName,
439
547
  coffeeId: bean.id,
@@ -441,7 +549,9 @@ Required: <file> path and --coffee-id (unless using --form)
441
549
  ozIn,
442
550
  roastNotes: notesStr !== '' ? notesStr : undefined,
443
551
  roastTargets: targetsStr !== '' ? targetsStr : undefined,
444
- });
552
+ fileSize: Buffer.byteLength(fileContent, 'utf-8'),
553
+ }), 'roast import');
554
+ const result = mapSdkImportResult(payload, bean.id, batchName);
445
555
  spin.stop('Done');
446
556
  p.outro(`Roast imported! Profile #${result.roast_id} created.`);
447
557
  outputData(result, globalOpts);
@@ -462,9 +572,7 @@ Required: <file> path and --coffee-id (unless using --form)
462
572
  // 2. Read file content
463
573
  const fileContent = await readFile(filePath, 'utf-8');
464
574
  const fileName = basename(filePath);
465
- // 3. Authenticate
466
- const { supabase, userId } = await requireAuth('member');
467
- // 4. Parse --coffee-id
575
+ // 3. Parse --coffee-id
468
576
  if (!opts.coffeeId) {
469
577
  throw new PrvrsError('INVALID_ARGUMENT', 'Missing --coffee-id. Use --form for interactive mode.');
470
578
  }
@@ -472,7 +580,7 @@ Required: <file> path and --coffee-id (unless using --form)
472
580
  if (isNaN(coffeeId) || coffeeId <= 0) {
473
581
  throw new PrvrsError('INVALID_ARGUMENT', `Invalid --coffee-id: "${opts.coffeeId}".`);
474
582
  }
475
- // 5. Parse --oz-in if provided
583
+ // 4. Parse --oz-in if provided
476
584
  let ozIn;
477
585
  if (opts.ozIn !== undefined) {
478
586
  ozIn = parseFloat(opts.ozIn);
@@ -483,8 +591,17 @@ Required: <file> path and --coffee-id (unless using --form)
483
591
  const roastTargets = typeof opts.roastTargets === 'string' && opts.roastTargets.trim() !== ''
484
592
  ? opts.roastTargets.trim()
485
593
  : undefined;
486
- // 6. Run the import
487
- const result = await importRoastFromFile(supabase, userId, {
594
+ // 5. Run the import via the canonical Parchment API. The server
595
+ // parses the raw .alog, creates the roast, and persists the curve +
596
+ // events atomically; identity is resolved from the bearer token
597
+ // (session JWT or owner-bound API key), so there is no local write.
598
+ // Prefer an existing logged-in member session for flag mode too:
599
+ // users often copy --coffee-id from session-scoped inventory, and an
600
+ // exported API key for another account must not steal that request.
601
+ // If no valid session exists, the API-key-only path still works.
602
+ const sessionToken = await resolveParchmentSessionTokenIfAvailable('member');
603
+ const client = await createParchmentClient('member', sessionToken);
604
+ const payload = unwrapParchment(await client.roasts.import({
488
605
  fileContent,
489
606
  fileName,
490
607
  coffeeId,
@@ -492,8 +609,10 @@ Required: <file> path and --coffee-id (unless using --form)
492
609
  ozIn,
493
610
  roastNotes: opts.roastNotes,
494
611
  roastTargets,
495
- });
496
- // 7. Output
612
+ fileSize: Buffer.byteLength(fileContent, 'utf-8'),
613
+ }), 'roast import');
614
+ const result = mapSdkImportResult(payload, coffeeId, opts.batchName ?? '');
615
+ // 6. Output
497
616
  if (globalOpts.pretty) {
498
617
  printImportPretty(result, coffeeId);
499
618
  }
@@ -537,6 +656,7 @@ Notes:
537
656
  `)
538
657
  .action(withErrorHandling(async (directory, opts) => {
539
658
  const { supabase, userId } = await requireAuth('member');
659
+ const roastImporter = createWatchRoastImporter(supabase);
540
660
  const parseCommitMode = (value, fallback = 'batch') => {
541
661
  if (value === undefined || value === null || String(value).trim() === '') {
542
662
  return fallback;
@@ -575,7 +695,7 @@ Notes:
575
695
  ozIn: saved.ozIn,
576
696
  roastNotes: saved.roastNotes,
577
697
  roastTargets: saved.roastTargets,
578
- });
698
+ }, { roastImporter });
579
699
  return;
580
700
  }
581
701
  // ── Interactive form mode ────────────────────────────────────────────
@@ -690,7 +810,7 @@ Notes:
690
810
  ozIn: parseOptionalPositiveNumber(ozInRaw, 'green weight'),
691
811
  roastTargets: String(roastTargetsRaw).trim() !== '' ? String(roastTargetsRaw).trim() : undefined,
692
812
  roastNotes: String(roastNotesRaw).trim() !== '' ? String(roastNotesRaw).trim() : undefined,
693
- });
813
+ }, { roastImporter });
694
814
  return;
695
815
  }
696
816
  // ── Flag-based mode ──────────────────────────────────────────────────
@@ -751,7 +871,7 @@ Notes:
751
871
  ozIn,
752
872
  roastNotes,
753
873
  roastTargets,
754
- });
874
+ }, { roastImporter });
755
875
  }));
756
876
  return roast;
757
877
  }
@@ -781,6 +901,7 @@ function printImportPretty(result, coffeeId) {
781
901
  fc_start: 'FC Start',
782
902
  fc_end: 'FC End',
783
903
  sc_start: 'SC Start',
904
+ sc_end: 'SC End',
784
905
  drop: 'Drop',
785
906
  cool: 'Cool',
786
907
  };