@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.
- package/dist/commands/catalog.d.ts +4 -56
- package/dist/commands/catalog.d.ts.map +1 -1
- package/dist/commands/catalog.js +14 -81
- package/dist/commands/catalog.js.map +1 -1
- package/dist/commands/config.d.ts +7 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +82 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/inventory.d.ts +2 -25
- package/dist/commands/inventory.d.ts.map +1 -1
- package/dist/commands/inventory.js +109 -149
- package/dist/commands/inventory.js.map +1 -1
- package/dist/commands/roast.d.ts +2 -41
- package/dist/commands/roast.d.ts.map +1 -1
- package/dist/commands/roast.js +445 -126
- package/dist/commands/roast.js.map +1 -1
- package/dist/commands/sales.d.ts +2 -10
- package/dist/commands/sales.d.ts.map +1 -1
- package/dist/commands/sales.js +100 -112
- package/dist/commands/sales.js.map +1 -1
- package/dist/commands/tasting.d.ts +4 -38
- package/dist/commands/tasting.d.ts.map +1 -1
- package/dist/commands/tasting.js +93 -116
- package/dist/commands/tasting.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/ai.d.ts +36 -0
- package/dist/lib/ai.d.ts.map +1 -0
- package/dist/lib/ai.js +38 -0
- package/dist/lib/ai.js.map +1 -0
- package/dist/lib/artisan/db.d.ts +37 -0
- package/dist/lib/artisan/db.d.ts.map +1 -0
- package/dist/lib/artisan/db.js +51 -0
- package/dist/lib/artisan/db.js.map +1 -0
- package/dist/lib/artisan/import.d.ts +16 -0
- package/dist/lib/artisan/import.d.ts.map +1 -0
- package/dist/lib/artisan/import.js +447 -0
- package/dist/lib/artisan/import.js.map +1 -0
- package/dist/lib/artisan/index.d.ts +9 -0
- package/dist/lib/artisan/index.d.ts.map +1 -0
- package/dist/lib/artisan/index.js +7 -0
- package/dist/lib/artisan/index.js.map +1 -0
- package/dist/lib/artisan/parser.d.ts +19 -0
- package/dist/lib/artisan/parser.d.ts.map +1 -0
- package/dist/lib/artisan/parser.js +376 -0
- package/dist/lib/artisan/parser.js.map +1 -0
- package/dist/lib/artisan/temperature.d.ts +52 -0
- package/dist/lib/artisan/temperature.d.ts.map +1 -0
- package/dist/lib/artisan/temperature.js +101 -0
- package/dist/lib/artisan/temperature.js.map +1 -0
- package/dist/lib/artisan/types.d.ts +195 -0
- package/dist/lib/artisan/types.d.ts.map +1 -0
- package/dist/lib/artisan/types.js +35 -0
- package/dist/lib/artisan/types.js.map +1 -0
- package/dist/lib/artisan/validator.d.ts +14 -0
- package/dist/lib/artisan/validator.d.ts.map +1 -0
- package/dist/lib/artisan/validator.js +228 -0
- package/dist/lib/artisan/validator.js.map +1 -0
- package/dist/lib/catalog.d.ts +87 -0
- package/dist/lib/catalog.d.ts.map +1 -0
- package/dist/lib/catalog.js +111 -0
- package/dist/lib/catalog.js.map +1 -0
- package/dist/lib/config.d.ts +26 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +59 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/index.d.ts +6 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +11 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/interactive/forms.d.ts +33 -0
- package/dist/lib/interactive/forms.d.ts.map +1 -0
- package/dist/lib/interactive/forms.js +139 -0
- package/dist/lib/interactive/forms.js.map +1 -0
- package/dist/lib/interactive/watch.d.ts +66 -0
- package/dist/lib/interactive/watch.d.ts.map +1 -0
- package/dist/lib/interactive/watch.js +494 -0
- package/dist/lib/interactive/watch.js.map +1 -0
- package/dist/lib/inventory.d.ts +80 -0
- package/dist/lib/inventory.d.ts.map +1 -0
- package/dist/lib/inventory.js +205 -0
- package/dist/lib/inventory.js.map +1 -0
- package/dist/lib/roast.d.ts +127 -0
- package/dist/lib/roast.d.ts.map +1 -0
- package/dist/lib/roast.js +284 -0
- package/dist/lib/roast.js.map +1 -0
- package/dist/lib/sales.d.ts +53 -0
- package/dist/lib/sales.d.ts.map +1 -0
- package/dist/lib/sales.js +155 -0
- package/dist/lib/sales.js.map +1 -0
- package/dist/lib/tasting.d.ts +76 -0
- package/dist/lib/tasting.d.ts.map +1 -0
- package/dist/lib/tasting.js +136 -0
- package/dist/lib/tasting.js.map +1 -0
- package/package.json +15 -2
package/dist/commands/roast.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
.
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
if (
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
.
|
|
158
|
-
.
|
|
159
|
-
|
|
160
|
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|