@plantnet/planttaxomatcher 0.1.3 → 0.3.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/index.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
|
|
6
|
+
// src/deps.ts
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
import { homedir as homedir2, hostname } from "os";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
11
|
+
import { checkbox, confirm } from "@inquirer/prompts";
|
|
12
|
+
|
|
13
|
+
// src/api-client.ts
|
|
14
|
+
import { openAsBlob } from "fs";
|
|
15
|
+
import { basename } from "path";
|
|
16
|
+
import { FormData, fetch } from "undici";
|
|
9
17
|
|
|
10
18
|
// ../shared/src/schemas/identifiers.ts
|
|
11
19
|
import { z } from "zod";
|
|
@@ -31,7 +39,9 @@ var evidenceTypeSchema = z2.enum([
|
|
|
31
39
|
"local_fuzzy",
|
|
32
40
|
"external_fuzzy",
|
|
33
41
|
"team_history",
|
|
34
|
-
"llm"
|
|
42
|
+
"llm",
|
|
43
|
+
// Resolved via the OTHER backbone (WFO↔WCVP) then mapped back to the target.
|
|
44
|
+
"cross_backbone"
|
|
35
45
|
]);
|
|
36
46
|
var gradeSchema = z2.enum(["A", "B", "C"]);
|
|
37
47
|
var reviewStatusSchema = z2.enum(["not_required", "pending", "accepted", "rejected", "overridden"]);
|
|
@@ -59,8 +69,80 @@ var scoreBreakdownSchema = z2.object({
|
|
|
59
69
|
});
|
|
60
70
|
|
|
61
71
|
// ../shared/src/schemas/job.ts
|
|
72
|
+
import { z as z4 } from "zod";
|
|
73
|
+
|
|
74
|
+
// ../shared/src/schemas/plugins.ts
|
|
62
75
|
import { z as z3 } from "zod";
|
|
63
|
-
var
|
|
76
|
+
var verifierStateSchema = z3.enum(["pass", "fail", "error", "skipped"]);
|
|
77
|
+
var verifierColumnKindSchema = z3.enum(["check", "badge", "link", "text"]);
|
|
78
|
+
var verifierColumnSchema = z3.object({
|
|
79
|
+
/** Export column id, e.g. `verify_plantnet`. */
|
|
80
|
+
key: z3.string(),
|
|
81
|
+
/** Human-facing header. */
|
|
82
|
+
header: z3.string(),
|
|
83
|
+
kind: verifierColumnKindSchema
|
|
84
|
+
});
|
|
85
|
+
var pluginRunStatusSchema = z3.enum(["queued", "running", "completed", "failed", "cancelled"]);
|
|
86
|
+
var pluginRunTriggerSchema = z3.enum(["auto", "manual"]);
|
|
87
|
+
var pluginConfigOptionSchema = z3.object({
|
|
88
|
+
value: z3.string(),
|
|
89
|
+
label: z3.string()
|
|
90
|
+
});
|
|
91
|
+
var pluginConfigFieldSchema = z3.object({
|
|
92
|
+
key: z3.string(),
|
|
93
|
+
label: z3.string(),
|
|
94
|
+
type: z3.literal("select"),
|
|
95
|
+
options: z3.array(pluginConfigOptionSchema).min(1),
|
|
96
|
+
default: z3.string()
|
|
97
|
+
});
|
|
98
|
+
var pluginDescriptorSchema = z3.object({
|
|
99
|
+
id: z3.string(),
|
|
100
|
+
version: z3.string(),
|
|
101
|
+
title: z3.string(),
|
|
102
|
+
description: z3.string(),
|
|
103
|
+
/** Deployment can actually run it (key/env present). */
|
|
104
|
+
available: z3.boolean(),
|
|
105
|
+
columns: z3.array(verifierColumnSchema),
|
|
106
|
+
configFields: z3.array(pluginConfigFieldSchema)
|
|
107
|
+
});
|
|
108
|
+
var pluginRunRequestSchema = z3.object({
|
|
109
|
+
config: z3.record(z3.string(), z3.string()).default({})
|
|
110
|
+
});
|
|
111
|
+
var pluginRunSummarySchema = z3.object({
|
|
112
|
+
id: z3.string().uuid(),
|
|
113
|
+
jobId: z3.string().ulid(),
|
|
114
|
+
pluginId: z3.string(),
|
|
115
|
+
pluginVersion: z3.string(),
|
|
116
|
+
config: z3.record(z3.string(), z3.unknown()),
|
|
117
|
+
status: pluginRunStatusSchema,
|
|
118
|
+
trigger: pluginRunTriggerSchema,
|
|
119
|
+
totalUnits: z3.number().int().nonnegative(),
|
|
120
|
+
processedUnits: z3.number().int().nonnegative(),
|
|
121
|
+
passUnits: z3.number().int().nonnegative(),
|
|
122
|
+
errorUnits: z3.number().int().nonnegative(),
|
|
123
|
+
/** Label of the token that triggered a manual run; null for auto runs. */
|
|
124
|
+
triggeredBy: z3.string().nullable(),
|
|
125
|
+
/** `failed` run: why it stopped. `completed` run: why its errored rows
|
|
126
|
+
* couldn't be checked (most frequent reasons first). */
|
|
127
|
+
error: z3.string().nullable(),
|
|
128
|
+
createdAt: z3.string().datetime(),
|
|
129
|
+
startedAt: z3.string().datetime().nullable(),
|
|
130
|
+
completedAt: z3.string().datetime().nullable()
|
|
131
|
+
});
|
|
132
|
+
var pluginRunsResponseSchema = z3.object({
|
|
133
|
+
runs: z3.array(pluginRunSummarySchema)
|
|
134
|
+
});
|
|
135
|
+
var jobRowPluginResultSchema = z3.object({
|
|
136
|
+
pluginId: z3.string(),
|
|
137
|
+
state: verifierStateSchema,
|
|
138
|
+
label: z3.string().nullable(),
|
|
139
|
+
url: z3.string().nullable(),
|
|
140
|
+
/** Why this verdict — the error reason, or what a `fail` was checked against. */
|
|
141
|
+
detail: z3.string().nullable()
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ../shared/src/schemas/job.ts
|
|
145
|
+
var jobStatusSchema = z4.enum([
|
|
64
146
|
"queued",
|
|
65
147
|
"parsing",
|
|
66
148
|
"matching",
|
|
@@ -69,39 +151,90 @@ var jobStatusSchema = z3.enum([
|
|
|
69
151
|
"completed",
|
|
70
152
|
"failed"
|
|
71
153
|
]);
|
|
72
|
-
var authorModeSchema =
|
|
73
|
-
var reviewModeSchema =
|
|
74
|
-
var
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
154
|
+
var authorModeSchema = z4.enum(["ignore", "prefer", "strict"]);
|
|
155
|
+
var reviewModeSchema = z4.enum(["off", "recommended", "strict"]);
|
|
156
|
+
var idTypeOptionSchema = z4.enum(["auto", "wcvp", "gbif"]);
|
|
157
|
+
var jobConfigSchema = z4.object({
|
|
158
|
+
nameColumn: z4.string().min(1),
|
|
159
|
+
idColumn: z4.string().nullable().optional(),
|
|
160
|
+
idType: idTypeOptionSchema.default("auto").optional(),
|
|
161
|
+
familyColumn: z4.string().nullable().optional(),
|
|
162
|
+
genusColumn: z4.string().nullable().optional(),
|
|
163
|
+
rankColumn: z4.string().nullable().optional(),
|
|
164
|
+
authorColumn: z4.string().nullable().optional(),
|
|
165
|
+
sourceReferentialColumn: z4.string().nullable().optional(),
|
|
166
|
+
sourceIdColumn: z4.string().nullable().optional(),
|
|
83
167
|
authorMode: authorModeSchema.default("prefer"),
|
|
84
|
-
matchAuthors:
|
|
85
|
-
parallelism:
|
|
86
|
-
allowFuzzy:
|
|
87
|
-
allowLlm:
|
|
88
|
-
llmCostCapCents:
|
|
168
|
+
matchAuthors: z4.boolean().default(true),
|
|
169
|
+
parallelism: z4.number().int().min(1).max(10).default(4),
|
|
170
|
+
allowFuzzy: z4.boolean().default(true),
|
|
171
|
+
allowLlm: z4.boolean().default(false),
|
|
172
|
+
llmCostCapCents: z4.number().int().min(0).default(500),
|
|
89
173
|
reviewMode: reviewModeSchema.default("recommended"),
|
|
90
|
-
exportConfirmedOnly:
|
|
174
|
+
exportConfirmedOnly: z4.boolean().default(false),
|
|
175
|
+
/**
|
|
176
|
+
* When false, this job's review decisions (accept + reject) do NOT feed the
|
|
177
|
+
* team's Layer 0.5 match-history cache — a one-off or experimental job can't
|
|
178
|
+
* teach (or poison) the shared cache. Default false (opt-in).
|
|
179
|
+
*/
|
|
180
|
+
contributeToTeamCache: z4.boolean().default(false),
|
|
181
|
+
/**
|
|
182
|
+
* Roll infraspecific results up to the species: when an input resolves to an
|
|
183
|
+
* infraspecific accepted taxon (Variety / Subspecies / Form / …), replace it
|
|
184
|
+
* with its parent Species so every output row sits at species level.
|
|
185
|
+
*
|
|
186
|
+
* Default FALSE — the resolved rank is preserved as-is. Rolling up discards
|
|
187
|
+
* information the source data carried, so it is opt-in: turn it on only when
|
|
188
|
+
* the consuming system works at species level and you would otherwise have to
|
|
189
|
+
* flatten the export yourself.
|
|
190
|
+
*/
|
|
191
|
+
speciesLevelAcceptedOnly: z4.boolean().default(false),
|
|
192
|
+
/**
|
|
193
|
+
* "The author isn't important." When the canonical name matches exactly one
|
|
194
|
+
* taxon but the input's author could not be CONFIRMED (flag
|
|
195
|
+
* `author-unconfirmed`, or `author-uncomparable` when it can't be compared
|
|
196
|
+
* at all), auto-accept the match instead of sending it to
|
|
197
|
+
* review. The taxon itself was never in doubt in that case — only whether
|
|
198
|
+
* the author string cites it the way WCVP does — so a dataset whose author
|
|
199
|
+
* column is unreliable (or absent from the source) can skip that queue.
|
|
200
|
+
*
|
|
201
|
+
* Default FALSE. This does NOT relax a genuine author CONFLICT
|
|
202
|
+
* (`author-mismatch`): a conflicting author may point at a different plant,
|
|
203
|
+
* so those still go to review. Every other review trigger (qualifiers,
|
|
204
|
+
* parse quality, force-review families, alternatives) is untouched.
|
|
205
|
+
*/
|
|
206
|
+
acceptUnconfirmedAuthor: z4.boolean().default(false),
|
|
207
|
+
/**
|
|
208
|
+
* Which taxonomic backbone to match against. `wcvp` (default) runs the full
|
|
209
|
+
* cascade; `wfo` matches against the World Flora Online snapshot using the
|
|
210
|
+
* local layers (L1–L4). Stored on `jobs.referential`.
|
|
211
|
+
*/
|
|
212
|
+
referential: z4.enum(["wcvp", "wfo"]).default("wcvp"),
|
|
91
213
|
/**
|
|
92
|
-
*
|
|
93
|
-
* resolves
|
|
94
|
-
*
|
|
95
|
-
*
|
|
214
|
+
* Which snapshot version of the chosen backbone to match against. When
|
|
215
|
+
* omitted, the API resolves it at submit time: the team's configured default
|
|
216
|
+
* snapshot for this backbone (Admin → Backbone) when it is still installed,
|
|
217
|
+
* otherwise the most-recently-imported one. The job stores the resolved
|
|
218
|
+
* version on `jobs.referential_version` so re-runs are reproducible even if
|
|
219
|
+
* a newer snapshot lands later.
|
|
96
220
|
*/
|
|
97
|
-
|
|
221
|
+
referentialVersion: z4.string().min(1).optional(),
|
|
98
222
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
223
|
+
* WGSRPD Level-3 area code (e.g. 'MAS' = Massachusetts) the import is scoped
|
|
224
|
+
* to. When set, an ambiguous multi-candidate match is narrowed to the taxa
|
|
225
|
+
* that occur in this area — exactly one survivor resolves the match (Grade B,
|
|
226
|
+
* flagged `resolved-by-area:<code>`). null/omitted = no disambiguation.
|
|
103
227
|
*/
|
|
104
|
-
|
|
228
|
+
area: z4.string().nullable().optional(),
|
|
229
|
+
/**
|
|
230
|
+
* Auto-run the Pl@ntNet verification plugin for this job at completion —
|
|
231
|
+
* tags each match with whether its accepted taxon aligns to a Pl@ntNet
|
|
232
|
+
* species. Defaults on; set false to skip the auto-run for this job (it can
|
|
233
|
+
* still be triggered manually from the Plugins menu). Only has an effect
|
|
234
|
+
* when the server has Pl@ntNet configured (key set + not globally disabled);
|
|
235
|
+
* otherwise it is a no-op regardless.
|
|
236
|
+
*/
|
|
237
|
+
plantnet: z4.boolean().optional(),
|
|
105
238
|
/**
|
|
106
239
|
* Row filter: keep only rows whose `filterColumn` value equals
|
|
107
240
|
* `filterValue` (trimmed, case-insensitive); all other rows are skipped at
|
|
@@ -109,306 +242,498 @@ var jobConfigSchema = z3.object({
|
|
|
109
242
|
* apply. Lets a user match a subset of a mixed file (e.g. only
|
|
110
243
|
* `kingdom = Plantae`).
|
|
111
244
|
*/
|
|
112
|
-
filterColumn:
|
|
113
|
-
filterValue:
|
|
245
|
+
filterColumn: z4.string().nullable().optional(),
|
|
246
|
+
filterValue: z4.string().nullable().optional()
|
|
114
247
|
});
|
|
115
|
-
var wcvpSnapshotSummarySchema =
|
|
116
|
-
version:
|
|
117
|
-
recordCount:
|
|
118
|
-
importedAt:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
248
|
+
var wcvpSnapshotSummarySchema = z4.object({
|
|
249
|
+
version: z4.string(),
|
|
250
|
+
recordCount: z4.number().int().nonnegative(),
|
|
251
|
+
importedAt: z4.string().datetime(),
|
|
252
|
+
/** Newest OFFICIAL snapshot (derived ones never count as latest). */
|
|
253
|
+
isLatest: z4.boolean(),
|
|
254
|
+
kind: z4.enum(["official", "derived"]),
|
|
255
|
+
baseVersion: z4.string().nullable(),
|
|
256
|
+
label: z4.string().nullable(),
|
|
257
|
+
ownerTeamId: z4.string().nullable()
|
|
258
|
+
});
|
|
259
|
+
var idTypeDetectionSchema = z4.object({
|
|
260
|
+
sampleSize: z4.number().int().nonnegative(),
|
|
261
|
+
counts: z4.record(z4.string(), z4.number().int().nonnegative()),
|
|
262
|
+
dominant: z4.string().nullable(),
|
|
263
|
+
dominantConfidence: z4.number().min(0).max(1),
|
|
264
|
+
minorityExamples: z4.array(
|
|
265
|
+
z4.object({
|
|
266
|
+
rowIndex: z4.number().int().nonnegative(),
|
|
267
|
+
value: z4.string(),
|
|
268
|
+
type: z4.string()
|
|
131
269
|
})
|
|
132
270
|
)
|
|
133
271
|
});
|
|
134
|
-
var publicAccessSchema =
|
|
135
|
-
var jobSummarySchema =
|
|
136
|
-
id:
|
|
137
|
-
teamId:
|
|
138
|
-
userId:
|
|
272
|
+
var publicAccessSchema = z4.enum(["none", "read", "review"]);
|
|
273
|
+
var jobSummarySchema = z4.object({
|
|
274
|
+
id: z4.string().ulid(),
|
|
275
|
+
teamId: z4.string().uuid(),
|
|
276
|
+
userId: z4.string().uuid(),
|
|
139
277
|
/** Optional user-supplied label. URL still uses `id`; null when unset. */
|
|
140
|
-
name:
|
|
141
|
-
/**
|
|
142
|
-
*
|
|
143
|
-
|
|
278
|
+
name: z4.string().nullable(),
|
|
279
|
+
/** The job's author: the label of the token that submitted it (snapshotted
|
|
280
|
+
* at creation, so it survives the token being renamed or revoked). Null for
|
|
281
|
+
* jobs created before authorship was tracked. */
|
|
282
|
+
submittedBy: z4.string().nullable(),
|
|
144
283
|
status: jobStatusSchema,
|
|
145
284
|
/** 'none' = private. 'read'/'review' = anyone with the link, no sign-in. */
|
|
146
285
|
publicAccess: publicAccessSchema,
|
|
147
|
-
referentialVersion:
|
|
148
|
-
idColumn:
|
|
286
|
+
referentialVersion: z4.string(),
|
|
287
|
+
idColumn: z4.string().nullable(),
|
|
149
288
|
idTypeDetection: idTypeDetectionSchema.nullable(),
|
|
150
|
-
totalRows:
|
|
151
|
-
uniqueQueries:
|
|
152
|
-
processedRows:
|
|
153
|
-
matchedRows:
|
|
154
|
-
ambiguousRows:
|
|
155
|
-
errorRows:
|
|
156
|
-
needsReviewRows:
|
|
157
|
-
llmCostCents:
|
|
158
|
-
llmCostCapCents:
|
|
159
|
-
createdAt:
|
|
160
|
-
startedAt:
|
|
161
|
-
completedAt:
|
|
162
|
-
expiresAt:
|
|
289
|
+
totalRows: z4.number().int().nonnegative(),
|
|
290
|
+
uniqueQueries: z4.number().int().nonnegative(),
|
|
291
|
+
processedRows: z4.number().int().nonnegative(),
|
|
292
|
+
matchedRows: z4.number().int().nonnegative(),
|
|
293
|
+
ambiguousRows: z4.number().int().nonnegative(),
|
|
294
|
+
errorRows: z4.number().int().nonnegative(),
|
|
295
|
+
needsReviewRows: z4.number().int().nonnegative(),
|
|
296
|
+
llmCostCents: z4.number().int().nonnegative(),
|
|
297
|
+
llmCostCapCents: z4.number().int().nonnegative(),
|
|
298
|
+
createdAt: z4.string().datetime(),
|
|
299
|
+
startedAt: z4.string().datetime().nullable(),
|
|
300
|
+
completedAt: z4.string().datetime().nullable(),
|
|
301
|
+
expiresAt: z4.string().datetime()
|
|
163
302
|
});
|
|
164
|
-
var jobPublicAccessUpdateSchema =
|
|
303
|
+
var jobPublicAccessUpdateSchema = z4.object({
|
|
165
304
|
publicAccess: publicAccessSchema
|
|
166
305
|
});
|
|
167
|
-
var
|
|
168
|
-
|
|
169
|
-
jobId: z3.string().ulid(),
|
|
170
|
-
timestamp: z3.string().datetime(),
|
|
171
|
-
processedRows: z3.number().int().nonnegative().optional(),
|
|
172
|
-
totalRows: z3.number().int().nonnegative().optional(),
|
|
173
|
-
status: jobStatusSchema.optional(),
|
|
174
|
-
message: z3.string().optional()
|
|
306
|
+
var jobRenameSchema = z4.object({
|
|
307
|
+
name: z4.string().trim().min(1).max(200)
|
|
175
308
|
});
|
|
176
|
-
var
|
|
177
|
-
|
|
178
|
-
|
|
309
|
+
var jobStreamPhaseSchema = z4.enum(["loading", "parsing", "matching"]);
|
|
310
|
+
var nonNegativeInt = z4.number().int().nonnegative();
|
|
311
|
+
var throttledProviderSchema = z4.object({
|
|
312
|
+
/** Provider id: `gbif`, `tnrs`, `gnverifier`, `plantnet`, `openrouter`. */
|
|
313
|
+
provider: z4.string(),
|
|
314
|
+
/** Calls waiting for that provider's limiter right now. */
|
|
315
|
+
waiting: nonNegativeInt,
|
|
316
|
+
/** How long the job has been waiting on it without a break. */
|
|
317
|
+
waitedMs: nonNegativeInt
|
|
179
318
|
});
|
|
180
|
-
var
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
319
|
+
var frameBase = { jobId: z4.string().optional(), timestamp: z4.string().optional() };
|
|
320
|
+
var jobStreamEventSchema = z4.discriminatedUnion("type", [
|
|
321
|
+
/** A status change, or the snapshot a stream opens with. */
|
|
322
|
+
z4.object({
|
|
323
|
+
...frameBase,
|
|
324
|
+
type: z4.literal("status"),
|
|
325
|
+
status: jobStatusSchema,
|
|
326
|
+
phase: jobStreamPhaseSchema.optional(),
|
|
327
|
+
snapshot: z4.string().optional(),
|
|
328
|
+
processedRows: nonNegativeInt.optional(),
|
|
329
|
+
totalRows: nonNegativeInt.optional(),
|
|
330
|
+
processedQueries: nonNegativeInt.optional(),
|
|
331
|
+
totalQueries: nonNegativeInt.optional()
|
|
332
|
+
}),
|
|
333
|
+
/** Live counters for the whole job, a few times a second at most. */
|
|
334
|
+
z4.object({
|
|
335
|
+
...frameBase,
|
|
336
|
+
type: z4.literal("progress"),
|
|
337
|
+
phase: jobStreamPhaseSchema,
|
|
338
|
+
processedQueries: nonNegativeInt,
|
|
339
|
+
totalQueries: nonNegativeInt,
|
|
340
|
+
matched: nonNegativeInt,
|
|
341
|
+
ambiguous: nonNegativeInt,
|
|
342
|
+
errors: nonNegativeInt,
|
|
343
|
+
review: nonNegativeInt,
|
|
344
|
+
processed: nonNegativeInt
|
|
345
|
+
}),
|
|
346
|
+
/**
|
|
347
|
+
* External providers are holding the match stage back: their rate
|
|
348
|
+
* limiter, a retry backoff after a 429 / 5xx, or the in-flight cap. Repeated while it
|
|
349
|
+
* lasts and simply not sent once it stops, so a reader should let the
|
|
350
|
+
* state lapse a few seconds after the last one.
|
|
351
|
+
*/
|
|
352
|
+
z4.object({ ...frameBase, type: z4.literal("throttle"), providers: z4.array(throttledProviderSchema).min(1) }),
|
|
353
|
+
/** Progress of a post-match verification plugin run. */
|
|
354
|
+
z4.object({ ...frameBase, type: z4.literal("plugin"), pluginId: z4.string() }).passthrough(),
|
|
355
|
+
z4.object({ ...frameBase, type: z4.literal("completed"), status: z4.enum(["completed", "cancelled"]) }),
|
|
356
|
+
z4.object({ ...frameBase, type: z4.literal("error"), message: z4.string() }),
|
|
357
|
+
z4.object({ ...frameBase, type: z4.literal("heartbeat") })
|
|
358
|
+
]);
|
|
359
|
+
var taxonIdentifierRefSchema = z4.object({
|
|
360
|
+
namespace: z4.string(),
|
|
361
|
+
value: z4.string()
|
|
362
|
+
});
|
|
363
|
+
var jobRowSummarySchema = z4.object({
|
|
364
|
+
id: z4.string().uuid(),
|
|
365
|
+
rowIndex: z4.number().int().nonnegative(),
|
|
366
|
+
inputName: z4.string().nullable(),
|
|
367
|
+
inputId: z4.string().nullable(),
|
|
368
|
+
inputFamily: z4.string().nullable(),
|
|
186
369
|
matchStatus: matchStatusSchema.nullable(),
|
|
187
370
|
grade: gradeSchema.nullable(),
|
|
188
|
-
evidenceType:
|
|
371
|
+
evidenceType: z4.string().nullable(),
|
|
189
372
|
reviewStatus: reviewStatusSchema,
|
|
190
|
-
matchQueryId:
|
|
191
|
-
confidence:
|
|
192
|
-
layer:
|
|
193
|
-
flags:
|
|
194
|
-
candidateAcceptedName:
|
|
373
|
+
matchQueryId: z4.string().uuid().nullable(),
|
|
374
|
+
confidence: z4.number().nullable(),
|
|
375
|
+
layer: z4.string().nullable(),
|
|
376
|
+
flags: z4.array(z4.string()).nullable(),
|
|
377
|
+
candidateAcceptedName: z4.string().nullable(),
|
|
195
378
|
/** Authorship of the accepted taxon (distinct from `candidateAuthorship`
|
|
196
379
|
* which carries the matched-row author when the match resolves via a synonym). */
|
|
197
|
-
candidateAcceptedAuthorship:
|
|
198
|
-
candidateAcceptedIdentifiers:
|
|
199
|
-
candidateScientificName:
|
|
200
|
-
candidateAuthorship:
|
|
201
|
-
candidateFamily:
|
|
202
|
-
candidateReason:
|
|
203
|
-
candidateTargetUrl:
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
380
|
+
candidateAcceptedAuthorship: z4.string().nullable(),
|
|
381
|
+
candidateAcceptedIdentifiers: z4.array(taxonIdentifierRefSchema).nullable(),
|
|
382
|
+
candidateScientificName: z4.string().nullable(),
|
|
383
|
+
candidateAuthorship: z4.string().nullable(),
|
|
384
|
+
candidateFamily: z4.string().nullable(),
|
|
385
|
+
candidateReason: z4.string().nullable(),
|
|
386
|
+
candidateTargetUrl: z4.string().nullable(),
|
|
387
|
+
/** Post-job verification-plugin results for this row, one per plugin that
|
|
388
|
+
* has a completed run. Empty when no plugin has run. Sourced from
|
|
389
|
+
* `job_plugin_results` joined via the row's match query. */
|
|
390
|
+
plugins: z4.array(jobRowPluginResultSchema).default([])
|
|
391
|
+
});
|
|
392
|
+
var gradeCountsSchema = z4.object({
|
|
393
|
+
A: z4.number().int().nonnegative(),
|
|
394
|
+
B: z4.number().int().nonnegative(),
|
|
395
|
+
C: z4.number().int().nonnegative(),
|
|
396
|
+
ungraded: z4.number().int().nonnegative()
|
|
397
|
+
});
|
|
398
|
+
var statusCountsSchema = z4.object({
|
|
399
|
+
matched: z4.number().int().nonnegative(),
|
|
400
|
+
ambiguous: z4.number().int().nonnegative(),
|
|
401
|
+
no_match: z4.number().int().nonnegative(),
|
|
402
|
+
error: z4.number().int().nonnegative(),
|
|
403
|
+
skipped: z4.number().int().nonnegative(),
|
|
217
404
|
/** Rows whose match query exists but hasn't been processed yet (mid-run).
|
|
218
405
|
* Distinct from `skipped` (no query — empty/guarded input). Not part of the
|
|
219
406
|
* outcome funnel; the SPA can show it as in-progress. */
|
|
220
|
-
pending:
|
|
407
|
+
pending: z4.number().int().nonnegative()
|
|
221
408
|
});
|
|
222
|
-
var
|
|
223
|
-
|
|
409
|
+
var layerCountsSchema = z4.record(z4.string(), z4.number().int().nonnegative());
|
|
410
|
+
var rowSortSchema = z4.enum([
|
|
411
|
+
"rowIndex",
|
|
412
|
+
"inputName",
|
|
413
|
+
"matchStatus",
|
|
414
|
+
"grade",
|
|
415
|
+
"confidence",
|
|
416
|
+
"acceptedName",
|
|
417
|
+
"family"
|
|
418
|
+
]);
|
|
419
|
+
var rowOrderSchema = z4.enum(["asc", "desc"]);
|
|
420
|
+
var jobRowsPageSchema = z4.object({
|
|
421
|
+
rows: z4.array(jobRowSummarySchema),
|
|
224
422
|
/** Row count matching the CURRENT filter (not the whole job) — drives
|
|
225
423
|
* pagination so page count adapts to active grade/status/search/conf filters. */
|
|
226
|
-
total:
|
|
227
|
-
offset:
|
|
228
|
-
limit:
|
|
424
|
+
total: z4.number().int().nonnegative(),
|
|
425
|
+
offset: z4.number().int().nonnegative(),
|
|
426
|
+
limit: z4.number().int().positive(),
|
|
427
|
+
/** Opaque keyset cursor for the page after this one, in the requested sort
|
|
428
|
+
* + order; null on the last page. Pass it back as `cursor`. */
|
|
429
|
+
nextCursor: z4.string().nullable(),
|
|
229
430
|
/** Per-grade row counts for the whole job, independent of the current
|
|
230
431
|
* filter. Used by the SPA to show "B (12)" next to each grade chip. */
|
|
231
432
|
gradeCounts: gradeCountsSchema,
|
|
232
433
|
/** Per-status row counts (matched / ambiguous / no_match / error /
|
|
233
434
|
* skipped), same scope + purpose as `gradeCounts`. */
|
|
234
|
-
statusCounts: statusCountsSchema
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
435
|
+
statusCounts: statusCountsSchema,
|
|
436
|
+
/** Per-layer row counts, keyed by evidence type. Same scope + purpose as
|
|
437
|
+
* `gradeCounts`; only layers present in the job appear. */
|
|
438
|
+
layerCounts: layerCountsSchema
|
|
439
|
+
});
|
|
440
|
+
var wcvpRankLevelSchema = z4.enum(["genus", "species", "infra"]);
|
|
441
|
+
var wcvpSearchHitSchema = z4.object({
|
|
442
|
+
taxonId: z4.string(),
|
|
443
|
+
scientificName: z4.string(),
|
|
444
|
+
canonicalName: z4.string(),
|
|
445
|
+
authorship: z4.string().nullable(),
|
|
446
|
+
rank: z4.string().nullable(),
|
|
447
|
+
taxonomicStatus: z4.string().nullable(),
|
|
448
|
+
family: z4.string().nullable(),
|
|
449
|
+
acceptedTaxonId: z4.string().nullable(),
|
|
450
|
+
acceptedName: z4.string().nullable(),
|
|
451
|
+
similarity: z4.number()
|
|
452
|
+
});
|
|
453
|
+
var wcvpSearchResponseSchema = z4.array(wcvpSearchHitSchema);
|
|
454
|
+
var speciesRollupStatusSchema = z4.enum([
|
|
455
|
+
"available",
|
|
456
|
+
"already-species",
|
|
457
|
+
"no-species-ancestor",
|
|
458
|
+
"no-selection",
|
|
459
|
+
"unknown-taxon"
|
|
460
|
+
]);
|
|
461
|
+
var speciesRollupTaxonSchema = z4.object({
|
|
462
|
+
taxonId: z4.string(),
|
|
463
|
+
scientificName: z4.string(),
|
|
464
|
+
canonicalName: z4.string(),
|
|
465
|
+
authorship: z4.string().nullable(),
|
|
466
|
+
rank: z4.string().nullable(),
|
|
467
|
+
family: z4.string().nullable()
|
|
468
|
+
});
|
|
469
|
+
var speciesRollupResponseSchema = z4.object({
|
|
470
|
+
status: speciesRollupStatusSchema,
|
|
471
|
+
/** The accepted taxon the row currently resolves to. */
|
|
472
|
+
current: speciesRollupTaxonSchema.nullable(),
|
|
473
|
+
/** Only set when `status` is `available`. */
|
|
474
|
+
target: speciesRollupTaxonSchema.nullable()
|
|
475
|
+
});
|
|
476
|
+
var jobDiffRowSchema = z4.object({
|
|
477
|
+
rowId: z4.string().uuid(),
|
|
478
|
+
rowIndex: z4.number().int().nonnegative(),
|
|
479
|
+
inputName: z4.string().nullable(),
|
|
480
|
+
inputFamily: z4.string().nullable(),
|
|
481
|
+
matchedName: z4.string().nullable(),
|
|
482
|
+
matchedAuthorship: z4.string().nullable(),
|
|
483
|
+
matchedTaxonomicStatus: z4.string().nullable(),
|
|
484
|
+
acceptedName: z4.string().nullable(),
|
|
485
|
+
acceptedAuthorship: z4.string().nullable(),
|
|
486
|
+
acceptedFamily: z4.string().nullable(),
|
|
487
|
+
isSynonym: z4.boolean(),
|
|
488
|
+
familyChanged: z4.boolean(),
|
|
262
489
|
grade: gradeSchema.nullable(),
|
|
263
490
|
reviewStatus: reviewStatusSchema,
|
|
264
|
-
targetUrl:
|
|
491
|
+
targetUrl: z4.string().nullable()
|
|
265
492
|
});
|
|
266
|
-
var jobDiffPageSchema =
|
|
267
|
-
rows:
|
|
268
|
-
total:
|
|
493
|
+
var jobDiffPageSchema = z4.object({
|
|
494
|
+
rows: z4.array(jobDiffRowSchema),
|
|
495
|
+
total: z4.number().int().nonnegative()
|
|
269
496
|
});
|
|
270
|
-
var reviewActionSchema =
|
|
271
|
-
var reviewRequestSchema =
|
|
497
|
+
var reviewActionSchema = z4.enum(["accept", "reject"]);
|
|
498
|
+
var reviewRequestSchema = z4.object({
|
|
272
499
|
action: reviewActionSchema,
|
|
273
|
-
|
|
500
|
+
// Optional so a row with no candidates can still be resolved as "no
|
|
501
|
+
// match" (reject with no candidate). Accept always needs one.
|
|
502
|
+
candidateId: z4.string().uuid().optional()
|
|
503
|
+
}).refine((v) => v.action === "reject" || !!v.candidateId, {
|
|
504
|
+
message: "candidateId is required to accept a candidate",
|
|
505
|
+
path: ["candidateId"]
|
|
274
506
|
});
|
|
275
|
-
var bulkReviewRequestSchema =
|
|
507
|
+
var bulkReviewRequestSchema = z4.object({
|
|
276
508
|
action: reviewActionSchema,
|
|
277
|
-
filter:
|
|
509
|
+
filter: z4.object({
|
|
278
510
|
grade: gradeSchema.optional(),
|
|
279
511
|
matchStatus: matchStatusSchema.optional(),
|
|
280
|
-
family:
|
|
512
|
+
family: z4.string().optional(),
|
|
513
|
+
/** Restrict the bulk action to these specific match-query ids. The
|
|
514
|
+
* review queue's grouped/batch view computes a group's membership
|
|
515
|
+
* client-side (by issue category or author-equivalence pair) and
|
|
516
|
+
* sends the exact ids — flag/category logic that a coarse
|
|
517
|
+
* grade/family filter can't express. ANDed with any other filter;
|
|
518
|
+
* the server still restricts to currently-pending, owned queries. */
|
|
519
|
+
matchQueryIds: z4.array(z4.string().uuid()).max(5e4).optional()
|
|
281
520
|
}).default({}),
|
|
282
|
-
dryRun:
|
|
521
|
+
dryRun: z4.boolean().default(false)
|
|
283
522
|
});
|
|
284
|
-
var bulkReviewResponseSchema =
|
|
523
|
+
var bulkReviewResponseSchema = z4.object({
|
|
285
524
|
action: reviewActionSchema,
|
|
286
|
-
matchedQueries:
|
|
287
|
-
dryRun:
|
|
288
|
-
appliedAt:
|
|
525
|
+
matchedQueries: z4.number().int().nonnegative(),
|
|
526
|
+
dryRun: z4.boolean(),
|
|
527
|
+
appliedAt: z4.string().datetime().nullable()
|
|
289
528
|
});
|
|
290
|
-
var reviewResponseSchema =
|
|
291
|
-
matchQueryId:
|
|
529
|
+
var reviewResponseSchema = z4.object({
|
|
530
|
+
matchQueryId: z4.string().uuid(),
|
|
292
531
|
action: reviewActionSchema,
|
|
293
|
-
|
|
532
|
+
// Null only for a "no match" reject (a row with no candidate). An accept
|
|
533
|
+
// always resolves to a candidate — the refine catches a server that
|
|
534
|
+
// returned an accept without one.
|
|
535
|
+
candidateId: z4.string().uuid().nullable(),
|
|
294
536
|
reviewStatus: reviewStatusSchema,
|
|
295
|
-
reviewEventId:
|
|
537
|
+
reviewEventId: z4.string().uuid()
|
|
538
|
+
}).refine((v) => v.action !== "accept" || v.candidateId !== null, {
|
|
539
|
+
message: "an accept must resolve to a candidateId",
|
|
540
|
+
path: ["candidateId"]
|
|
296
541
|
});
|
|
297
|
-
var matchAttemptSummarySchema =
|
|
298
|
-
id:
|
|
299
|
-
layer:
|
|
300
|
-
provider:
|
|
301
|
-
status:
|
|
302
|
-
durationMs:
|
|
542
|
+
var matchAttemptSummarySchema = z4.object({
|
|
543
|
+
id: z4.string().uuid(),
|
|
544
|
+
layer: z4.string(),
|
|
545
|
+
provider: z4.string(),
|
|
546
|
+
status: z4.string(),
|
|
547
|
+
durationMs: z4.number().int().nonnegative(),
|
|
303
548
|
// JSONB fields accept any shape. Note: `z.unknown()` infers as optional,
|
|
304
549
|
// so consumers should guard for `undefined` even though we always send
|
|
305
550
|
// them as part of the response.
|
|
306
|
-
query:
|
|
307
|
-
rawResponseSummary:
|
|
308
|
-
createdAt:
|
|
551
|
+
query: z4.unknown(),
|
|
552
|
+
rawResponseSummary: z4.unknown().nullable(),
|
|
553
|
+
createdAt: z4.string().datetime()
|
|
309
554
|
});
|
|
310
|
-
var matchCandidateSummarySchema =
|
|
311
|
-
id:
|
|
312
|
-
attemptId:
|
|
313
|
-
source:
|
|
314
|
-
sourceId:
|
|
315
|
-
scientificName:
|
|
316
|
-
canonicalName:
|
|
317
|
-
authorship:
|
|
318
|
-
rank:
|
|
319
|
-
taxonomicStatus:
|
|
320
|
-
acceptedName:
|
|
321
|
-
acceptedAuthorship:
|
|
322
|
-
acceptedIdentifiers:
|
|
323
|
-
identifiers:
|
|
324
|
-
family:
|
|
325
|
-
confidence:
|
|
555
|
+
var matchCandidateSummarySchema = z4.object({
|
|
556
|
+
id: z4.string().uuid(),
|
|
557
|
+
attemptId: z4.string().uuid(),
|
|
558
|
+
source: z4.string(),
|
|
559
|
+
sourceId: z4.string().nullable(),
|
|
560
|
+
scientificName: z4.string(),
|
|
561
|
+
canonicalName: z4.string(),
|
|
562
|
+
authorship: z4.string().nullable(),
|
|
563
|
+
rank: z4.string().nullable(),
|
|
564
|
+
taxonomicStatus: z4.string().nullable(),
|
|
565
|
+
acceptedName: z4.string().nullable(),
|
|
566
|
+
acceptedAuthorship: z4.string().nullable(),
|
|
567
|
+
acceptedIdentifiers: z4.array(taxonIdentifierRefSchema).nullable(),
|
|
568
|
+
identifiers: z4.array(taxonIdentifierRefSchema),
|
|
569
|
+
family: z4.string().nullable(),
|
|
570
|
+
confidence: z4.number().nullable(),
|
|
326
571
|
grade: gradeSchema.nullable(),
|
|
327
|
-
reason:
|
|
328
|
-
flags:
|
|
329
|
-
state:
|
|
330
|
-
targetUrl:
|
|
572
|
+
reason: z4.string().nullable(),
|
|
573
|
+
flags: z4.array(z4.string()).nullable(),
|
|
574
|
+
state: z4.enum(["proposed", "accepted", "rejected", "overridden"]),
|
|
575
|
+
targetUrl: z4.string().nullable()
|
|
331
576
|
});
|
|
332
|
-
var matchQueryDetailSchema =
|
|
333
|
-
id:
|
|
334
|
-
jobId:
|
|
335
|
-
normalizedInput:
|
|
336
|
-
parsed:
|
|
577
|
+
var matchQueryDetailSchema = z4.object({
|
|
578
|
+
id: z4.string().uuid(),
|
|
579
|
+
jobId: z4.string().ulid(),
|
|
580
|
+
normalizedInput: z4.string(),
|
|
581
|
+
parsed: z4.unknown(),
|
|
337
582
|
matchStatus: matchStatusSchema.nullable(),
|
|
338
|
-
evidenceType:
|
|
583
|
+
evidenceType: z4.string().nullable(),
|
|
339
584
|
grade: gradeSchema.nullable(),
|
|
340
|
-
confidence:
|
|
341
|
-
flags:
|
|
585
|
+
confidence: z4.number().nullable(),
|
|
586
|
+
flags: z4.array(z4.string()).nullable(),
|
|
342
587
|
reviewStatus: reviewStatusSchema,
|
|
343
|
-
selectedCandidateId:
|
|
588
|
+
selectedCandidateId: z4.string().uuid().nullable()
|
|
589
|
+
});
|
|
590
|
+
var otherVersionMatchSchema = z4.object({
|
|
591
|
+
version: z4.string(),
|
|
592
|
+
importedAt: z4.string(),
|
|
593
|
+
isLatest: z4.boolean(),
|
|
594
|
+
scientificName: z4.string(),
|
|
595
|
+
authorship: z4.string().nullable(),
|
|
596
|
+
taxonomicStatus: z4.string().nullable(),
|
|
597
|
+
acceptedName: z4.string().nullable()
|
|
344
598
|
});
|
|
345
|
-
var queryCandidatesResponseSchema =
|
|
599
|
+
var queryCandidatesResponseSchema = z4.object({
|
|
346
600
|
query: matchQueryDetailSchema,
|
|
347
|
-
candidates:
|
|
348
|
-
attempts:
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
601
|
+
candidates: z4.array(matchCandidateSummarySchema),
|
|
602
|
+
attempts: z4.array(matchAttemptSummarySchema),
|
|
603
|
+
/** A newer/other backbone version that has this exact name (see schema). */
|
|
604
|
+
otherVersionMatch: otherVersionMatchSchema.nullable()
|
|
605
|
+
});
|
|
606
|
+
var reviewChatMessageSchema = z4.object({
|
|
607
|
+
role: z4.enum(["user", "assistant"]),
|
|
608
|
+
content: z4.string().min(1).max(4e3)
|
|
609
|
+
});
|
|
610
|
+
var reviewChatBodySchema = z4.object({
|
|
611
|
+
messages: z4.array(reviewChatMessageSchema).min(1).max(40),
|
|
612
|
+
/** Let the assistant also search the web (OpenRouter web plugin). Default on
|
|
613
|
+
* server-side when omitted; the client sends it explicitly from its toggle. */
|
|
614
|
+
webSearch: z4.boolean().optional(),
|
|
615
|
+
/** Per-chat model override (an OpenRouter id / alias). When omitted the
|
|
616
|
+
* team's configured heavy model is used. Length-capped defensively. */
|
|
617
|
+
model: z4.string().trim().min(1).max(200).optional()
|
|
618
|
+
});
|
|
619
|
+
var reviewChatResponseSchema = z4.object({
|
|
620
|
+
reply: z4.string(),
|
|
621
|
+
model: z4.string()
|
|
622
|
+
});
|
|
623
|
+
var reviewChatStreamEventSchema = z4.discriminatedUnion("type", [
|
|
624
|
+
/** A chunk of the model's visible answer. */
|
|
625
|
+
z4.object({ type: z4.literal("text"), delta: z4.string() }),
|
|
626
|
+
/** A chunk of the model's reasoning/thinking (when the model exposes it). */
|
|
627
|
+
z4.object({ type: z4.literal("reasoning"), delta: z4.string() }),
|
|
628
|
+
/** The model decided to call a tool, with the (JSON) arguments it chose. */
|
|
629
|
+
z4.object({ type: z4.literal("tool_call"), id: z4.string(), name: z4.string(), arguments: z4.string() }),
|
|
630
|
+
/** The result we fed back to the model after running that tool. `sql` is the
|
|
631
|
+
* statement the tool actually ran, shown to the reviewer for transparency —
|
|
632
|
+
* it is emitted on this event ONLY and never added to the model's context. */
|
|
633
|
+
z4.object({
|
|
634
|
+
type: z4.literal("tool_result"),
|
|
635
|
+
id: z4.string(),
|
|
636
|
+
name: z4.string(),
|
|
637
|
+
result: z4.string(),
|
|
638
|
+
sql: z4.string().optional()
|
|
639
|
+
}),
|
|
640
|
+
/** Terminal success — carries the model id that answered. */
|
|
641
|
+
z4.object({ type: z4.literal("done"), model: z4.string() }),
|
|
642
|
+
/** Terminal failure — a human-readable reason. */
|
|
643
|
+
z4.object({ type: z4.literal("error"), error: z4.string() })
|
|
644
|
+
]);
|
|
645
|
+
var replayableLayerSchema = z4.enum(["L1", "L1.5", "L2", "L3", "L4", "L5", "L6", "L7"]);
|
|
646
|
+
var replayLayerBodySchema = z4.object({ layer: replayableLayerSchema });
|
|
647
|
+
var replayCandidateSchema = z4.object({
|
|
648
|
+
scientificName: z4.string(),
|
|
649
|
+
canonicalName: z4.string(),
|
|
650
|
+
authorship: z4.string().nullable(),
|
|
651
|
+
rank: z4.string().nullable(),
|
|
652
|
+
taxonomicStatus: z4.string().nullable(),
|
|
653
|
+
acceptedName: z4.string().nullable(),
|
|
654
|
+
acceptedAuthorship: z4.string().nullable(),
|
|
655
|
+
family: z4.string().nullable(),
|
|
372
656
|
/** WCVP taxon id of the matched row (null for unresolved external proposals). */
|
|
373
|
-
taxonId:
|
|
657
|
+
taxonId: z4.string().nullable(),
|
|
374
658
|
/** WCVP taxon id of the resolved accepted taxon (null when unresolved). */
|
|
375
|
-
acceptedTaxonId:
|
|
376
|
-
confidence:
|
|
377
|
-
reason:
|
|
378
|
-
flags:
|
|
379
|
-
targetUrl:
|
|
659
|
+
acceptedTaxonId: z4.string().nullable(),
|
|
660
|
+
confidence: z4.number().nullable(),
|
|
661
|
+
reason: z4.string().nullable(),
|
|
662
|
+
flags: z4.array(z4.string()),
|
|
663
|
+
targetUrl: z4.string().nullable(),
|
|
380
664
|
/** Provider that proposed this candidate (e.g. tnrs, gbif, openrouter,
|
|
381
665
|
* wcvp-local). */
|
|
382
|
-
provider:
|
|
666
|
+
provider: z4.string().nullable()
|
|
383
667
|
});
|
|
384
|
-
var replayAttemptSchema =
|
|
385
|
-
provider:
|
|
386
|
-
status:
|
|
387
|
-
durationMs:
|
|
388
|
-
summary:
|
|
668
|
+
var replayAttemptSchema = z4.object({
|
|
669
|
+
provider: z4.string(),
|
|
670
|
+
status: z4.string(),
|
|
671
|
+
durationMs: z4.number().int().nonnegative(),
|
|
672
|
+
summary: z4.unknown().nullable()
|
|
389
673
|
});
|
|
390
|
-
var replayLayerResultSchema =
|
|
674
|
+
var replayLayerResultSchema = z4.object({
|
|
391
675
|
layer: replayableLayerSchema,
|
|
392
|
-
kind:
|
|
393
|
-
reason:
|
|
676
|
+
kind: z4.enum(["unique", "ambiguous", "miss"]),
|
|
677
|
+
reason: z4.string().nullable(),
|
|
394
678
|
/** Human-facing note when the layer couldn't run as-is (e.g. LLM not
|
|
395
679
|
* configured, no external providers enabled). */
|
|
396
|
-
note:
|
|
397
|
-
providers:
|
|
398
|
-
durationMs:
|
|
680
|
+
note: z4.string().nullable(),
|
|
681
|
+
providers: z4.array(z4.string()),
|
|
682
|
+
durationMs: z4.number().int().nonnegative(),
|
|
399
683
|
/** What was actually submitted to the layer (post gnparser). */
|
|
400
|
-
query:
|
|
401
|
-
canonical:
|
|
402
|
-
authorship:
|
|
403
|
-
inputId:
|
|
684
|
+
query: z4.object({
|
|
685
|
+
canonical: z4.string(),
|
|
686
|
+
authorship: z4.string().nullable(),
|
|
687
|
+
inputId: z4.string().nullable()
|
|
688
|
+
}),
|
|
689
|
+
candidates: z4.array(replayCandidateSchema),
|
|
690
|
+
attempts: z4.array(replayAttemptSchema)
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// ../shared/src/schemas/live-match.ts
|
|
694
|
+
import { z as z5 } from "zod";
|
|
695
|
+
var liveMatchBodySchema = z5.object({
|
|
696
|
+
name: z5.string().trim().min(3).max(300),
|
|
697
|
+
referential: z5.enum(["wcvp", "wfo"]).default("wcvp"),
|
|
698
|
+
referentialVersion: z5.string().min(1),
|
|
699
|
+
/** WGSRPD Level-3 area code narrowing an ambiguous outcome (null = off). */
|
|
700
|
+
area: z5.string().nullable().optional(),
|
|
701
|
+
speciesLevelAcceptedOnly: z5.boolean().default(false),
|
|
702
|
+
acceptUnconfirmedAuthor: z5.boolean().default(false)
|
|
703
|
+
});
|
|
704
|
+
var liveMatchCandidateSchema = matchCandidateSummarySchema.omit({ id: true, attemptId: true });
|
|
705
|
+
var liveMatchAttemptSchema = z5.object({
|
|
706
|
+
layer: z5.string(),
|
|
707
|
+
provider: z5.string(),
|
|
708
|
+
status: z5.string(),
|
|
709
|
+
durationMs: z5.number().int().nonnegative(),
|
|
710
|
+
query: z5.unknown(),
|
|
711
|
+
rawResponseSummary: z5.unknown().nullable()
|
|
712
|
+
});
|
|
713
|
+
var liveMatchResultSchema = z5.object({
|
|
714
|
+
query: z5.object({
|
|
715
|
+
input: z5.string(),
|
|
716
|
+
canonical: z5.string(),
|
|
717
|
+
authorship: z5.string().nullable(),
|
|
718
|
+
parser: z5.enum(["gnparser", "fallback"])
|
|
404
719
|
}),
|
|
405
|
-
|
|
406
|
-
|
|
720
|
+
matchStatus: matchStatusSchema,
|
|
721
|
+
evidenceType: evidenceTypeSchema.nullable(),
|
|
722
|
+
grade: gradeSchema.nullable(),
|
|
723
|
+
confidence: z5.number().nullable(),
|
|
724
|
+
/** Layer that settled the outcome (null for no_match). */
|
|
725
|
+
decidedBy: z5.string().nullable(),
|
|
726
|
+
flags: z5.array(z5.string()),
|
|
727
|
+
/** Index into `candidates` of the pick a job would have selected. */
|
|
728
|
+
selectedIndex: z5.number().int().nonnegative().nullable(),
|
|
729
|
+
candidates: z5.array(liveMatchCandidateSchema),
|
|
730
|
+
attempts: z5.array(liveMatchAttemptSchema),
|
|
731
|
+
durationMs: z5.number().int().nonnegative()
|
|
407
732
|
});
|
|
408
733
|
|
|
409
734
|
// ../shared/src/schemas/auth.ts
|
|
410
|
-
import { z as
|
|
411
|
-
var tokenScopeSchema =
|
|
735
|
+
import { z as z6 } from "zod";
|
|
736
|
+
var tokenScopeSchema = z6.enum([
|
|
412
737
|
"submit:job",
|
|
413
738
|
"read:job",
|
|
414
739
|
"cancel:job",
|
|
@@ -420,77 +745,87 @@ var tokenScopeSchema = z4.enum([
|
|
|
420
745
|
"admin:teams"
|
|
421
746
|
]);
|
|
422
747
|
var ALL_TOKEN_SCOPES = tokenScopeSchema.options;
|
|
423
|
-
var tokenStringSchema =
|
|
424
|
-
var meSchema =
|
|
425
|
-
userId:
|
|
426
|
-
teamId:
|
|
427
|
-
displayName:
|
|
428
|
-
scopes:
|
|
429
|
-
tokenLabel:
|
|
748
|
+
var tokenStringSchema = z6.string().regex(/^ptm_[a-zA-Z0-9]{12}_[a-zA-Z0-9]{32}$/, "Malformed token");
|
|
749
|
+
var meSchema = z6.object({
|
|
750
|
+
userId: z6.string().uuid(),
|
|
751
|
+
teamId: z6.string().uuid(),
|
|
752
|
+
displayName: z6.string(),
|
|
753
|
+
scopes: z6.array(tokenScopeSchema),
|
|
754
|
+
tokenLabel: z6.string().nullable(),
|
|
755
|
+
/** DB id of the token that authenticated this request — matches a row's
|
|
756
|
+
* `id` in the admin token list, so the UI can highlight "this is you". */
|
|
757
|
+
tokenId: z6.string().uuid().nullable(),
|
|
758
|
+
/** Where this deployment's web app lives, for links to a job page. Absent from older servers. */
|
|
759
|
+
appUrl: z6.string().optional()
|
|
430
760
|
});
|
|
431
|
-
var tokenCreateBodySchema =
|
|
432
|
-
label:
|
|
433
|
-
scopes:
|
|
434
|
-
expiresAt:
|
|
761
|
+
var tokenCreateBodySchema = z6.object({
|
|
762
|
+
label: z6.string().min(1).max(120),
|
|
763
|
+
scopes: z6.array(tokenScopeSchema).min(1),
|
|
764
|
+
expiresAt: z6.string().datetime().optional()
|
|
435
765
|
});
|
|
436
|
-
var
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
766
|
+
var tokenRenameSchema = z6.object({
|
|
767
|
+
label: z6.string().trim().min(1).max(120)
|
|
768
|
+
});
|
|
769
|
+
var tokenSummarySchema = z6.object({
|
|
770
|
+
id: z6.string().uuid(),
|
|
771
|
+
/** Team the token belongs to — `admin:teams` callers list every team's tokens. */
|
|
772
|
+
teamId: z6.string().uuid(),
|
|
773
|
+
tokenIdPrefix: z6.string(),
|
|
774
|
+
label: z6.string(),
|
|
775
|
+
displayName: z6.string(),
|
|
776
|
+
scopes: z6.array(tokenScopeSchema),
|
|
777
|
+
createdAt: z6.string().datetime(),
|
|
778
|
+
lastUsedAt: z6.string().datetime().nullable(),
|
|
779
|
+
expiresAt: z6.string().datetime().nullable()
|
|
445
780
|
});
|
|
446
781
|
var tokenCreatedSchema = tokenSummarySchema.extend({
|
|
447
782
|
tokenString: tokenStringSchema
|
|
448
783
|
});
|
|
449
|
-
var teamSummarySchema =
|
|
450
|
-
id:
|
|
451
|
-
name:
|
|
452
|
-
createdAt:
|
|
784
|
+
var teamSummarySchema = z6.object({
|
|
785
|
+
id: z6.string().uuid(),
|
|
786
|
+
name: z6.string(),
|
|
787
|
+
createdAt: z6.string().datetime()
|
|
453
788
|
});
|
|
454
|
-
var teamCreateBodySchema =
|
|
455
|
-
name:
|
|
456
|
-
initialUserDisplayName:
|
|
457
|
-
initialToken:
|
|
458
|
-
label:
|
|
459
|
-
scopes:
|
|
789
|
+
var teamCreateBodySchema = z6.object({
|
|
790
|
+
name: z6.string().min(1).max(120),
|
|
791
|
+
initialUserDisplayName: z6.string().min(1).max(120).default("Admin"),
|
|
792
|
+
initialToken: z6.object({
|
|
793
|
+
label: z6.string().min(1).max(120),
|
|
794
|
+
scopes: z6.array(tokenScopeSchema).min(1)
|
|
460
795
|
}).optional()
|
|
461
796
|
});
|
|
462
797
|
var teamCreatedSchema = teamSummarySchema.extend({
|
|
463
|
-
initialUser:
|
|
464
|
-
id:
|
|
465
|
-
displayName:
|
|
798
|
+
initialUser: z6.object({
|
|
799
|
+
id: z6.string().uuid(),
|
|
800
|
+
displayName: z6.string()
|
|
466
801
|
}),
|
|
467
802
|
initialToken: tokenCreatedSchema.nullable()
|
|
468
803
|
});
|
|
469
|
-
var setupStatusSchema =
|
|
470
|
-
needsSetup:
|
|
804
|
+
var setupStatusSchema = z6.object({
|
|
805
|
+
needsSetup: z6.boolean()
|
|
471
806
|
});
|
|
472
|
-
var setupBodySchema =
|
|
473
|
-
teamName:
|
|
474
|
-
displayName:
|
|
807
|
+
var setupBodySchema = z6.object({
|
|
808
|
+
teamName: z6.string().min(1).max(120),
|
|
809
|
+
displayName: z6.string().min(1).max(120).default("Admin")
|
|
475
810
|
});
|
|
476
|
-
var setupResultSchema =
|
|
811
|
+
var setupResultSchema = z6.object({
|
|
477
812
|
team: teamSummarySchema,
|
|
478
|
-
user:
|
|
813
|
+
user: z6.object({ id: z6.string().uuid(), displayName: z6.string() }),
|
|
479
814
|
tokenString: tokenStringSchema,
|
|
480
|
-
scopes:
|
|
815
|
+
scopes: z6.array(tokenScopeSchema)
|
|
481
816
|
});
|
|
482
|
-
var teamLlmSettingsSchema =
|
|
483
|
-
keyConfigured:
|
|
484
|
-
keyHint:
|
|
485
|
-
lightModel:
|
|
486
|
-
heavyModel:
|
|
817
|
+
var teamLlmSettingsSchema = z6.object({
|
|
818
|
+
keyConfigured: z6.boolean(),
|
|
819
|
+
keyHint: z6.string().nullable(),
|
|
820
|
+
lightModel: z6.string(),
|
|
821
|
+
heavyModel: z6.string()
|
|
487
822
|
});
|
|
488
|
-
var teamLlmUpdateSchema =
|
|
489
|
-
openrouterApiKey:
|
|
490
|
-
lightModel:
|
|
491
|
-
heavyModel:
|
|
823
|
+
var teamLlmUpdateSchema = z6.object({
|
|
824
|
+
openrouterApiKey: z6.string().max(400).nullable().optional(),
|
|
825
|
+
lightModel: z6.string().min(1).max(200).optional(),
|
|
826
|
+
heavyModel: z6.string().min(1).max(200).optional()
|
|
492
827
|
});
|
|
493
|
-
var wcvpImportStatusSchema =
|
|
828
|
+
var wcvpImportStatusSchema = z6.enum([
|
|
494
829
|
"queued",
|
|
495
830
|
"downloading",
|
|
496
831
|
"importing",
|
|
@@ -498,68 +833,73 @@ var wcvpImportStatusSchema = z4.enum([
|
|
|
498
833
|
"failed",
|
|
499
834
|
"cancelled"
|
|
500
835
|
]);
|
|
501
|
-
var wcvpImportRunSchema =
|
|
502
|
-
id:
|
|
503
|
-
version:
|
|
504
|
-
sourceUrl:
|
|
836
|
+
var wcvpImportRunSchema = z6.object({
|
|
837
|
+
id: z6.string().uuid(),
|
|
838
|
+
version: z6.string(),
|
|
839
|
+
sourceUrl: z6.string(),
|
|
505
840
|
status: wcvpImportStatusSchema,
|
|
506
|
-
forceOverwrite:
|
|
507
|
-
bytesDownloaded:
|
|
508
|
-
totalBytes:
|
|
509
|
-
insertedCount:
|
|
510
|
-
recordCount:
|
|
511
|
-
message:
|
|
512
|
-
createdAt:
|
|
513
|
-
startedAt:
|
|
514
|
-
completedAt:
|
|
841
|
+
forceOverwrite: z6.boolean(),
|
|
842
|
+
bytesDownloaded: z6.number().int().nonnegative(),
|
|
843
|
+
totalBytes: z6.number().int().nonnegative().nullable(),
|
|
844
|
+
insertedCount: z6.number().int().nonnegative(),
|
|
845
|
+
recordCount: z6.number().int().nonnegative().nullable(),
|
|
846
|
+
message: z6.string().nullable(),
|
|
847
|
+
createdAt: z6.string().datetime(),
|
|
848
|
+
startedAt: z6.string().datetime().nullable(),
|
|
849
|
+
completedAt: z6.string().datetime().nullable()
|
|
515
850
|
});
|
|
516
|
-
var wcvpImportCreateBodySchema =
|
|
517
|
-
version:
|
|
851
|
+
var wcvpImportCreateBodySchema = z6.object({
|
|
852
|
+
version: z6.string().min(1).max(120),
|
|
518
853
|
/** Defaults to the Kew SFTP URL when omitted, so the admin doesn't have
|
|
519
854
|
* to remember it for routine v13/v14 imports. */
|
|
520
|
-
url:
|
|
521
|
-
force:
|
|
855
|
+
url: z6.string().url().optional(),
|
|
856
|
+
force: z6.boolean().optional()
|
|
522
857
|
});
|
|
523
|
-
var adminHealthErrorSchema =
|
|
524
|
-
id:
|
|
525
|
-
action:
|
|
526
|
-
entityType:
|
|
527
|
-
entityId:
|
|
528
|
-
timestamp:
|
|
529
|
-
metadata:
|
|
530
|
-
});
|
|
531
|
-
var
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
858
|
+
var adminHealthErrorSchema = z6.object({
|
|
859
|
+
id: z6.string().uuid(),
|
|
860
|
+
action: z6.string(),
|
|
861
|
+
entityType: z6.string(),
|
|
862
|
+
entityId: z6.string().nullable(),
|
|
863
|
+
timestamp: z6.string().datetime(),
|
|
864
|
+
metadata: z6.unknown().nullable()
|
|
865
|
+
});
|
|
866
|
+
var queueDepthSchema = z6.object({
|
|
867
|
+
waiting: z6.number().int().nonnegative(),
|
|
868
|
+
active: z6.number().int().nonnegative(),
|
|
869
|
+
delayed: z6.number().int().nonnegative(),
|
|
870
|
+
completed: z6.number().int().nonnegative(),
|
|
871
|
+
failed: z6.number().int().nonnegative(),
|
|
872
|
+
paused: z6.number().int().nonnegative()
|
|
873
|
+
});
|
|
874
|
+
var adminQueueSchema = z6.object({
|
|
875
|
+
queueDepth: queueDepthSchema,
|
|
876
|
+
workerCount: z6.number().int().nonnegative()
|
|
877
|
+
});
|
|
878
|
+
var adminHealthSchema = z6.object({
|
|
879
|
+
queueDepth: queueDepthSchema,
|
|
880
|
+
workerCount: z6.number().int().nonnegative(),
|
|
881
|
+
lastErrors: z6.array(adminHealthErrorSchema),
|
|
882
|
+
rateLimitBudget: z6.object({
|
|
883
|
+
max: z6.number().int().nonnegative(),
|
|
884
|
+
timeWindowSeconds: z6.number().int().nonnegative()
|
|
545
885
|
}),
|
|
546
|
-
snapshotVersion:
|
|
547
|
-
snapshotImportedAt:
|
|
886
|
+
snapshotVersion: z6.string().nullable(),
|
|
887
|
+
snapshotImportedAt: z6.string().datetime().nullable(),
|
|
548
888
|
/**
|
|
549
889
|
* Number of cached accepted-name mappings (L0.5 match-history rows) for the
|
|
550
890
|
* caller's team — the size of the team accepted-name cache.
|
|
551
891
|
*/
|
|
552
|
-
teamHistorySize:
|
|
892
|
+
teamHistorySize: z6.number().int().nonnegative(),
|
|
553
893
|
/**
|
|
554
894
|
* Overall (all-teams) tally of which cascade layer produced the winning
|
|
555
895
|
* candidate, across every matched query. `layer` is the raw attempt layer
|
|
556
896
|
* (`L1`…`L7`, `L0.5`, `OVERRIDE`); `count` is the number of matched queries
|
|
557
897
|
* that layer resolved. Descending by count.
|
|
558
898
|
*/
|
|
559
|
-
layerUsage:
|
|
560
|
-
|
|
561
|
-
layer:
|
|
562
|
-
count:
|
|
899
|
+
layerUsage: z6.array(
|
|
900
|
+
z6.object({
|
|
901
|
+
layer: z6.string(),
|
|
902
|
+
count: z6.number().int().nonnegative()
|
|
563
903
|
})
|
|
564
904
|
),
|
|
565
905
|
/**
|
|
@@ -569,18 +909,301 @@ var adminHealthSchema = z4.object({
|
|
|
569
909
|
* Lets the health page surface each external source's usage, hit/miss/error
|
|
570
910
|
* breakdown, latency, and recency — so a misbehaving provider is visible.
|
|
571
911
|
*/
|
|
572
|
-
externalProviders:
|
|
573
|
-
|
|
574
|
-
provider:
|
|
575
|
-
total:
|
|
576
|
-
hits:
|
|
577
|
-
misses:
|
|
578
|
-
errors:
|
|
579
|
-
avgDurationMs:
|
|
580
|
-
lastUsedAt:
|
|
912
|
+
externalProviders: z6.array(
|
|
913
|
+
z6.object({
|
|
914
|
+
provider: z6.string(),
|
|
915
|
+
total: z6.number().int().nonnegative(),
|
|
916
|
+
hits: z6.number().int().nonnegative(),
|
|
917
|
+
misses: z6.number().int().nonnegative(),
|
|
918
|
+
errors: z6.number().int().nonnegative(),
|
|
919
|
+
avgDurationMs: z6.number().int().nonnegative(),
|
|
920
|
+
lastUsedAt: z6.string().datetime().nullable()
|
|
581
921
|
})
|
|
582
922
|
)
|
|
583
923
|
});
|
|
924
|
+
var matchHistoryEntrySchema = z6.object({
|
|
925
|
+
id: z6.string().uuid(),
|
|
926
|
+
/** The normalized input name this mapping is keyed on. */
|
|
927
|
+
normalizedInput: z6.string(),
|
|
928
|
+
acceptedName: z6.string(),
|
|
929
|
+
acceptedIdentifier: z6.object({ namespace: z6.string(), value: z6.string() }).nullable(),
|
|
930
|
+
/** Resolved from the backbone snapshot at read time (null when the stored
|
|
931
|
+
* identifier no longer resolves): authorship + family of the accepted taxon,
|
|
932
|
+
* its identifiers (backbone id, IPNI LSID, portal url) and the portal link. */
|
|
933
|
+
acceptedAuthorship: z6.string().nullable(),
|
|
934
|
+
family: z6.string().nullable(),
|
|
935
|
+
acceptedIdentifiers: z6.array(z6.object({ namespace: z6.string(), value: z6.string() })),
|
|
936
|
+
targetUrl: z6.string().nullable(),
|
|
937
|
+
targetReferential: z6.string(),
|
|
938
|
+
referentialVersion: z6.string(),
|
|
939
|
+
independentUserAcceptanceCount: z6.number().int().nonnegative(),
|
|
940
|
+
rejectionCount: z6.number().int().nonnegative(),
|
|
941
|
+
blacklisted: z6.boolean(),
|
|
942
|
+
needsReconfirmation: z6.boolean(),
|
|
943
|
+
/** Currently usable as a cache hit (not blacklisted, acceptances > rejections). */
|
|
944
|
+
active: z6.boolean(),
|
|
945
|
+
/** Auto-accept (Grade A): acceptances ≥ the team's promotion threshold. */
|
|
946
|
+
promoted: z6.boolean(),
|
|
947
|
+
/** Label of the token that last reviewed this mapping (falls back to the
|
|
948
|
+
* reviewer's display name for rows predating token tracking). */
|
|
949
|
+
lastReviewedBy: z6.string().nullable(),
|
|
950
|
+
firstSeenAt: z6.string().datetime(),
|
|
951
|
+
lastAcceptedAt: z6.string().datetime().nullable()
|
|
952
|
+
});
|
|
953
|
+
var teamCacheResponseSchema = z6.object({
|
|
954
|
+
/** Distinct-user acceptances needed to promote a hit to Grade A. */
|
|
955
|
+
promotionThreshold: z6.number().int().positive(),
|
|
956
|
+
/** Total entries in the team cache (before the limit). */
|
|
957
|
+
total: z6.number().int().nonnegative(),
|
|
958
|
+
entries: z6.array(matchHistoryEntrySchema)
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// ../shared/src/schemas/device-auth.ts
|
|
962
|
+
import { z as z7 } from "zod";
|
|
963
|
+
var SLOW_DOWN_STEP_SECONDS = 5;
|
|
964
|
+
var deviceAuthorizationStatusSchema = z7.enum(["pending", "approved", "denied", "consumed"]);
|
|
965
|
+
var deviceAuthorizationCreateSchema = z7.object({
|
|
966
|
+
/** Shown to the approver so they recognise their own machine (the CLI sends its hostname). */
|
|
967
|
+
clientName: z7.string().trim().min(1).max(100)
|
|
968
|
+
});
|
|
969
|
+
var deviceAuthorizationCreatedSchema = z7.object({
|
|
970
|
+
deviceCode: z7.string(),
|
|
971
|
+
userCode: z7.string(),
|
|
972
|
+
verificationUri: z7.string(),
|
|
973
|
+
verificationUriComplete: z7.string(),
|
|
974
|
+
expiresIn: z7.number().int().positive(),
|
|
975
|
+
interval: z7.number().int().positive()
|
|
976
|
+
});
|
|
977
|
+
var deviceAuthorizationSchema = z7.object({
|
|
978
|
+
userCode: z7.string(),
|
|
979
|
+
clientName: z7.string(),
|
|
980
|
+
clientIp: z7.string().nullable(),
|
|
981
|
+
status: deviceAuthorizationStatusSchema,
|
|
982
|
+
scopes: z7.array(tokenScopeSchema),
|
|
983
|
+
createdAt: z7.string().datetime(),
|
|
984
|
+
expiresAt: z7.string().datetime()
|
|
985
|
+
});
|
|
986
|
+
var deviceAuthorizationDecisionSchema = z7.object({
|
|
987
|
+
status: z7.enum(["approved", "denied"])
|
|
988
|
+
});
|
|
989
|
+
var deviceTokenRequestSchema = z7.object({
|
|
990
|
+
deviceCode: z7.string().min(1).max(200)
|
|
991
|
+
});
|
|
992
|
+
var deviceTokenSchema = z7.object({
|
|
993
|
+
token: z7.string(),
|
|
994
|
+
label: z7.string(),
|
|
995
|
+
scopes: z7.array(tokenScopeSchema),
|
|
996
|
+
expiresAt: z7.string().datetime()
|
|
997
|
+
});
|
|
998
|
+
var deviceTokenErrorCodeSchema = z7.enum([
|
|
999
|
+
"authorization_pending",
|
|
1000
|
+
"slow_down",
|
|
1001
|
+
"expired_token",
|
|
1002
|
+
"access_denied"
|
|
1003
|
+
]);
|
|
1004
|
+
var deviceTokenErrorSchema = z7.object({
|
|
1005
|
+
error: deviceTokenErrorCodeSchema,
|
|
1006
|
+
/** Seconds to wait before the next poll, sent with `slow_down`. */
|
|
1007
|
+
interval: z7.number().int().positive().optional()
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// ../shared/src/schemas/backbone.ts
|
|
1011
|
+
import { z as z8 } from "zod";
|
|
1012
|
+
var backboneSchema = z8.enum(["wcvp", "wfo"]);
|
|
1013
|
+
var wfoImportStatusSchema = z8.enum([
|
|
1014
|
+
"queued",
|
|
1015
|
+
"downloading",
|
|
1016
|
+
"importing",
|
|
1017
|
+
"completed",
|
|
1018
|
+
"failed",
|
|
1019
|
+
"cancelled"
|
|
1020
|
+
]);
|
|
1021
|
+
var wfoImportRunSchema = z8.object({
|
|
1022
|
+
id: z8.string().uuid(),
|
|
1023
|
+
version: z8.string(),
|
|
1024
|
+
sourceUrl: z8.string(),
|
|
1025
|
+
status: wfoImportStatusSchema,
|
|
1026
|
+
forceOverwrite: z8.boolean(),
|
|
1027
|
+
bytesDownloaded: z8.number().int().nonnegative(),
|
|
1028
|
+
totalBytes: z8.number().int().nonnegative().nullable(),
|
|
1029
|
+
insertedCount: z8.number().int().nonnegative(),
|
|
1030
|
+
recordCount: z8.number().int().nonnegative().nullable(),
|
|
1031
|
+
message: z8.string().nullable(),
|
|
1032
|
+
createdAt: z8.string().datetime(),
|
|
1033
|
+
startedAt: z8.string().datetime().nullable(),
|
|
1034
|
+
completedAt: z8.string().datetime().nullable()
|
|
1035
|
+
});
|
|
1036
|
+
var wfoImportCreateBodySchema = z8.object({
|
|
1037
|
+
version: z8.string().min(1).max(120),
|
|
1038
|
+
/** Direct download URL (from the Zenodo version listing). Required for WFO
|
|
1039
|
+
* since versions aren't derivable from the label like WCVP's Kew URLs. */
|
|
1040
|
+
url: z8.string().url().optional(),
|
|
1041
|
+
force: z8.boolean().optional()
|
|
1042
|
+
});
|
|
1043
|
+
var snapshotKindSchema = z8.enum(["official", "derived"]);
|
|
1044
|
+
var wfoSnapshotSummarySchema = z8.object({
|
|
1045
|
+
version: z8.string(),
|
|
1046
|
+
recordCount: z8.number().int().nonnegative(),
|
|
1047
|
+
importedAt: z8.string().datetime(),
|
|
1048
|
+
isLatest: z8.boolean(),
|
|
1049
|
+
kind: snapshotKindSchema,
|
|
1050
|
+
baseVersion: z8.string().nullable(),
|
|
1051
|
+
label: z8.string().nullable(),
|
|
1052
|
+
ownerTeamId: z8.string().nullable()
|
|
1053
|
+
});
|
|
1054
|
+
var wfoVersionSchema = z8.object({
|
|
1055
|
+
/** Release label, e.g. "2025-12". */
|
|
1056
|
+
version: z8.string(),
|
|
1057
|
+
/** Zenodo record id for this version. */
|
|
1058
|
+
recordId: z8.string(),
|
|
1059
|
+
/** The plant-list archive filename inside the record. */
|
|
1060
|
+
fileName: z8.string(),
|
|
1061
|
+
/** Direct content download URL. */
|
|
1062
|
+
url: z8.string().url(),
|
|
1063
|
+
sizeBytes: z8.number().int().nonnegative().nullable(),
|
|
1064
|
+
publishedAt: z8.string().nullable(),
|
|
1065
|
+
isLatest: z8.boolean()
|
|
1066
|
+
});
|
|
1067
|
+
var wfoVersionListSchema = z8.array(wfoVersionSchema);
|
|
1068
|
+
var wcvpVersionSchema = z8.object({
|
|
1069
|
+
/** Snapshot label the import uses, e.g. "wcvp-v15". */
|
|
1070
|
+
version: z8.string(),
|
|
1071
|
+
/** Kew release number, e.g. 15. */
|
|
1072
|
+
release: z8.number().int().positive(),
|
|
1073
|
+
/** Direct download URL. */
|
|
1074
|
+
url: z8.string().url(),
|
|
1075
|
+
/** Size as Kew's directory index prints it, e.g. "85M". */
|
|
1076
|
+
sizeLabel: z8.string().nullable(),
|
|
1077
|
+
/** Last-modified timestamp from the index, "YYYY-MM-DD HH:MM". */
|
|
1078
|
+
publishedAt: z8.string().nullable()
|
|
1079
|
+
});
|
|
1080
|
+
var wcvpVersionListSchema = z8.array(wcvpVersionSchema);
|
|
1081
|
+
var backboneKeySchema = z8.enum(["wcvp", "wfo"]);
|
|
1082
|
+
var backboneDefaultsSchema = z8.object({
|
|
1083
|
+
wcvp: z8.string().nullable(),
|
|
1084
|
+
wfo: z8.string().nullable()
|
|
1085
|
+
});
|
|
1086
|
+
var backboneDefaultUpdateSchema = z8.object({
|
|
1087
|
+
backbone: backboneKeySchema,
|
|
1088
|
+
version: z8.string().min(1).nullable()
|
|
1089
|
+
});
|
|
1090
|
+
var DERIVED_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,40}$/;
|
|
1091
|
+
var MAX_BASE_VERSION_LENGTH = 120;
|
|
1092
|
+
var MAX_SNAPSHOT_VERSION_LENGTH = MAX_BASE_VERSION_LENGTH + 1 + 41;
|
|
1093
|
+
var derivedUploadMetaSchema = z8.object({
|
|
1094
|
+
baseVersion: z8.string().min(1).max(MAX_BASE_VERSION_LENGTH),
|
|
1095
|
+
slug: z8.string().regex(DERIVED_SLUG_RE, "slug: lowercase letters, digits and dashes (2\u201341 chars)"),
|
|
1096
|
+
label: z8.string().trim().min(1).max(120),
|
|
1097
|
+
notes: z8.string().trim().max(2e3).optional()
|
|
1098
|
+
});
|
|
1099
|
+
var derivedUploadStatusSchema = z8.enum([
|
|
1100
|
+
"staging",
|
|
1101
|
+
"ready",
|
|
1102
|
+
"invalid",
|
|
1103
|
+
"importing",
|
|
1104
|
+
"imported",
|
|
1105
|
+
"failed",
|
|
1106
|
+
"discarded"
|
|
1107
|
+
]);
|
|
1108
|
+
var derivedIssueSeveritySchema = z8.enum(["error", "warning"]);
|
|
1109
|
+
var derivedIssueSchema = z8.object({
|
|
1110
|
+
code: z8.string(),
|
|
1111
|
+
severity: derivedIssueSeveritySchema,
|
|
1112
|
+
count: z8.number().int().nonnegative(),
|
|
1113
|
+
/** Free-form detail for the UI (e.g. the offending vocabulary values). */
|
|
1114
|
+
detail: z8.string().nullable(),
|
|
1115
|
+
samples: z8.array(
|
|
1116
|
+
z8.object({
|
|
1117
|
+
taxonId: z8.string().nullable(),
|
|
1118
|
+
name: z8.string().nullable(),
|
|
1119
|
+
detail: z8.string().nullable(),
|
|
1120
|
+
line: z8.number().int().nullable()
|
|
1121
|
+
})
|
|
1122
|
+
)
|
|
1123
|
+
});
|
|
1124
|
+
var derivedDiffFieldSchema = z8.enum([
|
|
1125
|
+
"canonical_name",
|
|
1126
|
+
"scientific_name",
|
|
1127
|
+
"authorship",
|
|
1128
|
+
"rank",
|
|
1129
|
+
"taxonomic_status",
|
|
1130
|
+
"accepted_name_usage_id",
|
|
1131
|
+
"parent_name_usage_id",
|
|
1132
|
+
"family"
|
|
1133
|
+
]);
|
|
1134
|
+
var DERIVED_DIFF_FIELDS = derivedDiffFieldSchema.options;
|
|
1135
|
+
var diffSampleSchema = z8.object({ taxonId: z8.string(), name: z8.string().nullable() });
|
|
1136
|
+
var changedSampleSchema = diffSampleSchema.extend({
|
|
1137
|
+
changes: z8.array(
|
|
1138
|
+
z8.object({ field: derivedDiffFieldSchema, before: z8.string().nullable(), after: z8.string().nullable() })
|
|
1139
|
+
)
|
|
1140
|
+
});
|
|
1141
|
+
var derivedReportSchema = z8.object({
|
|
1142
|
+
rowsRead: z8.number().int().nonnegative(),
|
|
1143
|
+
rowsStaged: z8.number().int().nonnegative(),
|
|
1144
|
+
rowsRejected: z8.number().int().nonnegative(),
|
|
1145
|
+
baseRowCount: z8.number().int().nonnegative(),
|
|
1146
|
+
errors: z8.number().int().nonnegative(),
|
|
1147
|
+
warnings: z8.number().int().nonnegative(),
|
|
1148
|
+
issues: z8.array(derivedIssueSchema),
|
|
1149
|
+
diff: z8.object({
|
|
1150
|
+
added: z8.object({ count: z8.number().int().nonnegative(), samples: z8.array(diffSampleSchema) }),
|
|
1151
|
+
removed: z8.object({ count: z8.number().int().nonnegative(), samples: z8.array(diffSampleSchema) }),
|
|
1152
|
+
changed: z8.object({
|
|
1153
|
+
count: z8.number().int().nonnegative(),
|
|
1154
|
+
byField: z8.record(z8.string(), z8.number().int().nonnegative()),
|
|
1155
|
+
samples: z8.array(changedSampleSchema)
|
|
1156
|
+
}),
|
|
1157
|
+
unchanged: z8.number().int().nonnegative()
|
|
1158
|
+
})
|
|
1159
|
+
});
|
|
1160
|
+
var derivedUploadSchema = z8.object({
|
|
1161
|
+
id: z8.string().uuid(),
|
|
1162
|
+
backbone: backboneSchema,
|
|
1163
|
+
baseVersion: z8.string(),
|
|
1164
|
+
slug: z8.string(),
|
|
1165
|
+
label: z8.string(),
|
|
1166
|
+
notes: z8.string().nullable(),
|
|
1167
|
+
/** The snapshot version this upload installs as (`<base>+<slug>`). */
|
|
1168
|
+
version: z8.string(),
|
|
1169
|
+
originalFilename: z8.string().nullable(),
|
|
1170
|
+
sha256: z8.string(),
|
|
1171
|
+
sizeBytes: z8.number().int().nonnegative(),
|
|
1172
|
+
status: derivedUploadStatusSchema,
|
|
1173
|
+
rowsRead: z8.number().int().nonnegative(),
|
|
1174
|
+
report: derivedReportSchema.nullable(),
|
|
1175
|
+
message: z8.string().nullable(),
|
|
1176
|
+
createdAt: z8.string().datetime(),
|
|
1177
|
+
expiresAt: z8.string().datetime(),
|
|
1178
|
+
importedVersion: z8.string().nullable()
|
|
1179
|
+
});
|
|
1180
|
+
var derivedConfirmBodySchema = z8.object({
|
|
1181
|
+
/** Acknowledge warnings. Never overrides errors. */
|
|
1182
|
+
force: z8.boolean().optional()
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
// ../shared/src/schemas/openrouter.ts
|
|
1186
|
+
import { z as z9 } from "zod";
|
|
1187
|
+
var openRouterModelSchema = z9.object({
|
|
1188
|
+
/** Full model id, e.g. `anthropic/claude-opus-4.8` or the alias
|
|
1189
|
+
* `~anthropic/claude-haiku-latest`. Used verbatim as the OpenRouter model. */
|
|
1190
|
+
id: z9.string(),
|
|
1191
|
+
/** Human label from OpenRouter (falls back to the id). */
|
|
1192
|
+
name: z9.string(),
|
|
1193
|
+
/** Author slug (the part before `/`), with any leading `~` stripped. */
|
|
1194
|
+
author: z9.string(),
|
|
1195
|
+
/** Unix seconds the model was published; used to rank "latest". */
|
|
1196
|
+
created: z9.number(),
|
|
1197
|
+
/** Context window in tokens, when OpenRouter reports it. */
|
|
1198
|
+
contextLength: z9.number().nullable(),
|
|
1199
|
+
/** USD price per PROMPT token (input). Null when OpenRouter doesn't report a
|
|
1200
|
+
* numeric price. 0 = free. */
|
|
1201
|
+
promptPriceUsd: z9.number().nullable(),
|
|
1202
|
+
/** USD price per COMPLETION token (output). */
|
|
1203
|
+
completionPriceUsd: z9.number().nullable(),
|
|
1204
|
+
/** An auto-updating `…-latest` pointer (e.g. `~anthropic/claude-haiku-latest`). */
|
|
1205
|
+
isAlias: z9.boolean()
|
|
1206
|
+
});
|
|
584
1207
|
|
|
585
1208
|
// ../shared/src/normalize.ts
|
|
586
1209
|
var NULL_SENTINELS = /* @__PURE__ */ new Set(["", "na", "n/a", "null", "-", "\u2014", "unknown", "undet", "undet."]);
|
|
@@ -708,183 +1331,1175 @@ function detectIdTypeDistribution(samples, opts) {
|
|
|
708
1331
|
}
|
|
709
1332
|
|
|
710
1333
|
// ../shared/src/column-map.ts
|
|
711
|
-
import { z as
|
|
712
|
-
var columnMappingSchema =
|
|
713
|
-
nameColumn:
|
|
714
|
-
idColumn:
|
|
715
|
-
familyColumn:
|
|
716
|
-
genusColumn:
|
|
717
|
-
rankColumn:
|
|
718
|
-
authorColumn:
|
|
719
|
-
});
|
|
720
|
-
var detectColumnsBodySchema =
|
|
721
|
-
headers:
|
|
722
|
-
});
|
|
723
|
-
var detectColumnsResponseSchema =
|
|
1334
|
+
import { z as z10 } from "zod";
|
|
1335
|
+
var columnMappingSchema = z10.object({
|
|
1336
|
+
nameColumn: z10.string().nullable(),
|
|
1337
|
+
idColumn: z10.string().nullable(),
|
|
1338
|
+
familyColumn: z10.string().nullable(),
|
|
1339
|
+
genusColumn: z10.string().nullable(),
|
|
1340
|
+
rankColumn: z10.string().nullable(),
|
|
1341
|
+
authorColumn: z10.string().nullable()
|
|
1342
|
+
});
|
|
1343
|
+
var detectColumnsBodySchema = z10.object({
|
|
1344
|
+
headers: z10.array(z10.string().min(1)).min(1).max(200)
|
|
1345
|
+
});
|
|
1346
|
+
var detectColumnsResponseSchema = z10.object({
|
|
724
1347
|
mapping: columnMappingSchema,
|
|
725
|
-
usedLlm:
|
|
1348
|
+
usedLlm: z10.boolean()
|
|
726
1349
|
});
|
|
727
1350
|
|
|
728
|
-
// src/
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1351
|
+
// ../shared/src/env-flag.ts
|
|
1352
|
+
var FALSY = /* @__PURE__ */ new Set(["false", "0", "no", "off"]);
|
|
1353
|
+
function parseEnvFlag(raw, fallback) {
|
|
1354
|
+
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
1355
|
+
return !FALSY.has(raw.trim().toLowerCase());
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// ../shared/src/public-server.ts
|
|
1359
|
+
var DEFAULT_SERVER_URL = "https://planttaxomatcher.plantnet.org";
|
|
1360
|
+
|
|
1361
|
+
// src/errors.ts
|
|
1362
|
+
import { CommanderError } from "commander";
|
|
1363
|
+
var EXIT = {
|
|
1364
|
+
ok: 0,
|
|
1365
|
+
error: 1,
|
|
1366
|
+
usage: 2,
|
|
1367
|
+
auth: 3,
|
|
1368
|
+
jobFailed: 4,
|
|
1369
|
+
jobPaused: 5
|
|
1370
|
+
};
|
|
1371
|
+
var LOGIN_HINT = "Run `planttaxomatcher login`, or set PLANTTAXOMATCHER_TOKEN.";
|
|
1372
|
+
var CliError = class extends Error {
|
|
1373
|
+
constructor(message, exitCode = EXIT.error, hint) {
|
|
734
1374
|
super(message);
|
|
1375
|
+
this.exitCode = exitCode;
|
|
1376
|
+
this.hint = hint;
|
|
1377
|
+
}
|
|
1378
|
+
exitCode;
|
|
1379
|
+
hint;
|
|
1380
|
+
};
|
|
1381
|
+
var ApiError = class extends Error {
|
|
1382
|
+
constructor(status, body) {
|
|
1383
|
+
super(describeApiFailure(status, body));
|
|
735
1384
|
this.status = status;
|
|
736
1385
|
this.body = body;
|
|
737
1386
|
}
|
|
738
1387
|
status;
|
|
739
1388
|
body;
|
|
740
1389
|
};
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
const
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
...hasBody ? { "content-type": "application/json" } : {}
|
|
749
|
-
},
|
|
750
|
-
...hasBody ? { body: JSON.stringify(init.body) } : {}
|
|
751
|
-
});
|
|
752
|
-
const text = await res.body.text();
|
|
753
|
-
const parsed = text ? safeJson(text) : null;
|
|
754
|
-
if (res.statusCode >= 400) {
|
|
755
|
-
throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode, parsed ?? text);
|
|
1390
|
+
function describeApiFailure(status, body) {
|
|
1391
|
+
const parsed = body && typeof body === "object" ? body : {};
|
|
1392
|
+
const text = typeof body === "string" ? body.trim() : "";
|
|
1393
|
+
const code = typeof parsed.error === "string" ? parsed.error : "";
|
|
1394
|
+
const message = typeof parsed.message === "string" ? parsed.message : "";
|
|
1395
|
+
if (code === "missing_scope" && Array.isArray(parsed.required)) {
|
|
1396
|
+
return `HTTP ${status}: this token lacks the ${parsed.required.join(", ")} scope`;
|
|
756
1397
|
}
|
|
757
|
-
|
|
1398
|
+
if (text.startsWith("<"))
|
|
1399
|
+
return `HTTP ${status}: the server answered with a web page, not the API \u2014 check the server URL`;
|
|
1400
|
+
const detail = message || code || text.slice(0, 200);
|
|
1401
|
+
return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
|
|
1402
|
+
}
|
|
1403
|
+
function describeError(err) {
|
|
1404
|
+
if (err instanceof CommanderError) {
|
|
1405
|
+
return { exitCode: err.exitCode === 0 ? EXIT.ok : EXIT.usage, message: null };
|
|
1406
|
+
}
|
|
1407
|
+
if (err instanceof CliError) {
|
|
1408
|
+
return { exitCode: err.exitCode, message: err.message, ...err.hint ? { hint: err.hint } : {} };
|
|
1409
|
+
}
|
|
1410
|
+
if (err instanceof ApiError) {
|
|
1411
|
+
if (err.status === 401) return { exitCode: EXIT.auth, message: err.message, hint: LOGIN_HINT };
|
|
1412
|
+
return { exitCode: EXIT.error, message: err.message };
|
|
1413
|
+
}
|
|
1414
|
+
return { exitCode: EXIT.error, message: err instanceof Error ? err.message : String(err) };
|
|
1415
|
+
}
|
|
1416
|
+
function unreachable(server, err) {
|
|
1417
|
+
const cause = err instanceof Error && err.cause instanceof Error ? err.cause : err;
|
|
1418
|
+
const code = cause?.code;
|
|
1419
|
+
const reason = code ?? (cause instanceof Error ? cause.message : String(cause));
|
|
1420
|
+
return new CliError(`cannot reach ${server}: ${reason}`);
|
|
758
1421
|
}
|
|
759
|
-
|
|
1422
|
+
|
|
1423
|
+
// src/ndjson.ts
|
|
1424
|
+
async function* parseNdjson(chunks) {
|
|
1425
|
+
const decoder = new TextDecoder("utf-8");
|
|
1426
|
+
let buffered = "";
|
|
1427
|
+
for await (const chunk of chunks) {
|
|
1428
|
+
buffered += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
1429
|
+
let newline;
|
|
1430
|
+
while ((newline = buffered.indexOf("\n")) >= 0) {
|
|
1431
|
+
const frame = parseLine(buffered.slice(0, newline));
|
|
1432
|
+
buffered = buffered.slice(newline + 1);
|
|
1433
|
+
if (frame) yield frame;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
const last = parseLine(buffered + decoder.decode());
|
|
1437
|
+
if (last) yield last;
|
|
1438
|
+
}
|
|
1439
|
+
function parseLine(line) {
|
|
1440
|
+
const trimmed = line.trim();
|
|
1441
|
+
if (!trimmed) return null;
|
|
760
1442
|
try {
|
|
761
|
-
|
|
1443
|
+
const value = JSON.parse(trimmed);
|
|
1444
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
762
1445
|
} catch {
|
|
763
1446
|
return null;
|
|
764
1447
|
}
|
|
765
1448
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1449
|
+
|
|
1450
|
+
// src/api-client.ts
|
|
1451
|
+
var MIME_BY_EXTENSION = {
|
|
1452
|
+
json: "application/json",
|
|
1453
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1454
|
+
xls: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
1455
|
+
};
|
|
1456
|
+
function uploadMimeType(filePath) {
|
|
1457
|
+
const extension = filePath.toLowerCase().split(".").pop() ?? "";
|
|
1458
|
+
return MIME_BY_EXTENSION[extension] ?? "text/csv";
|
|
1459
|
+
}
|
|
1460
|
+
function downloadFilename(contentDisposition, jobId, opts) {
|
|
1461
|
+
const match = /filename="?([^";]+)"?/.exec(contentDisposition ?? "");
|
|
1462
|
+
const suggested = match?.[1] ? basename(match[1].replaceAll("\\", "/")) : "";
|
|
1463
|
+
if (suggested && suggested !== "." && suggested !== "..") return suggested;
|
|
1464
|
+
return `planttaxomatcher_${jobId}.${opts.bundle ? "zip" : opts.format}`;
|
|
1465
|
+
}
|
|
1466
|
+
function downloadQuery(opts) {
|
|
1467
|
+
const params = new URLSearchParams({ format: opts.format });
|
|
1468
|
+
if (opts.confirmedOnly) params.set("confirmedOnly", "true");
|
|
1469
|
+
if (opts.dedupe) params.set("dedupe", "true");
|
|
1470
|
+
if (opts.bundle) params.set("bundle", "true");
|
|
1471
|
+
if (opts.delimiter) params.set("delimiter", opts.delimiter);
|
|
1472
|
+
if (opts.columns) params.set("columns", opts.columns);
|
|
1473
|
+
if (opts.wcvpExtra) params.set("wcvpExtra", opts.wcvpExtra);
|
|
1474
|
+
return params;
|
|
1475
|
+
}
|
|
1476
|
+
function notAnApi(server, path) {
|
|
1477
|
+
return new CliError(`${server} did not answer ${path} like a PlantTaxoMatcher API \u2014 check the server URL`);
|
|
1478
|
+
}
|
|
1479
|
+
function safeJson(text) {
|
|
1480
|
+
try {
|
|
1481
|
+
return JSON.parse(text);
|
|
1482
|
+
} catch {
|
|
1483
|
+
return null;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
function createApiClient(options = {}) {
|
|
1487
|
+
const dispatcher = options.dispatcher ? { dispatcher: options.dispatcher } : {};
|
|
1488
|
+
async function send(target, path, init = {}) {
|
|
1489
|
+
const headers = target.token ? { authorization: `Bearer ${target.token}` } : {};
|
|
1490
|
+
if (typeof init.body === "string") headers["content-type"] = "application/json";
|
|
1491
|
+
try {
|
|
1492
|
+
return await fetch(new URL(path, target.server), {
|
|
1493
|
+
method: init.method ?? "GET",
|
|
1494
|
+
headers,
|
|
1495
|
+
...init.body !== void 0 ? { body: init.body } : {},
|
|
1496
|
+
...dispatcher
|
|
1497
|
+
});
|
|
1498
|
+
} catch (err) {
|
|
1499
|
+
throw unreachable(target.server, err);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
async function call(creds, path, init = {}) {
|
|
1503
|
+
const res = await send(creds, path, {
|
|
1504
|
+
...init.method ? { method: init.method } : {},
|
|
1505
|
+
...init.json !== void 0 ? { body: JSON.stringify(init.json) } : {}
|
|
787
1506
|
});
|
|
788
1507
|
const text = await res.text();
|
|
789
1508
|
const parsed = text ? safeJson(text) : null;
|
|
790
|
-
if (!res.ok) throw new ApiError(
|
|
1509
|
+
if (!res.ok) throw new ApiError(res.status, parsed ?? text);
|
|
1510
|
+
if (parsed === null) throw notAnApi(creds.server, path);
|
|
791
1511
|
return parsed;
|
|
792
|
-
}
|
|
793
|
-
async
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
},
|
|
813
|
-
async *streamJob(creds, id) {
|
|
814
|
-
const url = new URL(`/v1/jobs/${id}/stream`, creds.server).toString();
|
|
815
|
-
const res = await fetch(url, { headers: { authorization: `Bearer ${creds.token}` } });
|
|
816
|
-
if (!res.ok || !res.body) {
|
|
817
|
-
const text = await res.text().catch(() => "");
|
|
818
|
-
throw new ApiError(`HTTP ${res.status}`, res.status, text);
|
|
819
|
-
}
|
|
820
|
-
const reader = res.body.getReader();
|
|
821
|
-
const decoder = new TextDecoder("utf-8");
|
|
822
|
-
let buf = "";
|
|
823
|
-
try {
|
|
824
|
-
for (; ; ) {
|
|
825
|
-
const { done, value } = await reader.read();
|
|
826
|
-
if (done) break;
|
|
827
|
-
buf += decoder.decode(value, { stream: true });
|
|
828
|
-
let nl;
|
|
829
|
-
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
830
|
-
const line = buf.slice(0, nl).trim();
|
|
831
|
-
buf = buf.slice(nl + 1);
|
|
832
|
-
if (!line) continue;
|
|
833
|
-
try {
|
|
834
|
-
yield JSON.parse(line);
|
|
835
|
-
} catch {
|
|
836
|
-
}
|
|
1512
|
+
}
|
|
1513
|
+
async function failWith(res) {
|
|
1514
|
+
const text = await res.text().catch(() => "");
|
|
1515
|
+
throw new ApiError(res.status, (text && safeJson(text)) ?? text);
|
|
1516
|
+
}
|
|
1517
|
+
return {
|
|
1518
|
+
async startDeviceAuthorization(server, clientName) {
|
|
1519
|
+
let created;
|
|
1520
|
+
try {
|
|
1521
|
+
created = await call({ server }, "/v1/device-authorizations", {
|
|
1522
|
+
method: "POST",
|
|
1523
|
+
json: { clientName }
|
|
1524
|
+
});
|
|
1525
|
+
} catch (err) {
|
|
1526
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
1527
|
+
throw new CliError(
|
|
1528
|
+
`${server} does not offer browser sign-in`,
|
|
1529
|
+
EXIT.usage,
|
|
1530
|
+
"Pass a personal token instead: `planttaxomatcher login --token-stdin`."
|
|
1531
|
+
);
|
|
837
1532
|
}
|
|
1533
|
+
throw err;
|
|
838
1534
|
}
|
|
839
|
-
|
|
840
|
-
|
|
1535
|
+
const checked = deviceAuthorizationCreatedSchema.safeParse(created);
|
|
1536
|
+
if (!checked.success) throw notAnApi(server, "/v1/device-authorizations");
|
|
1537
|
+
return checked.data;
|
|
1538
|
+
},
|
|
1539
|
+
/** One poll: the token once approved, else the RFC 8628 reason to keep waiting or stop. */
|
|
1540
|
+
async pollDeviceToken(server, deviceCode) {
|
|
1541
|
+
const res = await send({ server }, "/v1/device-tokens", {
|
|
1542
|
+
method: "POST",
|
|
1543
|
+
body: JSON.stringify({ deviceCode })
|
|
841
1544
|
});
|
|
1545
|
+
const text = await res.text();
|
|
1546
|
+
const parsed = text ? safeJson(text) : null;
|
|
1547
|
+
if (res.status === 400) {
|
|
1548
|
+
const refusal = deviceTokenErrorSchema.safeParse(parsed);
|
|
1549
|
+
if (refusal.success) return refusal.data;
|
|
1550
|
+
}
|
|
1551
|
+
if (!res.ok) throw new ApiError(res.status, parsed ?? text);
|
|
1552
|
+
const token = deviceTokenSchema.safeParse(parsed);
|
|
1553
|
+
if (!token.success) throw notAnApi(server, "/v1/device-tokens");
|
|
1554
|
+
return token.data;
|
|
1555
|
+
},
|
|
1556
|
+
async me(c) {
|
|
1557
|
+
const checked = meSchema.safeParse(await call(c, "/v1/me"));
|
|
1558
|
+
if (!checked.success) throw notAnApi(c.server, "/v1/me");
|
|
1559
|
+
return checked.data;
|
|
1560
|
+
},
|
|
1561
|
+
listJobs(c, opts = {}) {
|
|
1562
|
+
const params = new URLSearchParams();
|
|
1563
|
+
if (opts.limit !== void 0) params.set("limit", String(opts.limit));
|
|
1564
|
+
if (opts.status) params.set("status", opts.status);
|
|
1565
|
+
const query = params.size > 0 ? `?${params}` : "";
|
|
1566
|
+
return call(c, `/v1/jobs${query}`);
|
|
1567
|
+
},
|
|
1568
|
+
getJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}`),
|
|
1569
|
+
downloadColumns: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/download/columns`),
|
|
1570
|
+
pauseJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/pause`, { method: "POST" }),
|
|
1571
|
+
resumeJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/resume`, { method: "POST" }),
|
|
1572
|
+
cancelJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
|
1573
|
+
async submitJob(c, filePath, config, name) {
|
|
1574
|
+
const form = new FormData();
|
|
1575
|
+
form.set("config", JSON.stringify(config));
|
|
1576
|
+
if (name) form.set("name", name);
|
|
1577
|
+
form.set("file", await openAsBlob(filePath, { type: uploadMimeType(filePath) }), basename(filePath));
|
|
1578
|
+
const res = await send(c, "/v1/jobs", { method: "POST", body: form });
|
|
1579
|
+
if (!res.ok) return failWith(res);
|
|
1580
|
+
return await res.json();
|
|
1581
|
+
},
|
|
1582
|
+
async downloadJob(c, id, opts) {
|
|
1583
|
+
const res = await send(c, `/v1/jobs/${encodeURIComponent(id)}/download?${downloadQuery(opts)}`);
|
|
1584
|
+
if (!res.ok) return failWith(res);
|
|
1585
|
+
const filename = downloadFilename(res.headers.get("content-disposition"), id, opts);
|
|
1586
|
+
return { filename, body: Buffer.from(await res.arrayBuffer()) };
|
|
1587
|
+
},
|
|
1588
|
+
async *streamJob(c, id) {
|
|
1589
|
+
const res = await send(c, `/v1/jobs/${encodeURIComponent(id)}/stream`);
|
|
1590
|
+
if (!res.ok || !res.body) return failWith(res);
|
|
1591
|
+
const reader = res.body.getReader();
|
|
1592
|
+
const chunks = {
|
|
1593
|
+
async *[Symbol.asyncIterator]() {
|
|
1594
|
+
for (; ; ) {
|
|
1595
|
+
const { done, value } = await reader.read();
|
|
1596
|
+
if (done) return;
|
|
1597
|
+
yield value;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
try {
|
|
1602
|
+
yield* parseNdjson(chunks);
|
|
1603
|
+
} finally {
|
|
1604
|
+
await reader.cancel().catch(() => {
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
842
1607
|
}
|
|
843
|
-
}
|
|
844
|
-
}
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
845
1610
|
|
|
846
1611
|
// src/config.ts
|
|
847
|
-
import { promises as
|
|
1612
|
+
import { promises as fs } from "fs";
|
|
848
1613
|
import { homedir } from "os";
|
|
849
1614
|
import { join } from "path";
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1615
|
+
|
|
1616
|
+
// src/transport.ts
|
|
1617
|
+
function isLoopback(hostname2) {
|
|
1618
|
+
return hostname2 === "localhost" || hostname2 === "127.0.0.1" || hostname2 === "[::1]" || hostname2 === "::1" || hostname2.endsWith(".localhost");
|
|
1619
|
+
}
|
|
1620
|
+
function assertServerTransport(server, options = { allowInsecure: false }) {
|
|
1621
|
+
let url;
|
|
1622
|
+
try {
|
|
1623
|
+
url = new URL(server);
|
|
1624
|
+
} catch {
|
|
1625
|
+
throw new CliError(`invalid server URL: ${server}`, EXIT.usage);
|
|
1626
|
+
}
|
|
1627
|
+
if (url.protocol === "https:") return;
|
|
1628
|
+
if (url.protocol !== "http:") {
|
|
1629
|
+
throw new CliError(`server must use http or https (got ${url.protocol})`, EXIT.usage);
|
|
1630
|
+
}
|
|
1631
|
+
if (!isLoopback(url.hostname) && !options.allowInsecure) {
|
|
1632
|
+
const override = options.insecureFlag ? ", or pass --insecure to override (NOT recommended)" : "";
|
|
1633
|
+
throw new CliError(
|
|
1634
|
+
`refusing to send a token in cleartext to ${url.host}. Use an https URL${override}.`,
|
|
1635
|
+
EXIT.usage
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// src/config.ts
|
|
1641
|
+
function defaultConfigDir() {
|
|
1642
|
+
return join(homedir(), ".config", "planttaxomatcher");
|
|
1643
|
+
}
|
|
1644
|
+
function createCredentialStore(dir = defaultConfigDir()) {
|
|
1645
|
+
const file = join(dir, "credentials");
|
|
1646
|
+
return {
|
|
1647
|
+
file,
|
|
1648
|
+
async read() {
|
|
1649
|
+
try {
|
|
1650
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
1651
|
+
} catch (err) {
|
|
1652
|
+
if (err.code === "ENOENT") return null;
|
|
1653
|
+
throw err;
|
|
1654
|
+
}
|
|
1655
|
+
},
|
|
1656
|
+
async write(creds) {
|
|
1657
|
+
await fs.mkdir(dir, { recursive: true, mode: 448 });
|
|
1658
|
+
await fs.writeFile(file, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
1659
|
+
await fs.chmod(file, 384);
|
|
1660
|
+
},
|
|
1661
|
+
async clear() {
|
|
1662
|
+
await fs.rm(file, { force: true });
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
function envValue(env2, key) {
|
|
1667
|
+
const value = env2[key]?.trim();
|
|
1668
|
+
return value ? value : void 0;
|
|
1669
|
+
}
|
|
1670
|
+
function serverFromEnv(env2) {
|
|
1671
|
+
return envValue(env2, "PLANTTAXOMATCHER_SERVER");
|
|
1672
|
+
}
|
|
1673
|
+
function tokenFromEnv(env2) {
|
|
1674
|
+
return envValue(env2, "PLANTTAXOMATCHER_TOKEN");
|
|
1675
|
+
}
|
|
1676
|
+
function sameServer(a, b) {
|
|
1677
|
+
const normalize = (url) => url.trim().replace(/\/+$/, "").toLowerCase();
|
|
1678
|
+
return normalize(a) === normalize(b);
|
|
1679
|
+
}
|
|
1680
|
+
function resolveCredentials(stored, env2) {
|
|
1681
|
+
const envToken = tokenFromEnv(env2);
|
|
1682
|
+
const envServer = serverFromEnv(env2);
|
|
1683
|
+
if (envToken) return { token: envToken, server: envServer ?? DEFAULT_SERVER_URL };
|
|
1684
|
+
if (!stored) return null;
|
|
1685
|
+
if (envServer && !sameServer(envServer, stored.server)) {
|
|
1686
|
+
throw new CliError(
|
|
1687
|
+
`PLANTTAXOMATCHER_SERVER (${envServer}) is not the server of the saved login (${stored.server})`,
|
|
1688
|
+
EXIT.usage,
|
|
1689
|
+
"Set PLANTTAXOMATCHER_TOKEN for that server too, or run `planttaxomatcher login --server <url>`."
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
return stored;
|
|
1693
|
+
}
|
|
1694
|
+
async function requireCredentials(store, env2) {
|
|
1695
|
+
const envServer = serverFromEnv(env2);
|
|
1696
|
+
if (envServer) assertServerTransport(envServer, { allowInsecure: false });
|
|
1697
|
+
const creds = resolveCredentials(await store.read(), env2);
|
|
1698
|
+
if (!creds) throw new CliError("not signed in", EXIT.auth, LOGIN_HINT);
|
|
1699
|
+
return creds;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// src/skills/paths.ts
|
|
1703
|
+
import { join as join2 } from "path";
|
|
1704
|
+
var SKILL_NAME = "planttaxomatcher";
|
|
1705
|
+
var MARKER_FILE = ".planttaxomatcher-managed";
|
|
1706
|
+
var AGENTS = ["claude", "codex", "opencode", "pi"];
|
|
1707
|
+
var SKILL_DIRS = ["claude", "agents", "opencode", "pi"];
|
|
1708
|
+
var env = (value) => value?.trim() ? value.trim() : void 0;
|
|
1709
|
+
function skillPaths(home, vars) {
|
|
1710
|
+
const dataHome = env(vars.XDG_DATA_HOME) ?? join2(home, ".local", "share");
|
|
1711
|
+
const configHome = env(vars.XDG_CONFIG_HOME) ?? join2(home, ".config");
|
|
1712
|
+
const claudeHome = env(vars.CLAUDE_CONFIG_DIR) ?? join2(home, ".claude");
|
|
1713
|
+
return {
|
|
1714
|
+
canonical: join2(dataHome, "planttaxomatcher", "skills", SKILL_NAME),
|
|
1715
|
+
entries: {
|
|
1716
|
+
claude: join2(claudeHome, "skills", SKILL_NAME),
|
|
1717
|
+
agents: join2(home, ".agents", "skills", SKILL_NAME),
|
|
1718
|
+
opencode: join2(configHome, "opencode", "skills", SKILL_NAME),
|
|
1719
|
+
pi: join2(home, ".pi", "agent", "skills", SKILL_NAME)
|
|
1720
|
+
},
|
|
1721
|
+
agentHomes: {
|
|
1722
|
+
claude: claudeHome,
|
|
1723
|
+
codex: join2(home, ".codex"),
|
|
1724
|
+
opencode: join2(configHome, "opencode"),
|
|
1725
|
+
pi: join2(home, ".pi")
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
var READS = {
|
|
1730
|
+
claude: ["claude"],
|
|
1731
|
+
codex: ["agents"],
|
|
1732
|
+
opencode: ["opencode", "agents", "claude"],
|
|
1733
|
+
pi: ["pi", "agents"]
|
|
1734
|
+
};
|
|
1735
|
+
function linkPlan(agents, alreadyLinked) {
|
|
1736
|
+
const plan = /* @__PURE__ */ new Set();
|
|
1737
|
+
if (agents.includes("claude")) plan.add("claude");
|
|
1738
|
+
if (agents.includes("codex") || agents.includes("pi")) plan.add("agents");
|
|
1739
|
+
const openCodeCovered = ["claude", "agents"].some(
|
|
1740
|
+
(dir) => plan.has(dir) || alreadyLinked.has(dir)
|
|
1741
|
+
);
|
|
1742
|
+
if (agents.includes("opencode") && !openCodeCovered) plan.add("opencode");
|
|
1743
|
+
return [...plan];
|
|
1744
|
+
}
|
|
1745
|
+
function parseAgents(value) {
|
|
1746
|
+
const names = value.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
|
|
1747
|
+
if (names.includes("all")) return "all";
|
|
1748
|
+
return names.length > 0 && names.every((name) => AGENTS.includes(name)) ? names : null;
|
|
1749
|
+
}
|
|
1750
|
+
function isNewerVersion(candidate, current) {
|
|
1751
|
+
const parse = (v) => v.split(/[.+-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0);
|
|
1752
|
+
const [a, b] = [parse(candidate), parse(current)];
|
|
1753
|
+
for (let i = 0; i < 3; i++) {
|
|
1754
|
+
if (a[i] !== b[i]) return a[i] > b[i];
|
|
1755
|
+
}
|
|
1756
|
+
return false;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// src/deps.ts
|
|
1760
|
+
async function readAllStdin() {
|
|
1761
|
+
const chunks = [];
|
|
1762
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
1763
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
1764
|
+
}
|
|
1765
|
+
function browserCommand(url) {
|
|
1766
|
+
if (process.platform === "darwin") return ["open", [url]];
|
|
1767
|
+
if (process.platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
1768
|
+
return ["xdg-open", [url]];
|
|
1769
|
+
}
|
|
1770
|
+
function openInBrowser(url, env2) {
|
|
1771
|
+
if (env2.SSH_CONNECTION || env2.SSH_TTY) return Promise.resolve(false);
|
|
1772
|
+
const [command, args] = browserCommand(url);
|
|
1773
|
+
return new Promise((resolve2) => {
|
|
1774
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
1775
|
+
child.once("error", () => resolve2(false));
|
|
1776
|
+
child.once("spawn", () => {
|
|
1777
|
+
child.unref();
|
|
1778
|
+
resolve2(true);
|
|
1779
|
+
});
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
function defaultDeps() {
|
|
1783
|
+
return {
|
|
1784
|
+
api: createApiClient(),
|
|
1785
|
+
store: createCredentialStore(),
|
|
1786
|
+
env: process.env,
|
|
1787
|
+
stdout: process.stdout,
|
|
1788
|
+
stderr: process.stderr,
|
|
1789
|
+
stdinIsTTY: !!process.stdin.isTTY,
|
|
1790
|
+
readStdin: readAllStdin,
|
|
1791
|
+
promptConfirm: (message) => confirm({ message, default: true }),
|
|
1792
|
+
openBrowser: (url) => openInBrowser(url, process.env),
|
|
1793
|
+
promptAgents: (detected) => checkbox({
|
|
1794
|
+
message: "Install the skill for",
|
|
1795
|
+
choices: AGENTS.map((agent) => ({ value: agent, checked: detected.includes(agent) }))
|
|
1796
|
+
}),
|
|
1797
|
+
hostname,
|
|
1798
|
+
home: homedir2(),
|
|
1799
|
+
// `src/` in development and the bundled `dist/` both sit next to `skills/`.
|
|
1800
|
+
skillSource: fileURLToPath(new URL("../skills/planttaxomatcher", import.meta.url)),
|
|
1801
|
+
sleep: (ms) => sleep(ms),
|
|
1802
|
+
now: Date.now
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// src/program.ts
|
|
1807
|
+
import { Command } from "commander";
|
|
1808
|
+
import kleur9 from "kleur";
|
|
1809
|
+
|
|
1810
|
+
// src/output.ts
|
|
1811
|
+
import kleur from "kleur";
|
|
1812
|
+
function createOutput(stdout, stderr, json, now = Date.now) {
|
|
1813
|
+
const human = json ? stderr : stdout;
|
|
1814
|
+
const line = (text) => human.write(`${text}
|
|
1815
|
+
`);
|
|
1816
|
+
return {
|
|
1817
|
+
json,
|
|
1818
|
+
info: line,
|
|
1819
|
+
success: (message) => line(`${kleur.green("\u2713")} ${message}`),
|
|
1820
|
+
warn: (message) => stderr.write(`${kleur.yellow("!")} ${message}
|
|
1821
|
+
`),
|
|
1822
|
+
data: (value) => stdout.write(`${JSON.stringify(value)}
|
|
1823
|
+
`),
|
|
1824
|
+
progress: createProgressWriter(human, now)
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
var NON_TTY_PROGRESS_INTERVAL_MS = 5e3;
|
|
1828
|
+
function createProgressWriter(sink, now, intervalMs = NON_TTY_PROGRESS_INTERVAL_MS) {
|
|
1829
|
+
let width = 0;
|
|
1830
|
+
let lastEmittedAt = null;
|
|
1831
|
+
let pending = null;
|
|
1832
|
+
if (sink.isTTY) {
|
|
1833
|
+
return {
|
|
1834
|
+
update(line) {
|
|
1835
|
+
sink.write(`\r${line.padEnd(width)}`);
|
|
1836
|
+
width = line.length;
|
|
1837
|
+
},
|
|
1838
|
+
done() {
|
|
1839
|
+
if (width > 0) sink.write("\n");
|
|
1840
|
+
width = 0;
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
return {
|
|
1845
|
+
update(line) {
|
|
1846
|
+
const at = now();
|
|
1847
|
+
if (lastEmittedAt !== null && at - lastEmittedAt < intervalMs) {
|
|
1848
|
+
pending = line;
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
sink.write(`${line}
|
|
1852
|
+
`);
|
|
1853
|
+
lastEmittedAt = at;
|
|
1854
|
+
pending = null;
|
|
1855
|
+
},
|
|
1856
|
+
done() {
|
|
1857
|
+
if (pending !== null) sink.write(`${pending}
|
|
1858
|
+
`);
|
|
1859
|
+
pending = null;
|
|
1860
|
+
lastEmittedAt = null;
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// src/device-login.ts
|
|
1866
|
+
import kleur2 from "kleur";
|
|
1867
|
+
var RESTART_HINT = "Run `planttaxomatcher login` again.";
|
|
1868
|
+
var CLIENT_NAME_MAX = 100;
|
|
1869
|
+
function isTransient(err) {
|
|
1870
|
+
if (err instanceof ApiError) return err.status === 429 || err.status >= 500;
|
|
1871
|
+
return err instanceof CliError && err.message.startsWith("cannot reach");
|
|
1872
|
+
}
|
|
1873
|
+
async function pollOnce(deps2, server, deviceCode) {
|
|
853
1874
|
try {
|
|
854
|
-
|
|
855
|
-
return JSON.parse(raw);
|
|
1875
|
+
return await deps2.api.pollDeviceToken(server, deviceCode);
|
|
856
1876
|
} catch (err) {
|
|
857
|
-
if (err
|
|
1877
|
+
if (isTransient(err)) return { error: "slow_down" };
|
|
858
1878
|
throw err;
|
|
859
1879
|
}
|
|
860
1880
|
}
|
|
861
|
-
async function
|
|
862
|
-
|
|
863
|
-
await
|
|
1881
|
+
async function deviceLogin(deps2, out, server, options) {
|
|
1882
|
+
const clientName = deps2.hostname().trim().slice(0, CLIENT_NAME_MAX) || "unknown";
|
|
1883
|
+
const request = await deps2.api.startDeviceAuthorization(server, clientName);
|
|
1884
|
+
out.info(`To sign in, open this page and approve the code ${kleur2.bold(request.userCode)}:`);
|
|
1885
|
+
out.info(` ${kleur2.cyan(request.verificationUriComplete)}`);
|
|
1886
|
+
if (options.openBrowser && await deps2.openBrowser(request.verificationUriComplete)) {
|
|
1887
|
+
out.info(kleur2.gray("Opened it in your browser."));
|
|
1888
|
+
}
|
|
1889
|
+
out.info(kleur2.gray("Waiting for approval\u2026"));
|
|
1890
|
+
const deadline = deps2.now() + request.expiresIn * 1e3;
|
|
1891
|
+
let interval = request.interval;
|
|
1892
|
+
while (deps2.now() < deadline) {
|
|
1893
|
+
await deps2.sleep(interval * 1e3);
|
|
1894
|
+
const answer = await pollOnce(deps2, server, request.deviceCode);
|
|
1895
|
+
if ("token" in answer) return answer;
|
|
1896
|
+
if (answer.error === "slow_down") interval = answer.interval ?? interval + SLOW_DOWN_STEP_SECONDS;
|
|
1897
|
+
else if (answer.error === "access_denied")
|
|
1898
|
+
throw new CliError("the sign-in was denied in the browser", EXIT.auth);
|
|
1899
|
+
else if (answer.error === "expired_token") break;
|
|
1900
|
+
}
|
|
1901
|
+
throw new CliError("the sign-in code expired before it was approved", EXIT.auth, RESTART_HINT);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// src/commands/auth.ts
|
|
1905
|
+
async function suppliedToken(opts, deps2) {
|
|
1906
|
+
if (opts.tokenStdin) {
|
|
1907
|
+
const token = (await deps2.readStdin()).trim();
|
|
1908
|
+
if (!token) throw new CliError("--token-stdin was set but stdin was empty", EXIT.usage);
|
|
1909
|
+
return token;
|
|
1910
|
+
}
|
|
1911
|
+
if (opts.token) return opts.token.trim();
|
|
1912
|
+
return tokenFromEnv(deps2.env) ?? null;
|
|
1913
|
+
}
|
|
1914
|
+
async function browserToken(deps2, out, server, opts) {
|
|
1915
|
+
if (parseEnvFlag(deps2.env.CI, false)) {
|
|
1916
|
+
throw new CliError(
|
|
1917
|
+
"no token given, and a browser sign-in cannot be approved in CI",
|
|
1918
|
+
EXIT.usage,
|
|
1919
|
+
"Set PLANTTAXOMATCHER_TOKEN, or pipe one to `planttaxomatcher login --token-stdin`."
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
return (await deviceLogin(deps2, out, server, { openBrowser: opts.browser !== false })).token;
|
|
864
1923
|
}
|
|
865
|
-
async function
|
|
1924
|
+
async function verifyToken(deps2, server, token) {
|
|
866
1925
|
try {
|
|
867
|
-
await
|
|
1926
|
+
return await deps2.api.me({ server, token });
|
|
868
1927
|
} catch (err) {
|
|
869
|
-
if (err.
|
|
1928
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
1929
|
+
throw new CliError(`${server} rejected this token (${err.message})`, EXIT.auth);
|
|
1930
|
+
}
|
|
1931
|
+
if (err instanceof ApiError && err.status === 403) return null;
|
|
1932
|
+
throw err;
|
|
870
1933
|
}
|
|
871
1934
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1935
|
+
function registerAuthCommands(program, deps2) {
|
|
1936
|
+
program.command("login").description(
|
|
1937
|
+
"Sign in through the browser (approve a code on the web app), or save a personal token you pass in"
|
|
1938
|
+
).option(
|
|
1939
|
+
"--token <token>",
|
|
1940
|
+
"Personal token (ptm_...). Avoid on shared hosts: it is visible in shell history and the process list. Prefer --token-stdin or PLANTTAXOMATCHER_TOKEN."
|
|
1941
|
+
).option(
|
|
1942
|
+
"--token-stdin",
|
|
1943
|
+
"Read the token from stdin (e.g. `cat token.txt | planttaxomatcher login --token-stdin`)",
|
|
1944
|
+
false
|
|
1945
|
+
).option("--server <url>", `API server URL (default: $PLANTTAXOMATCHER_SERVER, else ${DEFAULT_SERVER_URL})`).option(
|
|
1946
|
+
"--insecure",
|
|
1947
|
+
"Allow sending the token over cleartext http to a non-loopback server (NOT recommended)",
|
|
1948
|
+
false
|
|
1949
|
+
).option("--no-browser", "Print the sign-in link without opening a browser").option("--json", "Print the result as JSON", false).action(async (opts) => {
|
|
1950
|
+
const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
|
|
1951
|
+
const server = opts.server ?? serverFromEnv(deps2.env) ?? DEFAULT_SERVER_URL;
|
|
1952
|
+
assertServerTransport(server, { allowInsecure: !!opts.insecure, insecureFlag: true });
|
|
1953
|
+
const token = await suppliedToken(opts, deps2) ?? await browserToken(deps2, out, server, opts);
|
|
1954
|
+
if (!tokenStringSchema.safeParse(token).success) {
|
|
1955
|
+
throw new CliError("malformed token \u2014 expected ptm_<12 chars>_<32 chars>", EXIT.usage);
|
|
1956
|
+
}
|
|
1957
|
+
const me = await verifyToken(deps2, server, token);
|
|
1958
|
+
await deps2.store.write({ token, server, ...me?.appUrl ? { appUrl: me.appUrl } : {} });
|
|
1959
|
+
if (out.json) {
|
|
1960
|
+
out.data({ server, displayName: me?.displayName ?? null, scopes: me?.scopes ?? null });
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
out.success(me ? `Signed in to ${server} as ${me.displayName}` : `Signed in to ${server}`);
|
|
1964
|
+
if (!me) out.warn("this token cannot read jobs (no read:job scope)");
|
|
1965
|
+
});
|
|
1966
|
+
program.command("logout").description("Forget the saved token").action(async () => {
|
|
1967
|
+
await deps2.store.clear();
|
|
1968
|
+
createOutput(deps2.stdout, deps2.stderr, false).success("Logged out");
|
|
1969
|
+
});
|
|
1970
|
+
program.command("whoami").description("Show who the current token belongs to").option("--json", "Print the result as JSON", false).action(async (opts) => {
|
|
1971
|
+
const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
|
|
1972
|
+
const creds = await requireCredentials(deps2.store, deps2.env);
|
|
1973
|
+
const me = await deps2.api.me(creds);
|
|
1974
|
+
if (out.json) {
|
|
1975
|
+
out.data({ ...me, server: creds.server });
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
out.info(`${me.displayName} team=${me.teamId} scopes=${me.scopes.join(",")}`);
|
|
1979
|
+
out.info(`server=${creds.server}`);
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// src/commands/download.ts
|
|
1984
|
+
import { writeFile } from "fs/promises";
|
|
1985
|
+
import kleur3 from "kleur";
|
|
1986
|
+
var FORMATS = ["csv", "xlsx", "json", "ndjson"];
|
|
1987
|
+
var DELIMITERS = ["comma", "semicolon", "tab", "pipe"];
|
|
1988
|
+
function toDownloadOptions(opts) {
|
|
1989
|
+
const format = opts.format;
|
|
1990
|
+
if (!FORMATS.includes(format)) {
|
|
1991
|
+
throw new CliError(`--format must be ${FORMATS.join(", ")} (got ${opts.format})`, EXIT.usage);
|
|
876
1992
|
}
|
|
877
|
-
|
|
1993
|
+
const delimiter = opts.delimiter;
|
|
1994
|
+
if (!DELIMITERS.includes(delimiter)) {
|
|
1995
|
+
throw new CliError(`--delimiter must be ${DELIMITERS.join(", ")} (got ${opts.delimiter})`, EXIT.usage);
|
|
1996
|
+
}
|
|
1997
|
+
return {
|
|
1998
|
+
format,
|
|
1999
|
+
confirmedOnly: opts.confirmedOnly,
|
|
2000
|
+
dedupe: opts.dedupe,
|
|
2001
|
+
bundle: opts.bundle,
|
|
2002
|
+
...format === "csv" && delimiter !== "comma" ? { delimiter } : {},
|
|
2003
|
+
...opts.columns ? { columns: opts.columns } : {},
|
|
2004
|
+
...opts.wcvpExtra ? { wcvpExtra: opts.wcvpExtra } : {}
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
function printColumns(out, columns) {
|
|
2008
|
+
out.info(kleur3.bold("Result columns (--columns):"));
|
|
2009
|
+
for (const column of columns.result) out.info(` ${column.key} ${kleur3.gray(`(${column.group})`)}`);
|
|
2010
|
+
if (columns.original.length > 0) {
|
|
2011
|
+
out.info(kleur3.bold("\nYour upload columns (--columns):"));
|
|
2012
|
+
for (const key of columns.original) out.info(` ${key}`);
|
|
2013
|
+
}
|
|
2014
|
+
out.info(kleur3.bold("\nWCVP extra fields (--wcvp-extra):"));
|
|
2015
|
+
for (const field of columns.wcvpExtra) out.info(` ${field.key} ${kleur3.gray(`(${field.group})`)}`);
|
|
2016
|
+
}
|
|
2017
|
+
function registerDownloadCommand(program, deps2) {
|
|
2018
|
+
program.command("download <jobId>").description("Download a job export (CSV, XLSX, JSON or NDJSON; optionally zipped with NOTICE.md)").option("--format <fmt>", FORMATS.join(" | "), "csv").option("--confirmed-only", "Only matched rows that are accepted / not pending", false).option("--dedupe", "Collapse rows that resolved to the same accepted taxon to one line", false).option("--delimiter <sep>", `CSV separator: ${DELIMITERS.join(" | ")} (csv only)`, "comma").option("--bundle", "Wrap in a ZIP with NOTICE.md citing the WCVP snapshot and providers", false).option(
|
|
2019
|
+
"--columns <list>",
|
|
2020
|
+
"Comma-separated result/upload column keys to keep (default: all). See --list-columns"
|
|
2021
|
+
).option(
|
|
2022
|
+
"--wcvp-extra <list>",
|
|
2023
|
+
"Comma-separated extra WCVP fields appended as wcvp_<key> columns (e.g. ipni_id,powo_id). See --list-columns"
|
|
2024
|
+
).option("--list-columns", "Print the columns available for this job and exit", false).option(
|
|
2025
|
+
"--output <path>",
|
|
2026
|
+
"Write to this path (default: the server-provided file name in the current directory)"
|
|
2027
|
+
).option("--json", "Print the result (or --list-columns) as JSON", false).action(async (jobId, opts) => {
|
|
2028
|
+
const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
|
|
2029
|
+
const download = toDownloadOptions(opts);
|
|
2030
|
+
const creds = await requireCredentials(deps2.store, deps2.env);
|
|
2031
|
+
if (opts.listColumns) {
|
|
2032
|
+
const columns = await deps2.api.downloadColumns(creds, jobId);
|
|
2033
|
+
if (out.json) return out.data(columns);
|
|
2034
|
+
return printColumns(out, columns);
|
|
2035
|
+
}
|
|
2036
|
+
const { filename, body } = await deps2.api.downloadJob(creds, jobId, download);
|
|
2037
|
+
const path = opts.output ?? filename;
|
|
2038
|
+
await writeFile(path, body);
|
|
2039
|
+
if (out.json) return out.data({ path, bytes: body.length, format: download.format });
|
|
2040
|
+
out.success(`wrote ${body.length} bytes to ${path}`);
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// src/commands/jobs.ts
|
|
2045
|
+
import kleur5 from "kleur";
|
|
2046
|
+
|
|
2047
|
+
// src/flags.ts
|
|
2048
|
+
function parseIntegerFlag(value, flag, range = {}) {
|
|
2049
|
+
const parsed = Number(value);
|
|
2050
|
+
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = range;
|
|
2051
|
+
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
2052
|
+
const bounds = range.min !== void 0 || range.max !== void 0 ? ` between ${min} and ${max}` : "";
|
|
2053
|
+
throw new CliError(`${flag} must be an integer${bounds} (got "${value}")`, EXIT.usage);
|
|
2054
|
+
}
|
|
2055
|
+
return parsed;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// src/watch.ts
|
|
2059
|
+
import kleur4 from "kleur";
|
|
2060
|
+
var STOP = /* @__PURE__ */ new Set(["completed", "failed", "cancelled", "paused"]);
|
|
2061
|
+
function isStop(status) {
|
|
2062
|
+
return typeof status === "string" && STOP.has(status);
|
|
2063
|
+
}
|
|
2064
|
+
function stopStatusOf(frame) {
|
|
2065
|
+
if (frame.type === "status" || frame.type === "completed") return isStop(frame.status) ? frame.status : null;
|
|
2066
|
+
if (frame.type === "error") return "failed";
|
|
2067
|
+
return null;
|
|
2068
|
+
}
|
|
2069
|
+
function progressLine(frame) {
|
|
2070
|
+
const processed = Number(frame.processedQueries ?? frame.processedRows ?? 0);
|
|
2071
|
+
const total = Number(frame.totalQueries ?? frame.totalRows ?? 0);
|
|
2072
|
+
const phase = processed === 0 && typeof frame.phase === "string" ? ` (${frame.phase}\u2026)` : "";
|
|
2073
|
+
return ` progress: ${processed}/${total}${phase}`;
|
|
2074
|
+
}
|
|
2075
|
+
function render(out, frame) {
|
|
2076
|
+
if (frame.type === "progress") {
|
|
2077
|
+
out.progress.update(progressLine(frame));
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
if (frame.type === "status") {
|
|
2081
|
+
out.progress.done();
|
|
2082
|
+
out.info(`${kleur4.gray("\u2022")} ${String(frame.status)}`);
|
|
2083
|
+
} else if (frame.type === "error") {
|
|
2084
|
+
out.progress.done();
|
|
2085
|
+
out.info(`${kleur4.red("error:")} ${String(frame.message ?? "job failed")}`);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
async function watchJob(api, out, creds, jobId) {
|
|
2089
|
+
out.progress.done();
|
|
2090
|
+
if (!out.json) out.info(`${kleur4.cyan("\u2192")} Streaming progress for ${jobId} \u2026`);
|
|
2091
|
+
for await (const frame of api.streamJob(creds, jobId)) {
|
|
2092
|
+
if (frame.type === "heartbeat") continue;
|
|
2093
|
+
const status = stopStatusOf(frame);
|
|
2094
|
+
if (out.json) out.data(frame);
|
|
2095
|
+
else if (!(status && frame.type === "status")) render(out, frame);
|
|
2096
|
+
if (status) return finish(out, status);
|
|
2097
|
+
}
|
|
2098
|
+
out.progress.done();
|
|
2099
|
+
const job = await api.getJob(creds, jobId);
|
|
2100
|
+
if (isStop(job.status)) return finish(out, job.status);
|
|
2101
|
+
throw new CliError(
|
|
2102
|
+
`the progress stream closed while job ${jobId} was still ${job.status}`,
|
|
2103
|
+
void 0,
|
|
2104
|
+
`Run \`planttaxomatcher watch ${jobId}\` to follow it again.`
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
function finish(out, status) {
|
|
2108
|
+
out.progress.done();
|
|
2109
|
+
if (!out.json) {
|
|
2110
|
+
if (status === "completed") out.success("completed");
|
|
2111
|
+
else if (status === "paused") out.info(kleur4.yellow("paused \u2014 resume it, then watch again"));
|
|
2112
|
+
else out.info(kleur4.red(`\u2717 ${status}`));
|
|
2113
|
+
}
|
|
2114
|
+
return status;
|
|
878
2115
|
}
|
|
879
2116
|
|
|
2117
|
+
// src/commands/jobs.ts
|
|
2118
|
+
function jobUrl(creds, jobId) {
|
|
2119
|
+
return new URL(`/jobs/${encodeURIComponent(jobId)}`, creds.appUrl ?? creds.server).toString();
|
|
2120
|
+
}
|
|
2121
|
+
function watchOutcomeError(results) {
|
|
2122
|
+
const stopped = results.filter((r) => r.status !== "completed");
|
|
2123
|
+
if (stopped.length === 0) return null;
|
|
2124
|
+
const list = stopped.map((r) => `${r.id} (${r.status})`).join(", ");
|
|
2125
|
+
const onlyPaused = stopped.every((r) => r.status === "paused");
|
|
2126
|
+
return new CliError(
|
|
2127
|
+
onlyPaused ? `job paused: ${list}` : `job did not complete: ${list}`,
|
|
2128
|
+
onlyPaused ? EXIT.jobPaused : EXIT.jobFailed,
|
|
2129
|
+
onlyPaused ? "Run `planttaxomatcher resume <jobId>`, then `planttaxomatcher watch <jobId>`." : void 0
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
function listLine(job) {
|
|
2133
|
+
const matched = `${String(job.matchedRows).padStart(6)}/${String(job.totalRows).padStart(6)} matched`;
|
|
2134
|
+
return `${job.id} ${job.status.padEnd(10)} ${matched} ${kleur5.gray(job.name ?? "")}`;
|
|
2135
|
+
}
|
|
2136
|
+
var JOB_ACTIONS = {
|
|
2137
|
+
pause: { description: "Pause a running job (the worker stops between match queries)", past: "paused" },
|
|
2138
|
+
resume: { description: "Resume a paused job", past: "resumed" },
|
|
2139
|
+
cancel: { description: "Cancel a job. Rows already matched are kept; pending queries stop.", past: "cancelled" }
|
|
2140
|
+
};
|
|
2141
|
+
function runAction(deps2, action, creds, jobId) {
|
|
2142
|
+
if (action === "pause") return deps2.api.pauseJob(creds, jobId);
|
|
2143
|
+
if (action === "resume") return deps2.api.resumeJob(creds, jobId);
|
|
2144
|
+
return deps2.api.cancelJob(creds, jobId);
|
|
2145
|
+
}
|
|
2146
|
+
function registerJobCommands(program, deps2) {
|
|
2147
|
+
program.command("list").description("List recent jobs, newest first").option("--limit <n>", "How many jobs to show (1-100)", "20").option("--status <status>", `Only jobs in this status: ${jobStatusSchema.options.join(", ")}`).option("--json", "Print the jobs as a JSON array", false).action(async (opts) => {
|
|
2148
|
+
const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
|
|
2149
|
+
const status = opts.status === void 0 ? void 0 : jobStatusSchema.safeParse(opts.status);
|
|
2150
|
+
if (status && !status.success) {
|
|
2151
|
+
throw new CliError(`--status must be one of ${jobStatusSchema.options.join(", ")}`, EXIT.usage);
|
|
2152
|
+
}
|
|
2153
|
+
const limit = parseIntegerFlag(opts.limit, "--limit", { min: 1, max: 100 });
|
|
2154
|
+
const creds = await requireCredentials(deps2.store, deps2.env);
|
|
2155
|
+
const jobs = await deps2.api.listJobs(creds, { limit, ...status ? { status: status.data } : {} });
|
|
2156
|
+
if (out.json) return out.data(jobs);
|
|
2157
|
+
if (jobs.length === 0) return out.info(kleur5.gray("No jobs."));
|
|
2158
|
+
for (const job of jobs) out.info(listLine(job));
|
|
2159
|
+
});
|
|
2160
|
+
program.command("status <jobId>").description("Print a job snapshot as JSON (counts, status, timestamps)").option("--json", "Accepted for symmetry: the output is always JSON", false).action(async (jobId) => {
|
|
2161
|
+
const creds = await requireCredentials(deps2.store, deps2.env);
|
|
2162
|
+
const job = await deps2.api.getJob(creds, jobId);
|
|
2163
|
+
deps2.stdout.write(`${JSON.stringify({ ...job, url: jobUrl(creds, job.id) }, null, 2)}
|
|
2164
|
+
`);
|
|
2165
|
+
});
|
|
2166
|
+
program.command("watch <jobId>").description("Follow a job until it stops. Exits 4 if it fails or is cancelled, 5 if it is paused.").option("--json", "Echo each progress frame as one NDJSON line", false).action(async (jobId, opts) => {
|
|
2167
|
+
const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
|
|
2168
|
+
const creds = await requireCredentials(deps2.store, deps2.env);
|
|
2169
|
+
const status = await watchJob(deps2.api, out, creds, jobId);
|
|
2170
|
+
const failure = watchOutcomeError([{ id: jobId, status }]);
|
|
2171
|
+
if (failure) throw failure;
|
|
2172
|
+
});
|
|
2173
|
+
for (const [action, { description, past }] of Object.entries(JOB_ACTIONS)) {
|
|
2174
|
+
program.command(`${action} <jobId>`).description(description).option("--json", "Print the updated job as JSON", false).action(async (jobId, opts) => {
|
|
2175
|
+
const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
|
|
2176
|
+
const creds = await requireCredentials(deps2.store, deps2.env);
|
|
2177
|
+
const job = await runAction(deps2, action, creds, jobId);
|
|
2178
|
+
if (out.json) return out.data(job);
|
|
2179
|
+
out.success(`${past}: ${job.id} (status=${job.status})`);
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// src/commands/skills.ts
|
|
2185
|
+
import kleur6 from "kleur";
|
|
2186
|
+
|
|
2187
|
+
// src/skills/manage.ts
|
|
2188
|
+
import { promises as fs3 } from "fs";
|
|
2189
|
+
import { dirname as dirname2, resolve } from "path";
|
|
2190
|
+
|
|
2191
|
+
// src/skills/content.ts
|
|
2192
|
+
import { createHash } from "crypto";
|
|
2193
|
+
import { promises as fs2 } from "fs";
|
|
2194
|
+
import { dirname, join as join3, relative } from "path";
|
|
2195
|
+
async function walk(dir, root = dir) {
|
|
2196
|
+
const found = [];
|
|
2197
|
+
for (const entry of await fs2.readdir(dir, { withFileTypes: true })) {
|
|
2198
|
+
const path = join3(dir, entry.name);
|
|
2199
|
+
if (entry.isDirectory()) found.push(...await walk(path, root));
|
|
2200
|
+
else if (entry.isFile() && entry.name !== MARKER_FILE) found.push(relative(root, path));
|
|
2201
|
+
}
|
|
2202
|
+
return found.sort();
|
|
2203
|
+
}
|
|
2204
|
+
async function renderSkill(sourceDir, version2) {
|
|
2205
|
+
const files = /* @__PURE__ */ new Map();
|
|
2206
|
+
for (const path of await walk(sourceDir)) {
|
|
2207
|
+
const text = await fs2.readFile(join3(sourceDir, path), "utf8");
|
|
2208
|
+
files.set(path, text.replaceAll("{{version}}", version2));
|
|
2209
|
+
}
|
|
2210
|
+
return files;
|
|
2211
|
+
}
|
|
2212
|
+
function hashFiles(files) {
|
|
2213
|
+
const hash = createHash("sha256");
|
|
2214
|
+
for (const [path, text] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
|
|
2215
|
+
hash.update(path).update("\0").update(text).update("\0");
|
|
2216
|
+
}
|
|
2217
|
+
return hash.digest("hex");
|
|
2218
|
+
}
|
|
2219
|
+
async function readSkillDir(dir) {
|
|
2220
|
+
const files = /* @__PURE__ */ new Map();
|
|
2221
|
+
for (const path of await walk(dir)) files.set(path, await fs2.readFile(join3(dir, path), "utf8"));
|
|
2222
|
+
return files;
|
|
2223
|
+
}
|
|
2224
|
+
async function readMarker(dir) {
|
|
2225
|
+
try {
|
|
2226
|
+
const parsed = JSON.parse(await fs2.readFile(join3(dir, MARKER_FILE), "utf8"));
|
|
2227
|
+
return typeof parsed.version === "string" && typeof parsed.hash === "string" ? { version: parsed.version, hash: parsed.hash } : null;
|
|
2228
|
+
} catch {
|
|
2229
|
+
return null;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
async function writeSkillDir(dir, files, version2) {
|
|
2233
|
+
const staging = join3(dirname(dirname(dir)), `.planttaxomatcher-staging-${process.pid}-${Date.now()}`);
|
|
2234
|
+
try {
|
|
2235
|
+
for (const [path, text] of files) {
|
|
2236
|
+
await fs2.mkdir(dirname(join3(staging, path)), { recursive: true });
|
|
2237
|
+
await fs2.writeFile(join3(staging, path), text);
|
|
2238
|
+
}
|
|
2239
|
+
const marker = { version: version2, hash: hashFiles(files) };
|
|
2240
|
+
await fs2.writeFile(join3(staging, MARKER_FILE), `${JSON.stringify(marker, null, 2)}
|
|
2241
|
+
`);
|
|
2242
|
+
await fs2.rm(dir, { recursive: true, force: true });
|
|
2243
|
+
await fs2.mkdir(dirname(dir), { recursive: true });
|
|
2244
|
+
await fs2.rename(staging, dir);
|
|
2245
|
+
} finally {
|
|
2246
|
+
await fs2.rm(staging, { recursive: true, force: true });
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
async function isPristine(dir) {
|
|
2250
|
+
const marker = await readMarker(dir);
|
|
2251
|
+
return !!marker && hashFiles(await readSkillDir(dir)) === marker.hash;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// src/skills/manage.ts
|
|
2255
|
+
var symlink = (target, path) => fs3.symlink(target, path, "junction");
|
|
2256
|
+
function samePath(a, b) {
|
|
2257
|
+
const norm = (p) => resolve(p.replace(/^\\\\\?\\/, ""));
|
|
2258
|
+
return process.platform === "win32" ? norm(a).toLowerCase() === norm(b).toLowerCase() : norm(a) === norm(b);
|
|
2259
|
+
}
|
|
2260
|
+
var OUR_LAYOUT = /[/\\]planttaxomatcher[/\\]skills[/\\]planttaxomatcher$/;
|
|
2261
|
+
async function exists(path) {
|
|
2262
|
+
return fs3.stat(path).then(
|
|
2263
|
+
() => true,
|
|
2264
|
+
() => false
|
|
2265
|
+
);
|
|
2266
|
+
}
|
|
2267
|
+
async function classify(entry, canonical) {
|
|
2268
|
+
const stat = await fs3.lstat(entry).catch(() => null);
|
|
2269
|
+
if (!stat) return { kind: "missing" };
|
|
2270
|
+
if (stat.isSymbolicLink()) {
|
|
2271
|
+
const target = resolve(dirname2(entry), await fs3.readlink(entry));
|
|
2272
|
+
if (samePath(target, canonical)) return await exists(canonical) ? { kind: "link" } : { kind: "broken" };
|
|
2273
|
+
return OUR_LAYOUT.test(target) || await readMarker(target) ? { kind: "stale" } : { kind: "foreign" };
|
|
2274
|
+
}
|
|
2275
|
+
if (!stat.isDirectory()) return { kind: "foreign" };
|
|
2276
|
+
const marker = await readMarker(entry);
|
|
2277
|
+
return marker ? { kind: "copy", version: marker.version, pristine: await isPristine(entry) } : { kind: "foreign" };
|
|
2278
|
+
}
|
|
2279
|
+
async function classifyAll(paths) {
|
|
2280
|
+
const states = {};
|
|
2281
|
+
for (const dir of SKILL_DIRS) states[dir] = await classify(paths.entries[dir], paths.canonical);
|
|
2282
|
+
return states;
|
|
2283
|
+
}
|
|
2284
|
+
var isOurs = (state) => state.kind === "link" || state.kind === "copy" || state.kind === "stale";
|
|
2285
|
+
async function detectAgents(paths) {
|
|
2286
|
+
const found = [];
|
|
2287
|
+
for (const agent of AGENTS) if (await exists(paths.agentHomes[agent])) found.push(agent);
|
|
2288
|
+
return found;
|
|
2289
|
+
}
|
|
2290
|
+
async function writeCanonical(paths, files, version2, force) {
|
|
2291
|
+
const state = await fs3.lstat(paths.canonical).catch(() => null);
|
|
2292
|
+
if (state && !force) {
|
|
2293
|
+
const marker = await readMarker(paths.canonical);
|
|
2294
|
+
if (marker && isNewerVersion(marker.version, version2)) {
|
|
2295
|
+
throw new CliError(
|
|
2296
|
+
`the installed skill (${marker.version}) is newer than this CLI (${version2})`,
|
|
2297
|
+
EXIT.usage,
|
|
2298
|
+
"Run the newer CLI (`npx -y @plantnet/planttaxomatcher@latest skills install`), or pass --force."
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
if (!marker || !await isPristine(paths.canonical)) {
|
|
2302
|
+
throw new CliError(
|
|
2303
|
+
`${paths.canonical} was edited or is not ours`,
|
|
2304
|
+
EXIT.usage,
|
|
2305
|
+
"Pass --force to replace it with the skill shipped with this CLI."
|
|
2306
|
+
);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
await writeSkillDir(paths.canonical, files, version2);
|
|
2310
|
+
}
|
|
2311
|
+
async function place(paths, dir, state, files, version2, options) {
|
|
2312
|
+
const entry = paths.entries[dir];
|
|
2313
|
+
if (state.kind === "foreign") return "skipped-foreign";
|
|
2314
|
+
if (state.kind === "link") return "kept";
|
|
2315
|
+
if (state.kind === "copy") {
|
|
2316
|
+
if (!state.pristine && !options.force) return "skipped-edited";
|
|
2317
|
+
await writeSkillDir(entry, files, version2);
|
|
2318
|
+
return "refreshed";
|
|
2319
|
+
}
|
|
2320
|
+
if (state.kind === "broken" || state.kind === "stale") await fs3.unlink(entry);
|
|
2321
|
+
await fs3.mkdir(dirname2(entry), { recursive: true });
|
|
2322
|
+
try {
|
|
2323
|
+
await options.link(paths.canonical, entry);
|
|
2324
|
+
return "linked";
|
|
2325
|
+
} catch {
|
|
2326
|
+
await writeSkillDir(entry, files, version2);
|
|
2327
|
+
return "copied";
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
async function installSkill(paths, source, version2, agents, options = {}) {
|
|
2331
|
+
const opts = { force: !!options.force, link: options.link ?? symlink };
|
|
2332
|
+
const files = await renderSkill(source, version2);
|
|
2333
|
+
await writeCanonical(paths, files, version2, opts.force);
|
|
2334
|
+
const states = await classifyAll(paths);
|
|
2335
|
+
const linked = new Set(SKILL_DIRS.filter((dir) => isOurs(states[dir])));
|
|
2336
|
+
const planned = new Set(linkPlan(agents, linked));
|
|
2337
|
+
const entries = [];
|
|
2338
|
+
for (const dir of SKILL_DIRS) {
|
|
2339
|
+
const state = states[dir];
|
|
2340
|
+
if (!planned.has(dir) && !(state.kind === "copy" && state.pristine)) continue;
|
|
2341
|
+
entries.push({ dir, path: paths.entries[dir], action: await place(paths, dir, state, files, version2, opts) });
|
|
2342
|
+
}
|
|
2343
|
+
return { canonical: paths.canonical, entries };
|
|
2344
|
+
}
|
|
2345
|
+
async function uninstallSkill(paths, options = {}) {
|
|
2346
|
+
const entries = [];
|
|
2347
|
+
for (const [dir, state] of Object.entries(await classifyAll(paths))) {
|
|
2348
|
+
const path = paths.entries[dir];
|
|
2349
|
+
if (state.kind === "link" || state.kind === "broken" || state.kind === "stale") {
|
|
2350
|
+
await fs3.unlink(path);
|
|
2351
|
+
entries.push({ dir, path, action: "removed" });
|
|
2352
|
+
} else if (state.kind === "copy") {
|
|
2353
|
+
const removable = state.pristine || options.force;
|
|
2354
|
+
if (removable) await fs3.rm(path, { recursive: true, force: true });
|
|
2355
|
+
entries.push({ dir, path, action: removable ? "removed" : "skipped-edited" });
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
const marker = await readMarker(paths.canonical);
|
|
2359
|
+
if (marker && (options.force || await isPristine(paths.canonical))) {
|
|
2360
|
+
await fs3.rm(paths.canonical, { recursive: true, force: true });
|
|
2361
|
+
}
|
|
2362
|
+
return { canonical: paths.canonical, entries };
|
|
2363
|
+
}
|
|
2364
|
+
async function skillStatus(paths) {
|
|
2365
|
+
const states = await classifyAll(paths);
|
|
2366
|
+
const marker = await readMarker(paths.canonical);
|
|
2367
|
+
const installed = new Set(await detectAgents(paths));
|
|
2368
|
+
return {
|
|
2369
|
+
canonical: {
|
|
2370
|
+
path: paths.canonical,
|
|
2371
|
+
version: marker?.version ?? null,
|
|
2372
|
+
pristine: marker ? await isPristine(paths.canonical) : false
|
|
2373
|
+
},
|
|
2374
|
+
entries: SKILL_DIRS.map((dir) => ({ dir, path: paths.entries[dir], ...states[dir] })),
|
|
2375
|
+
agents: AGENTS.map((agent) => {
|
|
2376
|
+
const loads = READS[agent].find((dir) => states[dir].kind !== "missing") ?? null;
|
|
2377
|
+
return { agent, installed: installed.has(agent), loads, state: loads ? states[loads].kind : "missing" };
|
|
2378
|
+
})
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
async function refreshSkillIfOutdated(paths, source, version2) {
|
|
2382
|
+
try {
|
|
2383
|
+
const marker = await readMarker(paths.canonical);
|
|
2384
|
+
if (!marker || !isNewerVersion(version2, marker.version) || !await isPristine(paths.canonical)) return false;
|
|
2385
|
+
const files = await renderSkill(source, version2);
|
|
2386
|
+
await writeSkillDir(paths.canonical, files, version2);
|
|
2387
|
+
for (const [dir, state] of Object.entries(await classifyAll(paths))) {
|
|
2388
|
+
if (state.kind === "copy" && state.pristine) await writeSkillDir(paths.entries[dir], files, version2);
|
|
2389
|
+
}
|
|
2390
|
+
return true;
|
|
2391
|
+
} catch {
|
|
2392
|
+
return false;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// src/commands/skills.ts
|
|
2397
|
+
var AGENT_NAMES = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", pi: "pi" };
|
|
2398
|
+
var SERVES = {
|
|
2399
|
+
claude: "Claude Code (and OpenCode)",
|
|
2400
|
+
agents: "Codex, pi (and OpenCode)",
|
|
2401
|
+
opencode: "OpenCode",
|
|
2402
|
+
pi: "pi"
|
|
2403
|
+
};
|
|
2404
|
+
var ACTION_TEXT = {
|
|
2405
|
+
linked: "linked",
|
|
2406
|
+
copied: "copied (links are not available here)",
|
|
2407
|
+
kept: "already linked",
|
|
2408
|
+
refreshed: "copy refreshed",
|
|
2409
|
+
removed: "removed",
|
|
2410
|
+
"skipped-foreign": "left alone: a skill of the same name that is not ours",
|
|
2411
|
+
"skipped-edited": "left alone: edited by hand (use --force)"
|
|
2412
|
+
};
|
|
2413
|
+
var RELOAD_HINTS = {
|
|
2414
|
+
opencode: "Restart OpenCode to load it.",
|
|
2415
|
+
pi: "In pi, run /reload."
|
|
2416
|
+
};
|
|
2417
|
+
async function chooseAgents(deps2, requested, detected) {
|
|
2418
|
+
if (requested !== void 0) {
|
|
2419
|
+
const parsed = parseAgents(requested);
|
|
2420
|
+
if (!parsed) throw new CliError(`--agent takes ${AGENTS.join(", ")} or all (got "${requested}")`, EXIT.usage);
|
|
2421
|
+
return parsed === "all" ? [...AGENTS] : parsed;
|
|
2422
|
+
}
|
|
2423
|
+
if (deps2.stdinIsTTY) return deps2.promptAgents(detected);
|
|
2424
|
+
if (detected.length === 0) {
|
|
2425
|
+
throw new CliError(
|
|
2426
|
+
"no supported agent found in your home directory",
|
|
2427
|
+
EXIT.usage,
|
|
2428
|
+
`Pass --agent with any of ${AGENTS.join(", ")}, or all.`
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
return detected;
|
|
2432
|
+
}
|
|
2433
|
+
function tilde(path, home) {
|
|
2434
|
+
return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
2435
|
+
}
|
|
2436
|
+
function printEntries(out, result, home) {
|
|
2437
|
+
for (const { dir, path, action } of result.entries) {
|
|
2438
|
+
const line = `${SERVES[dir]}: ${ACTION_TEXT[action]} ${kleur6.gray(tilde(path, home))}`;
|
|
2439
|
+
if (action.startsWith("skipped")) out.warn(line);
|
|
2440
|
+
else out.success(line);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
function registerSkillsCommands(program, deps2, version2) {
|
|
2444
|
+
const skills = program.command("skills").description("Add the PlantTaxoMatcher skill to your AI coding agents (Claude Code, Codex, OpenCode, pi)");
|
|
2445
|
+
const paths = () => skillPaths(deps2.home, deps2.env);
|
|
2446
|
+
skills.command("install").description(
|
|
2447
|
+
"Install the skill for your user. The files live in one CLI-owned folder; each agent only gets a link to it."
|
|
2448
|
+
).option(
|
|
2449
|
+
"--agent <list>",
|
|
2450
|
+
`Comma-separated: ${AGENTS.join(", ")}, or all (default: the agents found, or a prompt)`
|
|
2451
|
+
).option("--force", "Replace an installed skill even if it was edited by hand", false).option("--json", "Print the result as JSON", false).action(async (opts) => {
|
|
2452
|
+
const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
|
|
2453
|
+
const agents = await chooseAgents(deps2, opts.agent, await detectAgents(paths()));
|
|
2454
|
+
if (agents.length === 0) throw new CliError("no agent selected", EXIT.usage);
|
|
2455
|
+
const result = await installSkill(paths(), deps2.skillSource, version2, agents, { force: opts.force });
|
|
2456
|
+
if (out.json) return out.data({ version: version2, agents, ...result });
|
|
2457
|
+
out.info(
|
|
2458
|
+
`${kleur6.bold("planttaxomatcher")} skill ${version2} \u2192 ${kleur6.gray(tilde(result.canonical, deps2.home))}`
|
|
2459
|
+
);
|
|
2460
|
+
printEntries(out, result, deps2.home);
|
|
2461
|
+
for (const agent of agents) if (RELOAD_HINTS[agent]) out.info(kleur6.gray(RELOAD_HINTS[agent]));
|
|
2462
|
+
out.info(kleur6.gray(`Try it: ask your agent to "match the plant names in my CSV with PlantTaxoMatcher".`));
|
|
2463
|
+
});
|
|
2464
|
+
skills.command("uninstall").description("Remove the links and copies this CLI installed, then its own copy of the skill").option("--force", "Also remove copies edited by hand", false).option("--json", "Print the result as JSON", false).action(async (opts) => {
|
|
2465
|
+
const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
|
|
2466
|
+
const result = await uninstallSkill(paths(), { force: opts.force });
|
|
2467
|
+
if (out.json) return out.data(result);
|
|
2468
|
+
if (result.entries.length === 0) out.info(kleur6.gray("No installed skill links found."));
|
|
2469
|
+
printEntries(out, result, deps2.home);
|
|
2470
|
+
});
|
|
2471
|
+
skills.command("status").description("Show where the skill is installed and which copy each agent loads").option("--json", "Print the status as JSON", false).action(async (opts) => {
|
|
2472
|
+
const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
|
|
2473
|
+
const status = await skillStatus(paths());
|
|
2474
|
+
if (out.json) return out.data({ cliVersion: version2, ...status });
|
|
2475
|
+
const { canonical } = status;
|
|
2476
|
+
out.info(
|
|
2477
|
+
canonical.version ? `Skill ${canonical.version}${canonical.pristine ? "" : " (edited)"} at ${kleur6.gray(tilde(canonical.path, deps2.home))}` : kleur6.gray("Not installed. Run `planttaxomatcher skills install`.")
|
|
2478
|
+
);
|
|
2479
|
+
for (const entry of status.entries) {
|
|
2480
|
+
if (entry.kind !== "missing")
|
|
2481
|
+
out.info(` ${entry.kind.padEnd(8)} ${kleur6.gray(tilde(entry.path, deps2.home))}`);
|
|
2482
|
+
}
|
|
2483
|
+
for (const { agent, installed, loads, state } of status.agents) {
|
|
2484
|
+
const entry = status.entries.find((e) => e.dir === loads);
|
|
2485
|
+
const where = entry ? `${state} in ${tilde(entry.path, deps2.home)}` : "no skill";
|
|
2486
|
+
out.info(` ${AGENT_NAMES[agent].padEnd(12)} ${installed ? where : kleur6.gray(`not found (${where})`)}`);
|
|
2487
|
+
}
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// src/commands/submit.ts
|
|
2492
|
+
import { parse as parsePath } from "path";
|
|
2493
|
+
import kleur8 from "kleur";
|
|
2494
|
+
|
|
880
2495
|
// src/dry-run.ts
|
|
881
|
-
import { promises as
|
|
2496
|
+
import { promises as fs4, createReadStream } from "fs";
|
|
882
2497
|
import Papa from "papaparse";
|
|
883
|
-
import
|
|
2498
|
+
import kleur7 from "kleur";
|
|
884
2499
|
async function readSample(file, sampleLimit) {
|
|
885
2500
|
const lower = file.toLowerCase();
|
|
886
2501
|
if (lower.endsWith(".json")) {
|
|
887
|
-
const text = await
|
|
2502
|
+
const text = await fs4.readFile(file, "utf8");
|
|
888
2503
|
const arr = JSON.parse(text);
|
|
889
2504
|
if (!Array.isArray(arr)) throw new Error("JSON file must be an array of row objects");
|
|
890
2505
|
const out = [];
|
|
@@ -898,13 +2513,13 @@ async function readSample(file, sampleLimit) {
|
|
|
898
2513
|
}
|
|
899
2514
|
return out;
|
|
900
2515
|
}
|
|
901
|
-
return await new Promise((
|
|
2516
|
+
return await new Promise((resolve2, reject) => {
|
|
902
2517
|
const out = [];
|
|
903
2518
|
let done = false;
|
|
904
|
-
const
|
|
2519
|
+
const finish2 = () => {
|
|
905
2520
|
if (done) return;
|
|
906
2521
|
done = true;
|
|
907
|
-
|
|
2522
|
+
resolve2(out);
|
|
908
2523
|
};
|
|
909
2524
|
const stream = createReadStream(file);
|
|
910
2525
|
const parseStream = Papa.parse(Papa.NODE_STREAM_INPUT, {
|
|
@@ -922,10 +2537,10 @@ async function readSample(file, sampleLimit) {
|
|
|
922
2537
|
out.push(o);
|
|
923
2538
|
if (out.length >= sampleLimit) {
|
|
924
2539
|
stream.destroy();
|
|
925
|
-
|
|
2540
|
+
finish2();
|
|
926
2541
|
}
|
|
927
2542
|
});
|
|
928
|
-
parseStream.on("end",
|
|
2543
|
+
parseStream.on("end", finish2);
|
|
929
2544
|
stream.pipe(parseStream);
|
|
930
2545
|
});
|
|
931
2546
|
}
|
|
@@ -943,7 +2558,7 @@ async function buildDryRunReport(file, opts) {
|
|
|
943
2558
|
};
|
|
944
2559
|
}
|
|
945
2560
|
if (!(opts.nameColumn in rows[0])) {
|
|
946
|
-
throw new
|
|
2561
|
+
throw new CliError(`name column "${opts.nameColumn}" not present in file header`, EXIT.usage);
|
|
947
2562
|
}
|
|
948
2563
|
let nullNameCount = 0;
|
|
949
2564
|
let qualifierFlagCount = 0;
|
|
@@ -983,347 +2598,241 @@ async function buildDryRunReport(file, opts) {
|
|
|
983
2598
|
idTypeDetection
|
|
984
2599
|
};
|
|
985
2600
|
}
|
|
986
|
-
function printDryRunReport(report) {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
` Read ${
|
|
2601
|
+
function printDryRunReport(report, log) {
|
|
2602
|
+
log("");
|
|
2603
|
+
log(kleur7.bold("Dry-run preview"));
|
|
2604
|
+
log(
|
|
2605
|
+
` Read ${kleur7.cyan(report.rowsRead)} rows \xB7 ${kleur7.yellow(report.nullNameCount)} with no usable name \xB7 ${kleur7.yellow(report.qualifierFlagCount)} with qualifier flags`
|
|
991
2606
|
);
|
|
992
2607
|
if (report.idTypeDetection) {
|
|
993
2608
|
const d = report.idTypeDetection;
|
|
994
2609
|
const pct = Math.round(d.dominantConfidence * 100);
|
|
995
|
-
|
|
996
|
-
` ID-column detection: ${kleur.green(d.dominant ?? "\u2014")} (${pct}% of ${d.sampleSize} sampled)`
|
|
997
|
-
);
|
|
2610
|
+
log(` ID-column detection: ${kleur7.green(d.dominant ?? "\u2014")} (${pct}% of ${d.sampleSize} sampled)`);
|
|
998
2611
|
if (d.minorityExamples.length > 0) {
|
|
999
|
-
|
|
2612
|
+
log(` Minority examples:`);
|
|
1000
2613
|
for (const m of d.minorityExamples) {
|
|
1001
|
-
|
|
1002
|
-
` row ${m.rowIndex + 1}: ${kleur.gray(m.value)} \u2192 ${kleur.dim(m.type)}`
|
|
1003
|
-
);
|
|
2614
|
+
log(` row ${m.rowIndex + 1}: ${kleur7.gray(m.value)} \u2192 ${kleur7.dim(m.type)}`);
|
|
1004
2615
|
}
|
|
1005
2616
|
}
|
|
1006
2617
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
` ${"#".padStart(4)} ${"input".padEnd(36)} ${"normalized".padEnd(36)} flags`
|
|
1011
|
-
);
|
|
2618
|
+
log("");
|
|
2619
|
+
log(kleur7.bold("First rows after normalization:"));
|
|
2620
|
+
log(` ${"#".padStart(4)} ${"input".padEnd(36)} ${"normalized".padEnd(36)} flags`);
|
|
1012
2621
|
for (const r of report.sampleRows) {
|
|
1013
2622
|
const idx = String(r.rowIndex + 1).padStart(4);
|
|
1014
2623
|
const inp = trunc(r.input ?? "\u2205", 36).padEnd(36);
|
|
1015
2624
|
const norm = trunc(r.normalized ?? "\u2205", 36).padEnd(36);
|
|
1016
|
-
|
|
2625
|
+
log(` ${idx} ${inp} ${norm} ${r.flags.join(" ") || ""}`);
|
|
1017
2626
|
}
|
|
1018
|
-
|
|
2627
|
+
log("");
|
|
1019
2628
|
}
|
|
1020
2629
|
function trunc(s, n) {
|
|
1021
2630
|
if (s.length <= n) return s;
|
|
1022
2631
|
return s.slice(0, n - 1) + "\u2026";
|
|
1023
2632
|
}
|
|
1024
2633
|
|
|
1025
|
-
// src/
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
).
|
|
1040
|
-
|
|
1041
|
-
assertServerTransport(opts.server, !!opts.insecure);
|
|
1042
|
-
const token = await resolveLoginToken(opts);
|
|
1043
|
-
if (!tokenStringSchema.safeParse(token).success) {
|
|
1044
|
-
throw new Error("malformed token \u2014 expected ptm_<12 chars>_<32 chars>");
|
|
1045
|
-
}
|
|
1046
|
-
await writeCredentials({ token, server: opts.server });
|
|
1047
|
-
console.log(kleur2.green("\u2713"), `Logged in to ${opts.server}`);
|
|
1048
|
-
}
|
|
1049
|
-
);
|
|
1050
|
-
program.command("logout").description("Clear saved credentials").action(async () => {
|
|
1051
|
-
await clearCredentials();
|
|
1052
|
-
console.log(kleur2.green("\u2713"), "Logged out");
|
|
1053
|
-
});
|
|
1054
|
-
program.command("whoami").description("Show current user").action(async () => {
|
|
1055
|
-
const creds = await requireCredentials();
|
|
1056
|
-
const me = await apiClient.me(creds);
|
|
1057
|
-
console.log(`${me.displayName} team=${me.teamId} scopes=${me.scopes.join(",")}`);
|
|
1058
|
-
console.log(`server=${creds.server}`);
|
|
1059
|
-
});
|
|
1060
|
-
program.command("list").description("List recent jobs").action(async () => {
|
|
1061
|
-
const creds = await requireCredentials();
|
|
1062
|
-
const jobs = await apiClient.listJobs(creds);
|
|
1063
|
-
if (jobs.length === 0) {
|
|
1064
|
-
console.log(kleur2.gray("No jobs."));
|
|
1065
|
-
return;
|
|
1066
|
-
}
|
|
1067
|
-
for (const j of jobs) {
|
|
1068
|
-
console.log(
|
|
1069
|
-
`${j.id} ${j.status.padEnd(10)} ${String(j.matchedRows).padStart(6)}/${String(j.totalRows).padStart(6)} matched`
|
|
1070
|
-
);
|
|
1071
|
-
}
|
|
1072
|
-
});
|
|
1073
|
-
program.command("status <jobId>").description("Show job status snapshot").action(async (jobId) => {
|
|
1074
|
-
const creds = await requireCredentials();
|
|
1075
|
-
const job = await apiClient.getJob(creds, jobId);
|
|
1076
|
-
console.log(JSON.stringify(job, null, 2));
|
|
1077
|
-
});
|
|
1078
|
-
program.command("submit <files...>").description(
|
|
1079
|
-
'Submit one or more CSV/JSON files for matching. Accepts shell-expanded paths or quoted glob patterns (e.g. "data/*.csv"). Each file becomes its own job; the file name (without extension) is used as the job name.'
|
|
1080
|
-
).option(
|
|
1081
|
-
"--name <label>",
|
|
1082
|
-
"Job name override (single file only; ignored when multiple files match \u2014 the file name is used)"
|
|
1083
|
-
).requiredOption("--name-column <name>", "Column holding the scientific name").option("--id-column <name>", "Column holding a known ID").option("--family-column <name>", "Column holding family").option("--genus-column <name>", "Column holding genus").option("--rank-column <name>", "Column holding rank").option("--author-column <name>", "Column holding authorship").option("--filter-column <name>", "Only process rows where this column matches --filter-value; others are skipped").option("--filter-value <value>", "Value the --filter-column must equal (trimmed, case-insensitive)").option("--author-mode <mode>", "ignore|prefer|strict", "prefer").option("--parallel <n>", "Per-job parallelism", "4").option("--review-mode <mode>", "off|recommended|strict", "recommended").option(
|
|
1084
|
-
"--keep-infraspecific",
|
|
1085
|
-
"Keep infraspecific accepted taxa (varieties, subspecies, forms) instead of collapsing them up to the species",
|
|
1086
|
-
false
|
|
1087
|
-
).option("--allow-llm", "Allow Layer 7 LLM cascade (breaks ties between competing matches)", false).option("--llm-cap-cents <cents>", "Max LLM spend in cents", "500").option("--referential <version>", "WCVP snapshot version to match against (default: latest)").option("--no-watch", "Do not stream progress after submit").option(
|
|
1088
|
-
"--dry-run",
|
|
1089
|
-
"Preview locally (normalize first rows + detect ID type) and confirm before uploading",
|
|
1090
|
-
false
|
|
1091
|
-
).option(
|
|
1092
|
-
"--dry-run-rows <n>",
|
|
1093
|
-
"Number of rows to show in the dry-run preview table (default 10)",
|
|
1094
|
-
"10"
|
|
1095
|
-
).action(async (files, opts) => {
|
|
1096
|
-
const creds = await requireCredentials();
|
|
1097
|
-
const inputs = await expandInputs(files);
|
|
1098
|
-
if (inputs.length === 0) throw new Error("no input files");
|
|
1099
|
-
if (opts.name && inputs.length > 1) {
|
|
1100
|
-
console.log(
|
|
1101
|
-
kleur2.yellow("!"),
|
|
1102
|
-
"--name ignored for multi-file submit; using each file name as the job name"
|
|
1103
|
-
);
|
|
1104
|
-
}
|
|
1105
|
-
if (inputs.length > 1) {
|
|
1106
|
-
console.log(kleur2.cyan("\u2192"), `${inputs.length} files matched:`);
|
|
1107
|
-
for (const f of inputs) console.log(kleur2.gray(` ${f}`));
|
|
1108
|
-
}
|
|
1109
|
-
if (opts.dryRun) {
|
|
1110
|
-
const idColumn = opts.idColumn ? String(opts.idColumn) : null;
|
|
1111
|
-
const previewRows = Number(opts.dryRunRows ?? 10);
|
|
1112
|
-
for (const file of inputs) {
|
|
1113
|
-
if (inputs.length > 1) console.log(kleur2.bold(`
|
|
1114
|
-
${file}`));
|
|
1115
|
-
const report = await buildDryRunReport(file, {
|
|
1116
|
-
nameColumn: String(opts.nameColumn),
|
|
1117
|
-
idColumn,
|
|
1118
|
-
previewRows: Number.isFinite(previewRows) ? previewRows : 10,
|
|
1119
|
-
sampleLimit: 1e3
|
|
2634
|
+
// src/inputs.ts
|
|
2635
|
+
import { promises as fs5 } from "fs";
|
|
2636
|
+
var GLOB_MAGIC = /[*?[\]{}!()]/;
|
|
2637
|
+
async function expandInputs(patterns) {
|
|
2638
|
+
const files = /* @__PURE__ */ new Set();
|
|
2639
|
+
for (const pattern of patterns) {
|
|
2640
|
+
if (GLOB_MAGIC.test(pattern)) {
|
|
2641
|
+
let matched = false;
|
|
2642
|
+
for await (const file of fs5.glob(pattern)) {
|
|
2643
|
+
files.add(file);
|
|
2644
|
+
matched = true;
|
|
2645
|
+
}
|
|
2646
|
+
if (!matched) throw new CliError(`no files matched: ${pattern}`, EXIT.usage);
|
|
2647
|
+
} else {
|
|
2648
|
+
await fs5.access(pattern).catch(() => {
|
|
2649
|
+
throw new CliError(`file not found: ${pattern}`, EXIT.usage);
|
|
1120
2650
|
});
|
|
1121
|
-
|
|
1122
|
-
}
|
|
1123
|
-
const proceed = await confirm({
|
|
1124
|
-
message: inputs.length > 1 ? `Proceed with upload of ${inputs.length} files?` : "Proceed with upload?",
|
|
1125
|
-
default: true
|
|
1126
|
-
});
|
|
1127
|
-
if (!proceed) {
|
|
1128
|
-
console.log(kleur2.gray("Aborted."));
|
|
1129
|
-
return;
|
|
2651
|
+
files.add(pattern);
|
|
1130
2652
|
}
|
|
1131
2653
|
}
|
|
2654
|
+
return [...files].sort();
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
// src/job-config.ts
|
|
2658
|
+
import "zod";
|
|
2659
|
+
function buildJobConfig(opts) {
|
|
1132
2660
|
const config = {
|
|
1133
|
-
nameColumn:
|
|
1134
|
-
idColumn: opts.idColumn
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
2661
|
+
nameColumn: opts.nameColumn,
|
|
2662
|
+
idColumn: opts.idColumn ?? null,
|
|
2663
|
+
idType: opts.idType,
|
|
2664
|
+
familyColumn: opts.familyColumn ?? null,
|
|
2665
|
+
genusColumn: opts.genusColumn ?? null,
|
|
2666
|
+
rankColumn: opts.rankColumn ?? null,
|
|
2667
|
+
authorColumn: opts.authorColumn ?? null,
|
|
2668
|
+
filterColumn: opts.filterColumn ?? null,
|
|
2669
|
+
filterValue: opts.filterValue ?? null,
|
|
2670
|
+
authorMode: opts.authorMode,
|
|
1142
2671
|
matchAuthors: true,
|
|
1143
|
-
parallelism:
|
|
2672
|
+
parallelism: parseIntegerFlag(opts.parallel, "--parallel"),
|
|
1144
2673
|
allowFuzzy: true,
|
|
1145
|
-
allowLlm:
|
|
1146
|
-
llmCostCapCents:
|
|
1147
|
-
reviewMode:
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
speciesLevelAcceptedOnly: !opts.keepInfraspecific,
|
|
2674
|
+
allowLlm: opts.allowLlm,
|
|
2675
|
+
llmCostCapCents: parseIntegerFlag(opts.llmCapCents, "--llm-cap-cents"),
|
|
2676
|
+
reviewMode: opts.reviewMode,
|
|
2677
|
+
speciesLevelAcceptedOnly: opts.speciesLevel,
|
|
2678
|
+
acceptUnconfirmedAuthor: opts.ignoreAuthor,
|
|
1151
2679
|
exportConfirmedOnly: false,
|
|
1152
|
-
|
|
1153
|
-
...opts.referential ? { referentialVersion: String(opts.referential) } : {}
|
|
2680
|
+
...opts.referential ? { referentialVersion: opts.referential } : {}
|
|
1154
2681
|
};
|
|
1155
|
-
const
|
|
1156
|
-
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
console.log(kleur2.green("\u2713"), `Job created: ${job.id} ${kleur2.gray(jobName)}`);
|
|
1161
|
-
console.log(` rows=${job.totalRows} uniqueQueries=${job.uniqueQueries}`);
|
|
1162
|
-
submitted.push({ id: job.id, file });
|
|
1163
|
-
}
|
|
1164
|
-
if (opts.watch === false) return;
|
|
1165
|
-
for (const s of submitted) {
|
|
1166
|
-
if (submitted.length > 1) console.log(kleur2.bold(`
|
|
1167
|
-
[${s.file}] ${s.id}`));
|
|
1168
|
-
await streamJob(creds, s.id);
|
|
1169
|
-
}
|
|
1170
|
-
});
|
|
1171
|
-
program.command("watch <jobId>").description("Stream NDJSON progress for a job").action(async (jobId) => {
|
|
1172
|
-
const creds = await requireCredentials();
|
|
1173
|
-
await streamJob(creds, jobId);
|
|
1174
|
-
});
|
|
1175
|
-
program.command("pause <jobId>").description("Pause a running job (worker stops between match queries)").action(async (jobId) => {
|
|
1176
|
-
const creds = await requireCredentials();
|
|
1177
|
-
const job = await apiClient.pauseJob(creds, jobId);
|
|
1178
|
-
console.log(kleur2.green("\u2713"), `paused: ${job.id} (status=${job.status})`);
|
|
1179
|
-
});
|
|
1180
|
-
program.command("resume <jobId>").description("Resume a paused job (re-enqueues the match stage)").action(async (jobId) => {
|
|
1181
|
-
const creds = await requireCredentials();
|
|
1182
|
-
const job = await apiClient.resumeJob(creds, jobId);
|
|
1183
|
-
console.log(kleur2.green("\u2713"), `resumed: ${job.id} (status=${job.status})`);
|
|
1184
|
-
});
|
|
1185
|
-
program.command("cancel <jobId>").description("Cancel a job. Already-matched rows are kept; pending queries stop.").action(async (jobId) => {
|
|
1186
|
-
const creds = await requireCredentials();
|
|
1187
|
-
const job = await apiClient.cancelJob(creds, jobId);
|
|
1188
|
-
console.log(kleur2.green("\u2713"), `cancelled: ${job.id} (status=${job.status})`);
|
|
1189
|
-
});
|
|
1190
|
-
program.command("download <jobId>").description("Download a job export (CSV, JSON, or NDJSON; optionally bundled with NOTICE.md)").option("--format <fmt>", "csv | xlsx | json | ndjson", "csv").option("--confirmed-only", "only matched rows that are accepted / not pending", false).option("--delimiter <sep>", "CSV separator: comma | semicolon | tab | pipe (csv format only)", "comma").option("--bundle", "wrap in a ZIP with NOTICE.md citing the WCVP snapshot + providers", false).option(
|
|
1191
|
-
"--columns <list>",
|
|
1192
|
-
"comma-separated result/upload column keys to KEEP (default: all). See --list-columns"
|
|
1193
|
-
).option(
|
|
1194
|
-
"--wcvp-extra <list>",
|
|
1195
|
-
"comma-separated extra WCVP fields to append as wcvp_<key> columns (e.g. ipni_id,powo_id). See --list-columns"
|
|
1196
|
-
).option("--list-columns", "print the columns available for this job and exit", false).option("--output <path>", "write to this path; default is the server-provided filename in CWD").action(async (jobId, opts) => {
|
|
1197
|
-
const creds = await requireCredentials();
|
|
1198
|
-
if (opts.listColumns) {
|
|
1199
|
-
const cat = await apiClient.downloadColumns(creds, jobId);
|
|
1200
|
-
console.log(kleur2.bold("Result columns (--columns):"));
|
|
1201
|
-
for (const r of cat.result) console.log(` ${r.key} ${kleur2.gray(`(${r.group})`)}`);
|
|
1202
|
-
if (cat.original.length > 0) {
|
|
1203
|
-
console.log(kleur2.bold("\nYour upload columns (--columns):"));
|
|
1204
|
-
for (const k of cat.original) console.log(` ${k}`);
|
|
1205
|
-
}
|
|
1206
|
-
console.log(kleur2.bold("\nWCVP extra fields (--wcvp-extra):"));
|
|
1207
|
-
for (const f of cat.wcvpExtra) console.log(` ${f.key} ${kleur2.gray(`(${f.group})`)}`);
|
|
1208
|
-
return;
|
|
1209
|
-
}
|
|
1210
|
-
const format = opts.format ?? "csv";
|
|
1211
|
-
if (format !== "csv" && format !== "json" && format !== "ndjson" && format !== "xlsx") {
|
|
1212
|
-
throw new Error(`--format must be csv, xlsx, json or ndjson (got ${String(format)})`);
|
|
1213
|
-
}
|
|
1214
|
-
const confirmedOnly = !!opts.confirmedOnly;
|
|
1215
|
-
const bundle = !!opts.bundle;
|
|
1216
|
-
const delimiter = String(opts.delimiter ?? "comma");
|
|
1217
|
-
if (!["comma", "semicolon", "tab", "pipe"].includes(delimiter)) {
|
|
1218
|
-
throw new Error(`--delimiter must be comma, semicolon, tab or pipe (got ${delimiter})`);
|
|
1219
|
-
}
|
|
1220
|
-
const { filename, body } = await apiClient.downloadJob(creds, jobId, {
|
|
1221
|
-
format,
|
|
1222
|
-
confirmedOnly,
|
|
1223
|
-
bundle,
|
|
1224
|
-
...format === "csv" && delimiter !== "comma" ? { delimiter } : {},
|
|
1225
|
-
...opts.columns ? { columns: String(opts.columns) } : {},
|
|
1226
|
-
...opts.wcvpExtra ? { wcvpExtra: String(opts.wcvpExtra) } : {}
|
|
1227
|
-
});
|
|
1228
|
-
const outPath = opts.output ?? filename;
|
|
1229
|
-
const { writeFile } = await import("fs/promises");
|
|
1230
|
-
await writeFile(outPath, body);
|
|
1231
|
-
console.log(kleur2.green("\u2713"), `wrote ${body.length} bytes to ${outPath}`);
|
|
1232
|
-
});
|
|
1233
|
-
program.parseAsync(process.argv).catch((err) => {
|
|
1234
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1235
|
-
console.error(kleur2.red("error:"), msg);
|
|
1236
|
-
process.exit(1);
|
|
1237
|
-
});
|
|
1238
|
-
function assertServerTransport(server, insecure) {
|
|
1239
|
-
let u;
|
|
1240
|
-
try {
|
|
1241
|
-
u = new URL(server);
|
|
1242
|
-
} catch {
|
|
1243
|
-
throw new Error(`invalid --server URL: ${server}`);
|
|
1244
|
-
}
|
|
1245
|
-
if (u.protocol === "https:") return;
|
|
1246
|
-
if (u.protocol !== "http:") {
|
|
1247
|
-
throw new Error(`--server must use http or https (got ${u.protocol})`);
|
|
1248
|
-
}
|
|
1249
|
-
const host = u.hostname;
|
|
1250
|
-
const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.endsWith(".localhost");
|
|
1251
|
-
if (!isLoopback && !insecure) {
|
|
1252
|
-
throw new Error(
|
|
1253
|
-
`refusing to send a token in cleartext to ${u.host}. Use an https URL, or pass --insecure to override (NOT recommended).`
|
|
1254
|
-
);
|
|
2682
|
+
const checked = jobConfigSchema.safeParse(config);
|
|
2683
|
+
if (!checked.success) {
|
|
2684
|
+
const issue = checked.error.issues[0];
|
|
2685
|
+
const field = issue?.path.join(".") ?? "config";
|
|
2686
|
+
throw new CliError(`invalid ${field}: ${issue?.message ?? "rejected"}`, EXIT.usage);
|
|
1255
2687
|
}
|
|
2688
|
+
return config;
|
|
1256
2689
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
return
|
|
2690
|
+
|
|
2691
|
+
// src/commands/submit.ts
|
|
2692
|
+
function summarize(submitted, finals, creds) {
|
|
2693
|
+
return submitted.map(({ file, job }) => ({
|
|
2694
|
+
id: job.id,
|
|
2695
|
+
name: job.name,
|
|
2696
|
+
file,
|
|
2697
|
+
status: finals.find((f) => f.id === job.id)?.status ?? job.status,
|
|
2698
|
+
totalRows: job.totalRows,
|
|
2699
|
+
uniqueQueries: job.uniqueQueries,
|
|
2700
|
+
url: jobUrl(creds, job.id)
|
|
2701
|
+
}));
|
|
1261
2702
|
}
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
2703
|
+
function jobNameFor(file, fileCount, name) {
|
|
2704
|
+
return fileCount === 1 && name?.trim() ? name.trim() : parsePath(file).name;
|
|
2705
|
+
}
|
|
2706
|
+
async function confirmDryRun(deps2, out, files, opts) {
|
|
2707
|
+
const previewRows = Number(opts.dryRunRows);
|
|
2708
|
+
for (const file of files) {
|
|
2709
|
+
if (files.length > 1) out.info(kleur8.bold(`
|
|
2710
|
+
${file}`));
|
|
2711
|
+
const report = await buildDryRunReport(file, {
|
|
2712
|
+
nameColumn: opts.nameColumn,
|
|
2713
|
+
idColumn: opts.idColumn ?? null,
|
|
2714
|
+
previewRows: Number.isFinite(previewRows) ? previewRows : 10,
|
|
2715
|
+
sampleLimit: 1e3
|
|
2716
|
+
});
|
|
2717
|
+
printDryRunReport(report, out.info);
|
|
1267
2718
|
}
|
|
1268
|
-
if (opts.
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
if (!process.stdin.isTTY) {
|
|
1272
|
-
throw new Error(
|
|
1273
|
-
"no token provided. Pass --token-stdin, set PLANTTAXOMATCHER_TOKEN, or run interactively."
|
|
1274
|
-
);
|
|
2719
|
+
if (opts.yes) return true;
|
|
2720
|
+
if (!deps2.stdinIsTTY) {
|
|
2721
|
+
throw new CliError("--dry-run needs a confirmation", EXIT.usage, "Pass --yes to upload after the preview.");
|
|
1275
2722
|
}
|
|
1276
|
-
const
|
|
1277
|
-
return
|
|
2723
|
+
const message = files.length > 1 ? `Proceed with upload of ${files.length} files?` : "Proceed with upload?";
|
|
2724
|
+
return deps2.promptConfirm(message);
|
|
1278
2725
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
2726
|
+
function registerSubmitCommand(program, deps2) {
|
|
2727
|
+
program.command("submit <files...>").description(
|
|
2728
|
+
'Submit CSV/XLSX/JSON files for matching, one job per file (named after the file). Accepts shell-expanded paths or quoted globs such as "data/*.csv". Follows progress until the jobs stop unless --no-watch; exits 4 if one fails, 5 if one is paused.'
|
|
2729
|
+
).option("--name <label>", "Job name (single file only; with several files each job is named after its file)").requiredOption("--name-column <name>", "Column holding the scientific name").option("--id-column <name>", "Column holding a known ID").option("--family-column <name>", "Column holding family").option("--genus-column <name>", "Column holding genus").option("--rank-column <name>", "Column holding rank").option("--author-column <name>", "Column holding authorship").option(
|
|
2730
|
+
"--filter-column <name>",
|
|
2731
|
+
"Only process rows where this column matches --filter-value; others are skipped"
|
|
2732
|
+
).option("--filter-value <value>", "Value the --filter-column must equal (trimmed, case-insensitive)").option("--author-mode <mode>", "ignore|prefer|strict", "prefer").option("--parallel <n>", "Per-job parallelism (1-10)", "4").option("--review-mode <mode>", "off|recommended|strict", "recommended").option(
|
|
2733
|
+
"--id-type <type>",
|
|
2734
|
+
"What --id-column holds: auto (backbone id, else GBIF key for bare integers), wcvp, gbif",
|
|
2735
|
+
"auto"
|
|
2736
|
+
).option(
|
|
2737
|
+
"--species-level",
|
|
2738
|
+
"Roll infraspecific accepted taxa (varieties, subspecies, forms) up to their species",
|
|
2739
|
+
false
|
|
2740
|
+
).option(
|
|
2741
|
+
"--ignore-author",
|
|
2742
|
+
"Accept a unique canonical name match whose author couldn't be confirmed instead of sending it to review (a CONFLICTING author still reviews)",
|
|
2743
|
+
false
|
|
2744
|
+
).option("--keep-infraspecific", "Deprecated \u2014 now the default; has no effect", false).option("--allow-llm", "Allow the Layer 7 LLM to break ties between competing matches", false).option("--llm-cap-cents <cents>", "Max LLM spend in cents", "500").option(
|
|
2745
|
+
"--referential <version>",
|
|
2746
|
+
"WCVP snapshot version to match against (default: your team's default snapshot, else the newest import)"
|
|
2747
|
+
).option("--no-watch", "Return as soon as the jobs are created").option("--dry-run", "Preview the first rows locally, then confirm before uploading", false).option("--dry-run-rows <n>", "Rows to show in the dry-run preview", "10").option("-y, --yes", "Skip the --dry-run confirmation (needed without a terminal)", false).option("--json", "Print the created jobs as a JSON array (with --watch: once they end)", false).action(async (patterns, opts) => {
|
|
2748
|
+
const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
|
|
2749
|
+
const config = buildJobConfig(opts);
|
|
2750
|
+
const creds = await requireCredentials(deps2.store, deps2.env);
|
|
2751
|
+
const files = await expandInputs(patterns);
|
|
2752
|
+
if (opts.name && files.length > 1)
|
|
2753
|
+
out.warn("--name ignored for a multi-file submit; each job is named after its file");
|
|
2754
|
+
if (opts.keepInfraspecific) {
|
|
2755
|
+
out.warn(
|
|
2756
|
+
"--keep-infraspecific is deprecated and does nothing \u2014 pass --species-level to roll up to the species"
|
|
2757
|
+
);
|
|
1295
2758
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
2759
|
+
if (files.length > 1) {
|
|
2760
|
+
out.info(`${kleur8.cyan("\u2192")} ${files.length} files matched:`);
|
|
2761
|
+
for (const file of files) out.info(kleur8.gray(` ${file}`));
|
|
2762
|
+
}
|
|
2763
|
+
if (opts.dryRun && !await confirmDryRun(deps2, out, files, opts)) {
|
|
2764
|
+
out.info(kleur8.gray("Aborted."));
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
const submitted = [];
|
|
2768
|
+
const finals = [];
|
|
2769
|
+
try {
|
|
2770
|
+
for (const file of files) {
|
|
2771
|
+
const name = jobNameFor(file, files.length, opts.name);
|
|
2772
|
+
out.info(`${kleur8.cyan("\u2192")} Uploading ${file} \u2026`);
|
|
2773
|
+
const job = await deps2.api.submitJob(creds, file, config, name || null);
|
|
2774
|
+
out.success(`Job created: ${job.id} ${kleur8.gray(name)}`);
|
|
2775
|
+
out.info(` rows=${job.totalRows} uniqueQueries=${job.uniqueQueries}`);
|
|
2776
|
+
submitted.push({ file, job });
|
|
1310
2777
|
}
|
|
1311
|
-
if (
|
|
1312
|
-
|
|
1313
|
-
|
|
2778
|
+
if (opts.watch) {
|
|
2779
|
+
const watchOut = opts.json ? createOutput(deps2.stderr, deps2.stderr, false, deps2.now) : out;
|
|
2780
|
+
for (const { file, job } of submitted) {
|
|
2781
|
+
if (submitted.length > 1) watchOut.info(kleur8.bold(`
|
|
2782
|
+
[${file}] ${job.id}`));
|
|
2783
|
+
finals.push({ id: job.id, status: await watchJob(deps2.api, watchOut, creds, job.id) });
|
|
2784
|
+
}
|
|
1314
2785
|
}
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
const total = evt.totalQueries ?? evt.totalRows ?? 0;
|
|
1318
|
-
process.stdout.write(`\r progress: ${p}/${total} `);
|
|
1319
|
-
} else if (t === "completed") {
|
|
1320
|
-
process.stdout.write("\n");
|
|
1321
|
-
console.log(kleur2.green("\u2713"), "completed");
|
|
1322
|
-
return;
|
|
1323
|
-
} else if (t === "error") {
|
|
1324
|
-
process.stdout.write("\n");
|
|
1325
|
-
console.error(kleur2.red("error:"), evt.message);
|
|
1326
|
-
return;
|
|
2786
|
+
} finally {
|
|
2787
|
+
if (opts.json) out.data(summarize(submitted, finals, creds));
|
|
1327
2788
|
}
|
|
2789
|
+
const failure = watchOutcomeError(finals);
|
|
2790
|
+
if (failure) throw failure;
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// src/program.ts
|
|
2795
|
+
var EXIT_CODES_HELP = `
|
|
2796
|
+
Exit codes:
|
|
2797
|
+
0 success
|
|
2798
|
+
1 error (API or network failure)
|
|
2799
|
+
2 usage error (bad flag or argument)
|
|
2800
|
+
3 not signed in, or the token was rejected
|
|
2801
|
+
4 a watched job ended failed or cancelled
|
|
2802
|
+
5 a watched job was paused (resume it, then watch again)
|
|
2803
|
+
|
|
2804
|
+
Environment:
|
|
2805
|
+
PLANTTAXOMATCHER_TOKEN token to use instead of the saved login
|
|
2806
|
+
PLANTTAXOMATCHER_SERVER its server (default: the public server); alone, it
|
|
2807
|
+
must match the saved login's server`;
|
|
2808
|
+
function buildProgram(deps2, version2) {
|
|
2809
|
+
const program = new Command().name("planttaxomatcher").description("Reconcile plant names against WCVP with the PlantTaxoMatcher API").version(version2).addHelpText("after", EXIT_CODES_HELP).exitOverride().configureOutput({
|
|
2810
|
+
writeOut: (text) => deps2.stdout.write(text),
|
|
2811
|
+
writeErr: (text) => deps2.stderr.write(text)
|
|
2812
|
+
});
|
|
2813
|
+
registerAuthCommands(program, deps2);
|
|
2814
|
+
registerJobCommands(program, deps2);
|
|
2815
|
+
registerSubmitCommand(program, deps2);
|
|
2816
|
+
registerDownloadCommand(program, deps2);
|
|
2817
|
+
registerSkillsCommands(program, deps2, version2);
|
|
2818
|
+
return program;
|
|
2819
|
+
}
|
|
2820
|
+
async function runCli(argv, deps2, version2) {
|
|
2821
|
+
try {
|
|
2822
|
+
await buildProgram(deps2, version2).parseAsync(argv, { from: "user" });
|
|
2823
|
+
return 0;
|
|
2824
|
+
} catch (err) {
|
|
2825
|
+
const { exitCode, message, hint } = describeError(err);
|
|
2826
|
+
if (message) deps2.stderr.write(`${kleur9.red("error:")} ${message}
|
|
2827
|
+
`);
|
|
2828
|
+
if (hint) deps2.stderr.write(`${kleur9.gray(hint)}
|
|
2829
|
+
`);
|
|
2830
|
+
return exitCode;
|
|
1328
2831
|
}
|
|
1329
2832
|
}
|
|
2833
|
+
|
|
2834
|
+
// src/index.ts
|
|
2835
|
+
var { version } = createRequire(import.meta.url)("../package.json");
|
|
2836
|
+
var deps = defaultDeps();
|
|
2837
|
+
await refreshSkillIfOutdated(skillPaths(deps.home, deps.env), deps.skillSource, version);
|
|
2838
|
+
process.exitCode = await runCli(process.argv.slice(2), deps, version);
|