@purveyors/cli 0.2.1 → 0.4.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 (95) hide show
  1. package/dist/commands/catalog.d.ts +4 -56
  2. package/dist/commands/catalog.d.ts.map +1 -1
  3. package/dist/commands/catalog.js +14 -81
  4. package/dist/commands/catalog.js.map +1 -1
  5. package/dist/commands/config.d.ts +7 -0
  6. package/dist/commands/config.d.ts.map +1 -0
  7. package/dist/commands/config.js +82 -0
  8. package/dist/commands/config.js.map +1 -0
  9. package/dist/commands/inventory.d.ts +2 -25
  10. package/dist/commands/inventory.d.ts.map +1 -1
  11. package/dist/commands/inventory.js +109 -149
  12. package/dist/commands/inventory.js.map +1 -1
  13. package/dist/commands/roast.d.ts +2 -41
  14. package/dist/commands/roast.d.ts.map +1 -1
  15. package/dist/commands/roast.js +445 -126
  16. package/dist/commands/roast.js.map +1 -1
  17. package/dist/commands/sales.d.ts +2 -10
  18. package/dist/commands/sales.d.ts.map +1 -1
  19. package/dist/commands/sales.js +100 -112
  20. package/dist/commands/sales.js.map +1 -1
  21. package/dist/commands/tasting.d.ts +4 -38
  22. package/dist/commands/tasting.d.ts.map +1 -1
  23. package/dist/commands/tasting.js +93 -116
  24. package/dist/commands/tasting.js.map +1 -1
  25. package/dist/index.js +8 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib/ai.d.ts +36 -0
  28. package/dist/lib/ai.d.ts.map +1 -0
  29. package/dist/lib/ai.js +38 -0
  30. package/dist/lib/ai.js.map +1 -0
  31. package/dist/lib/artisan/db.d.ts +37 -0
  32. package/dist/lib/artisan/db.d.ts.map +1 -0
  33. package/dist/lib/artisan/db.js +51 -0
  34. package/dist/lib/artisan/db.js.map +1 -0
  35. package/dist/lib/artisan/import.d.ts +16 -0
  36. package/dist/lib/artisan/import.d.ts.map +1 -0
  37. package/dist/lib/artisan/import.js +447 -0
  38. package/dist/lib/artisan/import.js.map +1 -0
  39. package/dist/lib/artisan/index.d.ts +9 -0
  40. package/dist/lib/artisan/index.d.ts.map +1 -0
  41. package/dist/lib/artisan/index.js +7 -0
  42. package/dist/lib/artisan/index.js.map +1 -0
  43. package/dist/lib/artisan/parser.d.ts +19 -0
  44. package/dist/lib/artisan/parser.d.ts.map +1 -0
  45. package/dist/lib/artisan/parser.js +376 -0
  46. package/dist/lib/artisan/parser.js.map +1 -0
  47. package/dist/lib/artisan/temperature.d.ts +52 -0
  48. package/dist/lib/artisan/temperature.d.ts.map +1 -0
  49. package/dist/lib/artisan/temperature.js +101 -0
  50. package/dist/lib/artisan/temperature.js.map +1 -0
  51. package/dist/lib/artisan/types.d.ts +195 -0
  52. package/dist/lib/artisan/types.d.ts.map +1 -0
  53. package/dist/lib/artisan/types.js +35 -0
  54. package/dist/lib/artisan/types.js.map +1 -0
  55. package/dist/lib/artisan/validator.d.ts +14 -0
  56. package/dist/lib/artisan/validator.d.ts.map +1 -0
  57. package/dist/lib/artisan/validator.js +228 -0
  58. package/dist/lib/artisan/validator.js.map +1 -0
  59. package/dist/lib/catalog.d.ts +87 -0
  60. package/dist/lib/catalog.d.ts.map +1 -0
  61. package/dist/lib/catalog.js +111 -0
  62. package/dist/lib/catalog.js.map +1 -0
  63. package/dist/lib/config.d.ts +26 -0
  64. package/dist/lib/config.d.ts.map +1 -1
  65. package/dist/lib/config.js +59 -0
  66. package/dist/lib/config.js.map +1 -1
  67. package/dist/lib/index.d.ts +6 -0
  68. package/dist/lib/index.d.ts.map +1 -0
  69. package/dist/lib/index.js +11 -0
  70. package/dist/lib/index.js.map +1 -0
  71. package/dist/lib/interactive/forms.d.ts +33 -0
  72. package/dist/lib/interactive/forms.d.ts.map +1 -0
  73. package/dist/lib/interactive/forms.js +139 -0
  74. package/dist/lib/interactive/forms.js.map +1 -0
  75. package/dist/lib/interactive/watch.d.ts +66 -0
  76. package/dist/lib/interactive/watch.d.ts.map +1 -0
  77. package/dist/lib/interactive/watch.js +494 -0
  78. package/dist/lib/interactive/watch.js.map +1 -0
  79. package/dist/lib/inventory.d.ts +80 -0
  80. package/dist/lib/inventory.d.ts.map +1 -0
  81. package/dist/lib/inventory.js +205 -0
  82. package/dist/lib/inventory.js.map +1 -0
  83. package/dist/lib/roast.d.ts +127 -0
  84. package/dist/lib/roast.d.ts.map +1 -0
  85. package/dist/lib/roast.js +284 -0
  86. package/dist/lib/roast.js.map +1 -0
  87. package/dist/lib/sales.d.ts +53 -0
  88. package/dist/lib/sales.d.ts.map +1 -0
  89. package/dist/lib/sales.js +155 -0
  90. package/dist/lib/sales.js.map +1 -0
  91. package/dist/lib/tasting.d.ts +76 -0
  92. package/dist/lib/tasting.d.ts.map +1 -0
  93. package/dist/lib/tasting.js +136 -0
  94. package/dist/lib/tasting.js.map +1 -0
  95. package/package.json +15 -2
@@ -1,8 +1,15 @@
1
1
  import { Command } from 'commander';
2
+ import * as p from '@clack/prompts';
3
+ import { access, readFile } from 'fs/promises';
4
+ import { basename } from 'path';
2
5
  import { createAuthenticatedClient } from '../lib/supabase.js';
3
6
  import { outputData, info, success } from '../lib/output.js';
4
7
  import { withErrorHandling, AuthError, PrvrsError } from '../lib/errors.js';
5
8
  import { confirm, todayIso } from '../lib/prompts.js';
9
+ import { listRoasts, getRoast, createRoast, deleteRoast, importRoastFromFile, } from '../lib/roast.js';
10
+ import { pickBean, guardCancel } from '../lib/interactive/forms.js';
11
+ import { startWatch, loadWatchSession } from '../lib/interactive/watch.js';
12
+ import { getConfigValue } from '../lib/config.js';
6
13
  // ─── Command builder ──────────────────────────────────────────────────────────
7
14
  /**
8
15
  * `purvey roast` — Browse and manage your roast profiles.
@@ -23,18 +30,11 @@ export function buildRoastCommand() {
23
30
  if (!user) {
24
31
  throw new AuthError('Not logged in. Run `purvey auth login` first.');
25
32
  }
26
- let query = supabase
27
- .from('roast_profiles')
28
- .select('roast_id, batch_name, coffee_id, coffee_name, roast_date, oz_in, oz_out, weight_loss_percent, roast_notes, roaster_type, roaster_size, temperature_unit, total_roast_time, development_percent, data_source, last_updated')
29
- .eq('user', user.id);
30
- if (opts.coffeeId !== undefined) {
31
- query = query.eq('coffee_id', parseInt(opts.coffeeId, 10));
32
- }
33
- const limit = Math.max(1, parseInt(opts.limit, 10));
34
- const { data, error } = await query.order('roast_date', { ascending: false }).limit(limit);
35
- if (error)
36
- throw error;
37
- if (!data || data.length === 0) {
33
+ const data = await listRoasts(supabase, user.id, {
34
+ coffeeId: opts.coffeeId !== undefined ? parseInt(opts.coffeeId, 10) : undefined,
35
+ limit: Math.max(1, parseInt(opts.limit, 10)),
36
+ });
37
+ if (data.length === 0) {
38
38
  info('No roast profiles found.');
39
39
  return;
40
40
  }
@@ -53,55 +53,23 @@ export function buildRoastCommand() {
53
53
  if (!user) {
54
54
  throw new AuthError('Not logged in. Run `purvey auth login` first.');
55
55
  }
56
- const roastId = parseInt(id, 10);
57
- // Fetch the profile, verifying ownership
58
- const { data: profile, error: profileError } = await supabase
59
- .from('roast_profiles')
60
- .select('*')
61
- .eq('roast_id', roastId)
62
- .eq('user', user.id)
63
- .single();
64
- if (profileError) {
65
- if (profileError.code === 'PGRST116') {
66
- throw new AuthError(`Roast profile ${id} not found or does not belong to you.`);
67
- }
68
- throw profileError;
69
- }
70
- const result = { ...profile };
71
- // Optionally fetch temperature curve
72
- if (opts.includeTemps) {
73
- const { data: temps, error: tempError } = await supabase
74
- .from('roast_temperatures')
75
- .select('roast_id, time_seconds, bean_temp, environmental_temp')
76
- .eq('roast_id', roastId)
77
- .order('time_seconds', { ascending: true });
78
- if (tempError)
79
- throw tempError;
80
- result.temperatures = temps ?? [];
81
- }
82
- // Optionally fetch roast events
83
- if (opts.includeEvents) {
84
- const { data: events, error: eventsError } = await supabase
85
- .from('roast_events')
86
- .select('roast_id, time_seconds, event_type, event_value')
87
- .eq('roast_id', roastId)
88
- .order('time_seconds', { ascending: true });
89
- if (eventsError)
90
- throw eventsError;
91
- result.events = events ?? [];
92
- }
93
- outputData(result, globalOpts);
56
+ const data = await getRoast(supabase, user.id, parseInt(id, 10), {
57
+ includeTemps: Boolean(opts.includeTemps),
58
+ includeEvents: Boolean(opts.includeEvents),
59
+ });
60
+ outputData(data, globalOpts);
94
61
  }));
95
62
  // ── roast create ──────────────────────────────────────────────────────────
96
63
  roast
97
64
  .command('create')
98
65
  .description('Create a new roast profile')
99
- .requiredOption('--coffee-id <id>', 'green_coffee_inv ID for this roast')
66
+ .option('--coffee-id <id>', 'green_coffee_inv ID for this roast')
100
67
  .option('--batch-name <name>', "Batch name (defaults to coffee name + today's date)")
101
68
  .option('--oz-in <oz>', 'Green weight in ounces')
102
69
  .option('--oz-out <oz>', 'Roasted weight in ounces')
103
70
  .option('--roast-date <YYYY-MM-DD>', 'Roast date (defaults to today)')
104
71
  .option('--notes <text>', 'Roast notes')
72
+ .option('--form', 'Interactive form mode')
105
73
  .action(withErrorHandling(async (opts, cmd) => {
106
74
  const globalOpts = cmd.optsWithGlobals();
107
75
  const supabase = await createAuthenticatedClient();
@@ -109,66 +77,95 @@ export function buildRoastCommand() {
109
77
  if (!user) {
110
78
  throw new AuthError('Not logged in. Run `purvey auth login` first.');
111
79
  }
80
+ // ── Interactive form mode ──────────────────────────────────────────
81
+ // Auto-enter form mode if config form-mode is true and required args are missing
82
+ const formMode = opts.form ||
83
+ (!(opts.coffeeId && opts.roastDate) && (await getConfigValue('form-mode')) === 'true');
84
+ if (formMode) {
85
+ p.intro('Create Roast Profile');
86
+ const bean = await pickBean(supabase, user.id);
87
+ const today = todayIso();
88
+ const defaultBatch = `${bean.name} ${today}`;
89
+ const batchNameRaw = await p.text({
90
+ message: 'Batch name',
91
+ placeholder: defaultBatch,
92
+ defaultValue: defaultBatch,
93
+ });
94
+ guardCancel(batchNameRaw);
95
+ const ozInRaw = await p.text({
96
+ message: 'Weight in (oz)',
97
+ placeholder: 'optional',
98
+ validate: (v) => {
99
+ if (!v || v.trim() === '')
100
+ return;
101
+ const n = parseFloat(v);
102
+ if (isNaN(n) || n <= 0)
103
+ return 'Must be a positive number.';
104
+ },
105
+ });
106
+ guardCancel(ozInRaw);
107
+ const notesRaw = await p.text({
108
+ message: 'Roast notes',
109
+ placeholder: 'optional',
110
+ });
111
+ guardCancel(notesRaw);
112
+ const targetsRaw = await p.text({
113
+ message: 'Roast targets',
114
+ placeholder: 'optional',
115
+ });
116
+ guardCancel(targetsRaw);
117
+ const confirmed = await p.confirm({ message: 'Create this roast?' });
118
+ guardCancel(confirmed);
119
+ if (!confirmed) {
120
+ p.cancel('Aborted.');
121
+ return;
122
+ }
123
+ const ozInStr = String(ozInRaw).trim();
124
+ const ozIn = ozInStr !== '' ? parseFloat(ozInStr) : undefined;
125
+ const notesStr = String(notesRaw).trim();
126
+ const targetsStr = String(targetsRaw).trim();
127
+ const combinedNotes = [notesStr, targetsStr ? `Targets: ${targetsStr}` : ''].filter(Boolean).join('\n') ||
128
+ undefined;
129
+ const data = await createRoast(supabase, user.id, {
130
+ coffeeId: bean.id,
131
+ batchName: String(batchNameRaw).trim() || defaultBatch,
132
+ ozIn,
133
+ roastDate: today,
134
+ notes: combinedNotes,
135
+ });
136
+ p.outro(`Roast profile created! Roast #${data.roast_id}.`);
137
+ outputData(data, globalOpts);
138
+ return;
139
+ }
140
+ // ── Flag-based mode ────────────────────────────────────────────────
141
+ if (!opts.coffeeId) {
142
+ throw new PrvrsError('INVALID_ARGUMENT', 'Missing --coffee-id. Use --form for interactive mode.');
143
+ }
112
144
  const coffeeId = parseInt(opts.coffeeId, 10);
113
145
  if (isNaN(coffeeId)) {
114
146
  throw new PrvrsError('INVALID_ARGUMENT', `Invalid --coffee-id: "${opts.coffeeId}".`);
115
147
  }
116
- // Verify ownership of the inventory item and get coffee name for default batch name
117
- const { data: invItem, error: invError } = await supabase
118
- .from('green_coffee_inv')
119
- .select('id, coffee_catalog!catalog_id (name)')
120
- .eq('id', coffeeId)
121
- .eq('user', user.id)
122
- .single();
123
- if (invError || !invItem) {
124
- throw new AuthError(`Inventory item ${coffeeId} not found or does not belong to you.`);
125
- }
126
- const roastDate = opts.roastDate ?? todayIso();
127
- // Default batch name: coffee name + roast date
128
- let batchName = opts.batchName;
129
- if (!batchName) {
130
- const catalogRaw = invItem.coffee_catalog;
131
- const catalog = Array.isArray(catalogRaw) ? (catalogRaw[0] ?? null) : catalogRaw;
132
- const coffeeName = catalog?.name ?? `Coffee #${coffeeId}`;
133
- batchName = `${coffeeName} — ${roastDate}`;
134
- }
135
- const insertPayload = {
136
- user: user.id,
137
- coffee_id: coffeeId,
138
- batch_name: batchName,
139
- roast_date: roastDate,
140
- };
148
+ let ozIn;
141
149
  if (opts.ozIn !== undefined) {
142
- const ozIn = parseFloat(opts.ozIn);
150
+ ozIn = parseFloat(opts.ozIn);
143
151
  if (isNaN(ozIn) || ozIn <= 0)
144
152
  throw new PrvrsError('INVALID_ARGUMENT', `Invalid --oz-in: "${opts.ozIn}".`);
145
- insertPayload.oz_in = ozIn;
146
153
  }
154
+ let ozOut;
147
155
  if (opts.ozOut !== undefined) {
148
- const ozOut = parseFloat(opts.ozOut);
156
+ ozOut = parseFloat(opts.ozOut);
149
157
  if (isNaN(ozOut) || ozOut <= 0)
150
158
  throw new PrvrsError('INVALID_ARGUMENT', `Invalid --oz-out: "${opts.ozOut}".`);
151
- insertPayload.oz_out = ozOut;
152
- }
153
- if (opts.notes !== undefined) {
154
- insertPayload.roast_notes = opts.notes;
155
- }
156
- const { data: inserted, error: insertError } = await supabase
157
- .from('roast_profiles')
158
- .insert(insertPayload)
159
- .select('roast_id')
160
- .single();
161
- if (insertError)
162
- throw insertError;
163
- // Re-fetch the full row
164
- const { data, error } = await supabase
165
- .from('roast_profiles')
166
- .select('roast_id, batch_name, coffee_id, coffee_name, roast_date, oz_in, oz_out, weight_loss_percent, roast_notes, roaster_type, total_roast_time, data_source, last_updated')
167
- .eq('roast_id', inserted.roast_id)
168
- .single();
169
- if (error)
170
- throw error;
171
- success(`Roast profile ${inserted.roast_id} created.`);
159
+ }
160
+ const data = await createRoast(supabase, user.id, {
161
+ coffeeId,
162
+ batchName: opts.batchName,
163
+ ozIn,
164
+ ozOut,
165
+ roastDate: opts.roastDate ?? todayIso(),
166
+ notes: opts.notes,
167
+ });
168
+ success(`Roast profile ${data.roast_id} created.`);
172
169
  outputData(data, globalOpts);
173
170
  }));
174
171
  // ── roast delete <id> ─────────────────────────────────────────────────────
@@ -177,7 +174,7 @@ export function buildRoastCommand() {
177
174
  .description('Delete a roast profile (must be yours)')
178
175
  .option('-y, --yes', 'Skip confirmation prompt')
179
176
  .action(withErrorHandling(async (id, opts, cmd) => {
180
- void cmd; // global opts not needed for delete
177
+ void cmd;
181
178
  const supabase = await createAuthenticatedClient();
182
179
  const { data: { user }, } = await supabase.auth.getUser();
183
180
  if (!user) {
@@ -187,39 +184,361 @@ export function buildRoastCommand() {
187
184
  if (isNaN(roastId)) {
188
185
  throw new PrvrsError('INVALID_ARGUMENT', `Invalid roast ID: "${id}".`);
189
186
  }
190
- // Verify ownership
191
- const { data: existing, error: fetchError } = await supabase
192
- .from('roast_profiles')
193
- .select('roast_id, batch_name')
194
- .eq('roast_id', roastId)
195
- .eq('user', user.id)
196
- .single();
197
- if (fetchError || !existing) {
198
- throw new AuthError(`Roast profile ${id} not found or does not belong to you.`);
199
- }
200
187
  if (!opts.yes) {
201
- const label = existing.batch_name ? `"${existing.batch_name}"` : `#${roastId}`;
202
- const ok = await confirm(`Delete roast profile ${label}?`);
188
+ const ok = await confirm(`Delete roast profile #${roastId}?`);
203
189
  if (!ok) {
204
190
  info('Aborted.');
205
191
  return;
206
192
  }
207
193
  }
208
- const { error: deleteError } = await supabase
209
- .from('roast_profiles')
210
- .delete()
211
- .eq('roast_id', roastId)
212
- .eq('user', user.id);
213
- if (deleteError)
214
- throw deleteError;
194
+ await deleteRoast(supabase, user.id, roastId);
215
195
  success(`Roast profile ${roastId} deleted.`);
216
196
  }));
217
- // ── roast import-artisan <id> <file.alog> ─────────────────────────────────
218
- // TODO (Phase 3): Wire up artisan import.
219
- // The .alog parser runs server-side on the SvelteKit API. This command needs
220
- // to POST the file content to the app's /api/artisan-import endpoint, which
221
- // requires the SvelteKit server URL to be configurable (not just Supabase URL).
222
- // Tracked for Phase 3 implementation.
197
+ // ── roast import <file> ───────────────────────────────────────────────────
198
+ roast
199
+ .command('import')
200
+ .description('Import an Artisan .alog file and create a new roast profile')
201
+ .argument('[file]', 'Path to .alog file (or use --form for interactive mode)')
202
+ .option('--coffee-id <id>', 'green_coffee_inv ID for this roast')
203
+ .option('--batch-name <name>', 'Batch name (auto-generated from coffee name + date if omitted)')
204
+ .option('--oz-in <oz>', 'Green weight in ounces (extracted from .alog if omitted)')
205
+ .option('--roast-notes <notes>', 'Additional roast notes')
206
+ .option('--form', 'Interactive form mode')
207
+ .action(withErrorHandling(async (file, opts, cmd) => {
208
+ const globalOpts = cmd.optsWithGlobals();
209
+ // ── Interactive form mode ────────────────────────────────────────
210
+ // Auto-enter form mode if config form-mode is true and required args are missing
211
+ const importFormMode = opts.form || (!file && (await getConfigValue('form-mode')) === 'true');
212
+ if (importFormMode) {
213
+ p.intro('Import Artisan Roast');
214
+ const filePathRaw = await p.text({
215
+ message: 'Path to .alog file',
216
+ placeholder: '/path/to/roast.alog',
217
+ validate: (v) => {
218
+ if (!v || String(v).trim() === '')
219
+ return 'Please enter a file path.';
220
+ },
221
+ });
222
+ guardCancel(filePathRaw);
223
+ const filePath = String(filePathRaw).trim();
224
+ // Validate file exists (async check after prompt)
225
+ try {
226
+ await access(filePath);
227
+ }
228
+ catch {
229
+ p.cancel(`File not found: "${filePath}"`);
230
+ process.exit(1);
231
+ }
232
+ // Authenticate
233
+ const supabase = await createAuthenticatedClient();
234
+ const { data: { user }, } = await supabase.auth.getUser();
235
+ if (!user) {
236
+ throw new AuthError('Not logged in. Run `purvey auth login` first.');
237
+ }
238
+ const bean = await pickBean(supabase, user.id);
239
+ const today = todayIso();
240
+ const defaultBatch = `${bean.name} ${today}`;
241
+ const batchNameRaw = await p.text({
242
+ message: 'Batch name',
243
+ placeholder: defaultBatch,
244
+ defaultValue: defaultBatch,
245
+ });
246
+ guardCancel(batchNameRaw);
247
+ const ozInRaw = await p.text({
248
+ message: 'Weight in (oz)',
249
+ placeholder: 'optional — extracted from .alog if omitted',
250
+ validate: (v) => {
251
+ if (!v || v.trim() === '')
252
+ return;
253
+ const n = parseFloat(v);
254
+ if (isNaN(n) || n <= 0)
255
+ return 'Must be a positive number.';
256
+ },
257
+ });
258
+ guardCancel(ozInRaw);
259
+ const roastNotesRaw = await p.text({
260
+ message: 'Roast notes',
261
+ placeholder: 'optional',
262
+ });
263
+ guardCancel(roastNotesRaw);
264
+ const confirmed = await p.confirm({ message: 'Import this roast?' });
265
+ guardCancel(confirmed);
266
+ if (!confirmed) {
267
+ p.cancel('Aborted.');
268
+ return;
269
+ }
270
+ const fileContent = await readFile(filePath, 'utf-8');
271
+ const fileName = basename(filePath);
272
+ const ozInStr = String(ozInRaw).trim();
273
+ const ozIn = ozInStr !== '' ? parseFloat(ozInStr) : undefined;
274
+ const batchName = String(batchNameRaw).trim() || defaultBatch;
275
+ const notesStr = String(roastNotesRaw).trim();
276
+ const result = await importRoastFromFile(supabase, user.id, {
277
+ fileContent,
278
+ fileName,
279
+ coffeeId: bean.id,
280
+ batchName,
281
+ ozIn,
282
+ roastNotes: notesStr !== '' ? notesStr : undefined,
283
+ });
284
+ p.outro(`Roast imported! Profile #${result.roast_id} created.`);
285
+ outputData(result, globalOpts);
286
+ return;
287
+ }
288
+ // ── Flag-based mode ──────────────────────────────────────────────
289
+ if (!file) {
290
+ throw new PrvrsError('INVALID_ARGUMENT', 'Missing file argument. Use --form for interactive mode.');
291
+ }
292
+ // 1. Validate file exists and is readable
293
+ try {
294
+ await access(file);
295
+ }
296
+ catch {
297
+ throw new PrvrsError('INVALID_ARGUMENT', `File not found or not readable: "${file}"`);
298
+ }
299
+ // 2. Read file content
300
+ const fileContent = await readFile(file, 'utf-8');
301
+ const fileName = basename(file);
302
+ // 3. Authenticate
303
+ const supabase = await createAuthenticatedClient();
304
+ const { data: { user }, } = await supabase.auth.getUser();
305
+ if (!user) {
306
+ throw new AuthError('Not logged in. Run `purvey auth login` first.');
307
+ }
308
+ // 4. Parse --coffee-id
309
+ if (!opts.coffeeId) {
310
+ throw new PrvrsError('INVALID_ARGUMENT', 'Missing --coffee-id. Use --form for interactive mode.');
311
+ }
312
+ const coffeeId = parseInt(opts.coffeeId, 10);
313
+ if (isNaN(coffeeId) || coffeeId <= 0) {
314
+ throw new PrvrsError('INVALID_ARGUMENT', `Invalid --coffee-id: "${opts.coffeeId}".`);
315
+ }
316
+ // 5. Parse --oz-in if provided
317
+ let ozIn;
318
+ if (opts.ozIn !== undefined) {
319
+ ozIn = parseFloat(opts.ozIn);
320
+ if (isNaN(ozIn) || ozIn <= 0) {
321
+ throw new PrvrsError('INVALID_ARGUMENT', `Invalid --oz-in: "${opts.ozIn}".`);
322
+ }
323
+ }
324
+ // 6. Run the import
325
+ const result = await importRoastFromFile(supabase, user.id, {
326
+ fileContent,
327
+ fileName,
328
+ coffeeId,
329
+ batchName: opts.batchName,
330
+ ozIn,
331
+ roastNotes: opts.roastNotes,
332
+ });
333
+ // 7. Output
334
+ if (globalOpts.pretty) {
335
+ printImportPretty(result, coffeeId);
336
+ }
337
+ else {
338
+ success(`Roast profile ${result.roast_id} imported from ${fileName}.`);
339
+ outputData(result, globalOpts);
340
+ }
341
+ }));
342
+ // ── roast watch <directory> ───────────────────────────────────────────────
343
+ roast
344
+ .command('watch')
345
+ .description('Watch a directory for new .alog files and auto-import them')
346
+ .argument('[directory]', 'Directory to watch for .alog files')
347
+ .option('--coffee-id <id>', 'green_coffee_inv ID for all imports')
348
+ .option('--batch-prefix <name>', 'Batch name prefix (defaults to coffee name)')
349
+ .option('--prompt-each', 'Prompt for bean selection on each new file')
350
+ .option('--auto-match', 'Use AI to auto-match beans (requires member role; no --coffee-id needed)')
351
+ .option('--resume', 'Resume a previous watch session')
352
+ .option('--form', 'Interactive form mode')
353
+ .action(withErrorHandling(async (directory, opts) => {
354
+ const supabase = await createAuthenticatedClient();
355
+ const { data: { user }, } = await supabase.auth.getUser();
356
+ if (!user) {
357
+ throw new AuthError('Not logged in. Run `purvey auth login` first.');
358
+ }
359
+ // ── Resume mode ──────────────────────────────────────────────────────
360
+ if (opts.resume) {
361
+ const saved = await loadWatchSession();
362
+ if (!saved) {
363
+ throw new PrvrsError('NOT_FOUND', 'No watch session to resume. Start a new one with: purvey roast watch <dir> --coffee-id <id>');
364
+ }
365
+ await startWatch(supabase, user.id, saved.directory, {
366
+ coffeeId: saved.coffeeId,
367
+ coffeeName: saved.coffeeName,
368
+ batchPrefix: saved.batchPrefix,
369
+ startSequence: saved.imports.length,
370
+ });
371
+ return;
372
+ }
373
+ // ── Interactive form mode ────────────────────────────────────────────
374
+ if (opts.form) {
375
+ p.intro('Watch for Artisan Roasts');
376
+ const dirRaw = await p.text({
377
+ message: 'Directory to watch',
378
+ placeholder: '/path/to/alog/directory',
379
+ validate: (v) => {
380
+ if (!v || String(v).trim() === '')
381
+ return 'Please enter a directory path.';
382
+ },
383
+ });
384
+ guardCancel(dirRaw);
385
+ const watchDir = String(dirRaw).trim();
386
+ try {
387
+ await access(watchDir);
388
+ }
389
+ catch {
390
+ p.cancel(`Directory not found: "${watchDir}"`);
391
+ process.exit(1);
392
+ }
393
+ // Ask whether to use AI auto-match
394
+ const useAutoMatchRaw = await p.confirm({
395
+ message: 'Use AI to auto-match beans? (requires member role)',
396
+ initialValue: false,
397
+ });
398
+ guardCancel(useAutoMatchRaw);
399
+ const useAutoMatch = Boolean(useAutoMatchRaw);
400
+ let batchPrefix;
401
+ let watchCoffeeId;
402
+ let watchCoffeeName;
403
+ if (useAutoMatch) {
404
+ // In auto-match mode we don't need a bean upfront; use placeholder values
405
+ watchCoffeeId = 0;
406
+ watchCoffeeName = 'auto-match';
407
+ const batchPrefixRaw = await p.text({
408
+ message: 'Batch prefix',
409
+ placeholder: 'Roast',
410
+ defaultValue: 'Roast',
411
+ });
412
+ guardCancel(batchPrefixRaw);
413
+ batchPrefix = String(batchPrefixRaw).trim() || 'Roast';
414
+ }
415
+ else {
416
+ const bean = await pickBean(supabase, user.id);
417
+ watchCoffeeId = bean.id;
418
+ watchCoffeeName = bean.name;
419
+ const batchPrefixRaw = await p.text({
420
+ message: 'Batch prefix',
421
+ placeholder: bean.name,
422
+ defaultValue: bean.name,
423
+ });
424
+ guardCancel(batchPrefixRaw);
425
+ batchPrefix = String(batchPrefixRaw).trim() || bean.name;
426
+ }
427
+ const promptEachRaw = useAutoMatch
428
+ ? false
429
+ : await p.confirm({
430
+ message: 'Prompt for bean selection on each new file?',
431
+ initialValue: false,
432
+ });
433
+ if (typeof promptEachRaw !== 'boolean')
434
+ guardCancel(promptEachRaw);
435
+ await startWatch(supabase, user.id, watchDir, {
436
+ coffeeId: watchCoffeeId,
437
+ coffeeName: watchCoffeeName,
438
+ batchPrefix,
439
+ promptEach: useAutoMatch ? false : Boolean(promptEachRaw),
440
+ autoMatch: useAutoMatch,
441
+ });
442
+ return;
443
+ }
444
+ // ── Flag-based mode ──────────────────────────────────────────────────
445
+ if (!directory) {
446
+ throw new PrvrsError('INVALID_ARGUMENT', 'Missing directory argument. Use --form for interactive mode or --resume to continue.');
447
+ }
448
+ const autoMatch = Boolean(opts.autoMatch);
449
+ // --auto-match and --coffee-id are mutually exclusive (auto-match picks the bean)
450
+ if (autoMatch && opts.coffeeId) {
451
+ throw new PrvrsError('INVALID_ARGUMENT', '--auto-match and --coffee-id are mutually exclusive. Use one or the other.');
452
+ }
453
+ if (!autoMatch && !opts.coffeeId) {
454
+ throw new PrvrsError('INVALID_ARGUMENT', 'Missing --coffee-id. Use --form for interactive mode or --auto-match to let AI pick the bean.');
455
+ }
456
+ let coffeeId;
457
+ let coffeeName;
458
+ if (autoMatch) {
459
+ // Placeholder values — auto-match resolves per-file at runtime
460
+ coffeeId = 0;
461
+ coffeeName = 'auto-match';
462
+ }
463
+ else {
464
+ coffeeId = parseInt(opts.coffeeId, 10);
465
+ if (isNaN(coffeeId) || coffeeId <= 0) {
466
+ throw new PrvrsError('INVALID_ARGUMENT', `Invalid --coffee-id: "${opts.coffeeId}".`);
467
+ }
468
+ // Look up coffee name from inventory
469
+ const { data: invItem, error: invError } = await supabase
470
+ .from('green_coffee_inv')
471
+ .select('id, coffee_catalog!catalog_id (name)')
472
+ .eq('id', coffeeId)
473
+ .eq('user', user.id)
474
+ .single();
475
+ if (invError || !invItem) {
476
+ throw new AuthError(`Inventory item ${coffeeId} not found or does not belong to you.`);
477
+ }
478
+ const catalogRaw = invItem.coffee_catalog;
479
+ const catalog = Array.isArray(catalogRaw) ? (catalogRaw[0] ?? null) : catalogRaw;
480
+ coffeeName = catalog?.name ?? `Coffee #${coffeeId}`;
481
+ }
482
+ const batchPrefix = opts.batchPrefix ?? coffeeName;
483
+ await startWatch(supabase, user.id, directory, {
484
+ coffeeId,
485
+ coffeeName,
486
+ batchPrefix,
487
+ promptEach: Boolean(opts.promptEach),
488
+ autoMatch,
489
+ });
490
+ }));
223
491
  return roast;
224
492
  }
493
+ // ─── Pretty-print helper ──────────────────────────────────────────────────────
494
+ function formatTime(seconds) {
495
+ const m = Math.floor(seconds / 60);
496
+ const s = Math.round(seconds % 60);
497
+ return `${m}:${s.toString().padStart(2, '0')}`;
498
+ }
499
+ function printImportPretty(result, coffeeId) {
500
+ const { milestones, phases } = result;
501
+ console.log('');
502
+ console.log('✓ Roast imported successfully');
503
+ console.log('');
504
+ console.log(` Roast ID: ${result.roast_id}`);
505
+ console.log(` Bean: ${result.coffee_name} (#${coffeeId})`);
506
+ console.log(` Batch: ${result.batch_name}`);
507
+ const tempCount = result.message.match(/(\d+) data points/)?.[1] ?? '?';
508
+ console.log(` Data points: ${tempCount} temperatures, ${result.milestone_events} milestones, ${result.control_events} control events`);
509
+ // Milestones
510
+ if (Object.keys(milestones).length > 0) {
511
+ console.log('');
512
+ console.log(' Milestones:');
513
+ const labels = {
514
+ charge: 'Charge',
515
+ dry_end: 'Dry End',
516
+ fc_start: 'FC Start',
517
+ fc_end: 'FC End',
518
+ sc_start: 'SC Start',
519
+ drop: 'Drop',
520
+ cool: 'Cool',
521
+ };
522
+ for (const [key, label] of Object.entries(labels)) {
523
+ const t = milestones[key];
524
+ if (t !== undefined && t > 0) {
525
+ console.log(` ${label.padEnd(10)}${formatTime(t)}`);
526
+ }
527
+ }
528
+ }
529
+ // Phases
530
+ const { drying_percent, maillard_percent, development_percent, total_time_seconds } = phases;
531
+ if (total_time_seconds > 0) {
532
+ console.log('');
533
+ console.log(' Phases:');
534
+ if (drying_percent > 0)
535
+ console.log(` Drying ${Math.round(drying_percent)}%`);
536
+ if (maillard_percent > 0)
537
+ console.log(` Maillard ${Math.round(maillard_percent)}%`);
538
+ if (development_percent > 0)
539
+ console.log(` Development ${Math.round(development_percent)}%`);
540
+ console.log(` Total ${formatTime(total_time_seconds)}`);
541
+ }
542
+ console.log('');
543
+ }
225
544
  //# sourceMappingURL=roast.js.map