@myelinbridge/cli 0.2.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -41
- package/bin/myelin.js +616 -413
- package/package.json +23 -23
package/README.md
CHANGED
|
@@ -1,41 +1,105 @@
|
|
|
1
|
-
# @myelinbridge/cli
|
|
2
|
-
|
|
3
|
-
Push R&D data deliveries into [Myelin](https://myelinbridge.com) from a pipeline —
|
|
4
|
-
preflight against the client's published quality rules, resumable upload, submit,
|
|
5
|
-
and track review outcomes. Full API reference: https://myelinbridge.com/developers.
|
|
6
|
-
|
|
7
|
-
## Quick start (~10 minutes from key to first submit)
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
export MYELIN_API_KEY=myl_live_… # created by your bridge owner in Bridge → API
|
|
11
|
-
|
|
12
|
-
npx @myelinbridge/cli ping # verifies auth, prints your projects
|
|
13
|
-
npx @myelinbridge/cli datasets # what you can deliver to, and whose move it is
|
|
14
|
-
npx @myelinbridge/cli
|
|
15
|
-
npx @myelinbridge/cli
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
1
|
+
# @myelinbridge/cli
|
|
2
|
+
|
|
3
|
+
Push R&D data deliveries into [Myelin](https://myelinbridge.com) from a pipeline —
|
|
4
|
+
preflight against the client's published quality rules, resumable upload, submit,
|
|
5
|
+
and track review outcomes. Full API reference: https://myelinbridge.com/developers.
|
|
6
|
+
|
|
7
|
+
## Quick start (~10 minutes from key to first submit)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
export MYELIN_API_KEY=myl_live_… # created by your bridge owner in Bridge → API
|
|
11
|
+
|
|
12
|
+
npx @myelinbridge/cli ping # verifies auth, prints your projects
|
|
13
|
+
npx @myelinbridge/cli datasets # what you can deliver to, and whose move it is
|
|
14
|
+
npx @myelinbridge/cli contract --dataset onco1-wes # what is expected of your delivery
|
|
15
|
+
npx @myelinbridge/cli sample-depth 1 --dataset onco1-wes # once, before your first submit
|
|
16
|
+
npx @myelinbridge/cli check ./run_042 --dataset onco1-wes # validate BEFORE uploading a byte
|
|
17
|
+
npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- **Declare your sample depth once, first.** The client creates and describes the
|
|
21
|
+
dataset; you own your output structure, so you tell Myelin at which folder depth a
|
|
22
|
+
sample sits — `0` = the delivery root is one sample, `1` (default) = each top-level
|
|
23
|
+
folder is a sample, `2` = one level deeper. Sample-scoped quality checks group by it,
|
|
24
|
+
so getting it right up front is what makes per-sample verdicts mean anything. It is
|
|
25
|
+
**idempotent** (safe to assert on every pipeline run) and **locks once your first
|
|
26
|
+
batch leaves draft**, so that verdicts stay comparable across deliveries — after that
|
|
27
|
+
the command exits `2`. This is the only dataset field you can write.
|
|
28
|
+
|
|
29
|
+
- **Resume = re-run.** `push` is idempotent: already-uploaded files are skipped
|
|
30
|
+
(path + size), and within a large file, parts that already landed are skipped
|
|
31
|
+
too (S3 multipart). Uploads go direct to storage over short-lived presigned
|
|
32
|
+
URLs — no credential is stored on your machine, and revoking the API key cuts
|
|
33
|
+
off signing immediately.
|
|
34
|
+
- **Read the contract before you build the delivery.** `contract` prints what the
|
|
35
|
+
client expects — every check as one plain sentence, grouped by what it answers
|
|
36
|
+
(completeness, structure, validity, consistency, integrity, privacy), and marked
|
|
37
|
+
`!` when a failure blocks validation. It also tells you which checks `check` can
|
|
38
|
+
verify locally and which only run once the files are uploaded, so nothing about
|
|
39
|
+
the bar is a surprise at review time.
|
|
40
|
+
|
|
41
|
+
- **`check` costs nothing.** It evaluates your local file list against the dataset's
|
|
42
|
+
quality checks server-side — same engine, same verdicts as submit — without
|
|
43
|
+
uploading. Exit code 2 means a blocking rule fails.
|
|
44
|
+
- **The fix loop is machine-readable.** On `changes_requested`,
|
|
45
|
+
`myelin status <batch> --json` returns the failed files, reviewer comments, and
|
|
46
|
+
rule remediation hints; fix, re-`push --submit`, unchanged files keep their
|
|
47
|
+
review votes.
|
|
48
|
+
|
|
49
|
+
## If you are the client, not the partner
|
|
50
|
+
|
|
51
|
+
Two kinds of key exist, and they are not interchangeable. Everything above needs a
|
|
52
|
+
**partner key** (write: upload, submit). A **client key** is read-only and answers the
|
|
53
|
+
question your own systems ask once the data has landed: *the bucket is full of UUIDs —
|
|
54
|
+
what is this?*
|
|
55
|
+
|
|
56
|
+
By default a client key is **organisation-wide**: one key, every partner, one answer. Your
|
|
57
|
+
organisation admin creates it in **Organisation → API keys**. If your governance is per-partner,
|
|
58
|
+
a bridge owner can create a bridge-scoped one instead in **Bridge → API → "Your read keys"**.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
export MYELIN_API_KEY=myl_live_…
|
|
62
|
+
|
|
63
|
+
# What has landed, newest first
|
|
64
|
+
myelin deliveries --dataset <dataset-id>
|
|
65
|
+
|
|
66
|
+
# What is this object, exactly?
|
|
67
|
+
myelin resolve gs://acme-landing/inbox/acme-cro/4319…/de99…/7aa5…/data/SAMPLE_01/reads.fastq.gz
|
|
68
|
+
# file · gs://…/reads.fastq.gz
|
|
69
|
+
# project ONCO1 — Oncology discovery
|
|
70
|
+
# dataset WES batch 7 (Genomics)
|
|
71
|
+
# batch ONCO1-WES-007 · #7
|
|
72
|
+
# validated 2026-08-07T18:05:12Z · delivered 2026-08-07T18:06:20Z
|
|
73
|
+
# manifest gs://…/7aa5…/_myelin/manifest.json
|
|
74
|
+
# file reads.fastq.gz · 4096 bytes
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`resolve` accepts a full `gs://`/`s3://` URI, a bare prefix, or a single id, and answers at
|
|
78
|
+
whatever granularity the path supports.
|
|
79
|
+
|
|
80
|
+
Each delivery also carries the same record **as a file**, written next to the data at
|
|
81
|
+
`_myelin/manifest.json` (plus `_myelin/files.csv`, a flat table you can load straight into a
|
|
82
|
+
warehouse). Prefer the file for anything auditable: it is frozen at delivery time, needs no
|
|
83
|
+
credentials, and does not depend on Myelin being reachable. Use the API when you want it live.
|
|
84
|
+
|
|
85
|
+
A partner key calling these gets `403 wrong_key_side`, and vice versa.
|
|
86
|
+
|
|
87
|
+
## Machine mode
|
|
88
|
+
|
|
89
|
+
Every command takes `--json`. Exit codes: `0` ok · `1` error · `2` blocked
|
|
90
|
+
(blocking preflight failure, locked delivery, blocked submit, locked sample depth).
|
|
91
|
+
|
|
92
|
+
## Environment
|
|
93
|
+
|
|
94
|
+
| Variable | |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `MYELIN_API_KEY` | Required. Created by your bridge owner in **Bridge → API**. |
|
|
97
|
+
| `MYELIN_API_URL` | Optional. Defaults to `https://myelinbridge.com/api/v1`. |
|
|
98
|
+
| `MYELIN_API_HEADER` | Optional. Extra headers sent with every API call, one `Name: value` per line — for a Myelin deployment fronted by something that authenticates before Myelin does (a corporate gateway, an SSO-protected preview). It can never override `Authorization`. |
|
|
99
|
+
|
|
100
|
+
## Webhooks instead of polling
|
|
101
|
+
|
|
102
|
+
Register an HTTPS endpoint (portal Bridge → API, or `POST /v1/webhook-endpoints`)
|
|
103
|
+
to receive signed events (`batch.validated`, `batch.changes_requested`,
|
|
104
|
+
`batch.transferred`, …). Verification snippets: https://myelinbridge.com/developers.
|
|
105
|
+
Polling fallback: `GET /v1/events?cursor=…`.
|
package/bin/myelin.js
CHANGED
|
@@ -1,413 +1,616 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// @myelinbridge/cli — Partner Ingestion CLI (Partner API Phase 5, design §3.3).
|
|
3
|
-
//
|
|
4
|
-
// export MYELIN_API_KEY=myl_live_…
|
|
5
|
-
// npx @myelinbridge/cli ping
|
|
6
|
-
// npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
7
|
-
//
|
|
8
|
-
// Exit codes: 0 ok · 1 error · 2 blocked (failing checks / locked delivery).
|
|
9
|
-
// Every command accepts --json for machine-readable output.
|
|
10
|
-
|
|
11
|
-
import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
|
|
12
|
-
import { resolve, join, relative, sep, basename } from 'node:path'
|
|
13
|
-
import process from 'node:process'
|
|
14
|
-
|
|
15
|
-
const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
|
|
16
|
-
const KEY = process.env.MYELIN_API_KEY
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
async function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @myelinbridge/cli — Partner Ingestion CLI (Partner API Phase 5, design §3.3).
|
|
3
|
+
//
|
|
4
|
+
// export MYELIN_API_KEY=myl_live_…
|
|
5
|
+
// npx @myelinbridge/cli ping
|
|
6
|
+
// npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
7
|
+
//
|
|
8
|
+
// Exit codes: 0 ok · 1 error · 2 blocked (failing checks / locked delivery).
|
|
9
|
+
// Every command accepts --json for machine-readable output.
|
|
10
|
+
|
|
11
|
+
import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
|
|
12
|
+
import { resolve, join, relative, sep, basename } from 'node:path'
|
|
13
|
+
import process from 'node:process'
|
|
14
|
+
|
|
15
|
+
const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
|
|
16
|
+
const KEY = process.env.MYELIN_API_KEY
|
|
17
|
+
|
|
18
|
+
// Extra headers sent with every API call — newline-separated "Name: value"
|
|
19
|
+
// pairs in MYELIN_API_HEADER. For deployments fronted by something that
|
|
20
|
+
// authenticates before Myelin does (a corporate gateway, an SSO-protected
|
|
21
|
+
// preview environment). Never overrides Authorization.
|
|
22
|
+
const EXTRA_HEADERS = Object.fromEntries(
|
|
23
|
+
(process.env.MYELIN_API_HEADER ?? '')
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map((line) => line.trim())
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((line) => {
|
|
28
|
+
const i = line.indexOf(':')
|
|
29
|
+
if (i < 1) return null
|
|
30
|
+
return [line.slice(0, i).trim(), line.slice(i + 1).trim()]
|
|
31
|
+
})
|
|
32
|
+
.filter(Boolean),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const argv = process.argv.slice(2)
|
|
36
|
+
const JSON_MODE = argv.includes('--json')
|
|
37
|
+
const args = argv.filter((a) => a !== '--json')
|
|
38
|
+
const command = args[0]
|
|
39
|
+
|
|
40
|
+
const out = (line) => { if (!JSON_MODE) console.log(line) }
|
|
41
|
+
const emit = (obj) => { if (JSON_MODE) console.log(JSON.stringify(obj, null, 2)) }
|
|
42
|
+
|
|
43
|
+
/** Thrown by die() to unwind to main; never reaches the user. */
|
|
44
|
+
class CliExit extends Error {}
|
|
45
|
+
|
|
46
|
+
// die() sets the exit code and unwinds rather than calling process.exit():
|
|
47
|
+
// killing the process while an HTTP socket is still closing trips a libuv
|
|
48
|
+
// assertion on Windows, which printed a C stack trace after a perfectly good
|
|
49
|
+
// error message and replaced the documented exit code with 127. Node exits on
|
|
50
|
+
// its own once the request settles — measured at ~130 ms, no keep-alive stall.
|
|
51
|
+
const die = (message, code = 1) => {
|
|
52
|
+
if (JSON_MODE) console.log(JSON.stringify({ error: message }))
|
|
53
|
+
else console.error(`✗ ${message}`)
|
|
54
|
+
process.exitCode = code
|
|
55
|
+
throw new CliExit(message)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Column widths come from the content. padEnd() alone silently ran a long
|
|
59
|
+
// value into the next column — a 28-character dataset slug swallowed the
|
|
60
|
+
// STATUS header's gutter. Pass headers = null for an unheadered list.
|
|
61
|
+
function table(headers, rows) {
|
|
62
|
+
const cols = headers?.length ?? rows[0]?.length ?? 0
|
|
63
|
+
const widths = Array.from({ length: cols }, (_, i) =>
|
|
64
|
+
Math.max(headers?.[i]?.length ?? 0, 0, ...rows.map((r) => String(r[i] ?? '').length)),
|
|
65
|
+
)
|
|
66
|
+
const line = (cells) =>
|
|
67
|
+
cells
|
|
68
|
+
.map((c, i) => (i === cols - 1 ? String(c ?? '') : String(c ?? '').padEnd(widths[i])))
|
|
69
|
+
.join(' ')
|
|
70
|
+
.trimEnd()
|
|
71
|
+
return headers ? [line(headers), ...rows.map(line)] : rows.map(line)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function flag(name) {
|
|
75
|
+
return args.includes(`--${name}`)
|
|
76
|
+
}
|
|
77
|
+
function opt(name) {
|
|
78
|
+
const i = args.indexOf(`--${name}`)
|
|
79
|
+
return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function api(method, path, body, raw) {
|
|
83
|
+
const res = await fetch(API_URL + path, {
|
|
84
|
+
method,
|
|
85
|
+
headers: {
|
|
86
|
+
...EXTRA_HEADERS,
|
|
87
|
+
Authorization: `Bearer ${KEY}`,
|
|
88
|
+
...(body && !raw ? { 'Content-Type': 'application/json' } : {}),
|
|
89
|
+
},
|
|
90
|
+
body: raw ? body : body ? JSON.stringify(body) : undefined,
|
|
91
|
+
}).catch((err) => die(`Cannot reach ${API_URL}: ${err.message}`))
|
|
92
|
+
let json = null
|
|
93
|
+
try { json = await res.json() } catch { /* non-JSON body */ }
|
|
94
|
+
return { status: res.status, json }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function expectOk(r, context) {
|
|
98
|
+
if (r.status >= 200 && r.status < 300) return r.json
|
|
99
|
+
const detail = r.json?.detail ?? `HTTP ${r.status}`
|
|
100
|
+
const code = r.json?.code ?? 'error'
|
|
101
|
+
const blocked = ['delivery_locked', 'submit_blocked', 'dataset_not_active', 'api_disabled', 'bridge_paused', 'sample_depth_locked'].includes(code)
|
|
102
|
+
die(`${context}: [${code}] ${detail}`, blocked ? 2 : 1)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ——— dataset resolution (id or slug) ———
|
|
106
|
+
async function resolveDataset(ref) {
|
|
107
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
|
|
108
|
+
const r = await api('GET', `/datasets/${ref}`)
|
|
109
|
+
if (r.status === 200) return r.json.dataset
|
|
110
|
+
die(`Dataset ${ref} not found in this key's scope`)
|
|
111
|
+
}
|
|
112
|
+
const me = expectOk(await api('GET', '/me'), 'auth')
|
|
113
|
+
for (const p of me.projects) {
|
|
114
|
+
const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
|
|
115
|
+
const hit = r.datasets.find((d) => d.slug === ref || d.name === ref)
|
|
116
|
+
if (hit) return hit
|
|
117
|
+
}
|
|
118
|
+
die(`No dataset with slug or name "${ref}" in this key's scope`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ——— local manifest ———
|
|
122
|
+
function walkDir(root) {
|
|
123
|
+
const files = []
|
|
124
|
+
const walk = (dir) => {
|
|
125
|
+
for (const entry of readdirSync(dir)) {
|
|
126
|
+
const full = join(dir, entry)
|
|
127
|
+
const st = statSync(full)
|
|
128
|
+
if (st.isDirectory()) walk(full)
|
|
129
|
+
else files.push({ abs: full, path: '/' + relative(root, full).split(sep).join('/'), size: st.size })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
walk(root)
|
|
133
|
+
return files
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function fmtBytes(n) {
|
|
137
|
+
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'
|
|
138
|
+
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'
|
|
139
|
+
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'
|
|
140
|
+
return n + ' B'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Upload a file via S3 presigned multipart (SEC-TOKEN-01: no bearer token ever
|
|
144
|
+
// reaches the client — declare tells us the part size, and each part gets a
|
|
145
|
+
// short-lived presigned PUT URL scoped to exactly that one part of one object).
|
|
146
|
+
// Resumable: the sign step reports parts that already landed, so a re-run skips
|
|
147
|
+
// them. Returns { upload_id, parts: [{part_number, etag}] } for confirm.
|
|
148
|
+
const SIGN_WINDOW = 1000 // ≤ the server's max_parts_per_sign
|
|
149
|
+
|
|
150
|
+
async function readPart(absPath, start, end) {
|
|
151
|
+
const chunks = []
|
|
152
|
+
await new Promise((res, rej) => {
|
|
153
|
+
const s = createReadStream(absPath, { start, end }) // end inclusive
|
|
154
|
+
s.on('data', (c) => chunks.push(c))
|
|
155
|
+
s.on('end', res)
|
|
156
|
+
s.on('error', rej)
|
|
157
|
+
})
|
|
158
|
+
return Buffer.concat(chunks)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone) {
|
|
162
|
+
const numParts = Math.max(1, Math.ceil(size / partSize))
|
|
163
|
+
const allParts = Array.from({ length: numParts }, (_, i) => i + 1)
|
|
164
|
+
const etags = new Map() // part_number -> etag
|
|
165
|
+
let uploadId = null
|
|
166
|
+
|
|
167
|
+
for (let i = 0; i < allParts.length; i += SIGN_WINDOW) {
|
|
168
|
+
const window = allParts.slice(i, i + SIGN_WINDOW)
|
|
169
|
+
const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: window }), 'sign upload parts')
|
|
170
|
+
uploadId = j.upload_id
|
|
171
|
+
for (const p of j.uploaded_parts) etags.set(p.part_number, p.etag) // already landed → skip
|
|
172
|
+
const urlByPart = new Map(j.urls.map((u) => [u.part_number, u.url]))
|
|
173
|
+
|
|
174
|
+
// upload the still-missing parts in this window, up to 4 concurrent.
|
|
175
|
+
// Presigned URLs expire (~30 min); on a rejection we re-sign that one part
|
|
176
|
+
// and retry once — cheap, since upload-parts is idempotent.
|
|
177
|
+
const missing = window.filter((n) => !etags.has(n))
|
|
178
|
+
const queue = [...missing]
|
|
179
|
+
const resign = async (partNo) => {
|
|
180
|
+
const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: [partNo] }), 're-sign upload part')
|
|
181
|
+
const fresh = j.urls.find((u) => u.part_number === partNo)?.url
|
|
182
|
+
if (!fresh) die(`upload part ${partNo}: could not re-sign (already finalized?)`)
|
|
183
|
+
urlByPart.set(partNo, fresh)
|
|
184
|
+
return fresh
|
|
185
|
+
}
|
|
186
|
+
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
187
|
+
for (;;) {
|
|
188
|
+
const partNo = queue.shift()
|
|
189
|
+
if (partNo === undefined) return
|
|
190
|
+
const start = (partNo - 1) * partSize
|
|
191
|
+
const end = Math.min(start + partSize, size) - 1
|
|
192
|
+
const bytes = await readPart(absPath, start, end)
|
|
193
|
+
let put = await fetch(urlByPart.get(partNo), { method: 'PUT', body: bytes }).catch(() => null)
|
|
194
|
+
if (!put || put.status === 403 || put.status === 401) {
|
|
195
|
+
put = await fetch(await resign(partNo), { method: 'PUT', body: bytes }).catch((e) => die(`upload part ${partNo}: ${e.message}`))
|
|
196
|
+
}
|
|
197
|
+
if (put.status !== 200) die(`upload part ${partNo}: HTTP ${put.status}`)
|
|
198
|
+
const etag = put.headers.get('etag')
|
|
199
|
+
if (!etag) die(`upload part ${partNo}: storage returned no ETag`)
|
|
200
|
+
etags.set(partNo, etag)
|
|
201
|
+
if (onPartDone) onPartDone()
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
await Promise.all(workers)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { upload_id: uploadId, parts: allParts.map((n) => ({ part_number: n, etag: etags.get(n) })) }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ——— commands ———
|
|
211
|
+
|
|
212
|
+
async function cmdPing() {
|
|
213
|
+
const j = expectOk(await api('GET', '/ping'), 'ping')
|
|
214
|
+
emit(j)
|
|
215
|
+
out(`✓ key "${j.key.name}" (${j.key.prefix}…) · bridge ${j.bridge.name} · ${j.projects.length} project(s)`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function cmdProjects() {
|
|
219
|
+
const j = expectOk(await api('GET', '/projects'), 'projects')
|
|
220
|
+
emit(j)
|
|
221
|
+
for (const line of table(null, j.projects.map((p) => [p.code, p.status, p.title]))) out(line)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function cmdDatasets() {
|
|
225
|
+
const me = expectOk(await api('GET', '/me'), 'auth')
|
|
226
|
+
const rows = []
|
|
227
|
+
for (const p of me.projects) {
|
|
228
|
+
const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
|
|
229
|
+
for (const d of r.datasets) {
|
|
230
|
+
rows.push({
|
|
231
|
+
project: p.code,
|
|
232
|
+
dataset: d.slug,
|
|
233
|
+
status: d.lifecycle_status,
|
|
234
|
+
quality_version: d.quality_check_version,
|
|
235
|
+
open_draft: d.open_draft_batch_id,
|
|
236
|
+
id: d.id,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
emit({ datasets: rows })
|
|
241
|
+
if (!JSON_MODE) {
|
|
242
|
+
const lines = table(
|
|
243
|
+
['PROJECT', 'DATASET', 'STATUS', 'YOUR MOVE?'],
|
|
244
|
+
rows.map((r) => [r.project, r.dataset, r.status, r.open_draft ? 'yes — open draft to finish' : '—']),
|
|
245
|
+
)
|
|
246
|
+
for (const line of lines) out(line)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The delivery contract, before you deliver anything. Answers "what is expected
|
|
251
|
+
// of me?" — which used to be discoverable only by submitting and being rejected.
|
|
252
|
+
async function cmdContract() {
|
|
253
|
+
const dsRef = opt('dataset')
|
|
254
|
+
if (!dsRef) die('Usage: myelin contract --dataset <slug|id>')
|
|
255
|
+
const dataset = await resolveDataset(dsRef)
|
|
256
|
+
const j = expectOk(
|
|
257
|
+
await api('GET', `/datasets/${dataset.id}/quality-checks`),
|
|
258
|
+
'quality-checks',
|
|
259
|
+
)
|
|
260
|
+
emit(j)
|
|
261
|
+
if (JSON_MODE) return
|
|
262
|
+
|
|
263
|
+
if (!j.version) {
|
|
264
|
+
out(`${dataset.name}: no quality contract published yet — nothing is enforced.`)
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const s = j.summary
|
|
269
|
+
out(`${dataset.name} — delivery contract v${j.version}`)
|
|
270
|
+
out(
|
|
271
|
+
`${s.total} checks · ${s.blocking} block validation · ` +
|
|
272
|
+
`${s.checkable_before_upload} checkable before upload` +
|
|
273
|
+
(s.needs_reviewer ? ` · ${s.needs_reviewer} reviewed by a person` : ''),
|
|
274
|
+
)
|
|
275
|
+
out('')
|
|
276
|
+
|
|
277
|
+
// Grouped by dimension so the contract reads as questions, not a flat list.
|
|
278
|
+
const byDim = new Map()
|
|
279
|
+
for (const c of j.checks) {
|
|
280
|
+
const key = c.dimension ?? 'review'
|
|
281
|
+
if (!byDim.has(key)) byDim.set(key, [])
|
|
282
|
+
byDim.get(key).push(c)
|
|
283
|
+
}
|
|
284
|
+
for (const d of s.dimensions) {
|
|
285
|
+
const items = byDim.get(d.key) ?? []
|
|
286
|
+
if (items.length === 0) continue
|
|
287
|
+
out(`${d.label.toUpperCase()} — ${d.question}`)
|
|
288
|
+
for (const c of items) {
|
|
289
|
+
const gate = c.severity === 'blocking' ? 'must' : 'should'
|
|
290
|
+
const when = c.runs_at === 'preflight' ? '' : ' (checked at submission)'
|
|
291
|
+
out(` ${gate === 'must' ? '!' : '·'} ${c.assertion ?? c.name}${when}`)
|
|
292
|
+
}
|
|
293
|
+
out('')
|
|
294
|
+
}
|
|
295
|
+
const manual = byDim.get('review') ?? []
|
|
296
|
+
if (manual.length > 0) {
|
|
297
|
+
out('REVIEWER JUDGEMENT — decided by a person, not the engine')
|
|
298
|
+
for (const c of manual) out(` · ${c.name}`)
|
|
299
|
+
out('')
|
|
300
|
+
}
|
|
301
|
+
out(`Run "myelin check <dir> --dataset ${dsRef}" to test ${s.checkable_before_upload} of these locally.`)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function cmdCheck() {
|
|
305
|
+
const dir = args[1]
|
|
306
|
+
const dsRef = opt('dataset')
|
|
307
|
+
if (!dir || !dsRef) die('Usage: myelin check <dir> --dataset <slug|id>')
|
|
308
|
+
const root = resolve(dir)
|
|
309
|
+
const files = walkDir(root)
|
|
310
|
+
if (files.length === 0) die(`No files under ${root}`)
|
|
311
|
+
const dataset = await resolveDataset(dsRef)
|
|
312
|
+
|
|
313
|
+
out(`Evaluating ${files.length} files (${fmtBytes(files.reduce((s, f) => s + f.size, 0))}) against quality checks v${dataset.quality_check_version ?? '—'}…`)
|
|
314
|
+
const j = expectOk(
|
|
315
|
+
await api('POST', `/datasets/${dataset.id}/preflight`, {
|
|
316
|
+
files: files.map((f) => ({ path: f.path, size_bytes: f.size })),
|
|
317
|
+
}),
|
|
318
|
+
'preflight',
|
|
319
|
+
)
|
|
320
|
+
emit(j)
|
|
321
|
+
if (!JSON_MODE) {
|
|
322
|
+
// One width across all three lists so the verdicts line up, and the hint
|
|
323
|
+
// lines can still be interleaved under the check they belong to.
|
|
324
|
+
const w = Math.max(
|
|
325
|
+
0,
|
|
326
|
+
...j.evaluated.map((c) => c.check_type.length),
|
|
327
|
+
...j.deferred.map((d) => d.check_type.length),
|
|
328
|
+
...j.manual.map((m) => m.name.length),
|
|
329
|
+
)
|
|
330
|
+
for (const c of j.evaluated) {
|
|
331
|
+
const mark = c.verdict === 'passed' ? '✓' : c.verdict === 'flagged' ? '⚠' : '✗'
|
|
332
|
+
out(`${mark} ${c.check_type.padEnd(w)} ${c.verdict}${c.severity === 'blocking' && c.verdict === 'failed' ? ' — BLOCKING' : ''}`)
|
|
333
|
+
if (c.verdict !== 'passed' && c.remediation) out(` hint: ${c.remediation}`)
|
|
334
|
+
}
|
|
335
|
+
for (const d of j.deferred) out(`… ${d.check_type.padEnd(w)} ${d.reason}`)
|
|
336
|
+
for (const m of j.manual) out(`○ ${m.name.padEnd(w)} ${m.reason}`)
|
|
337
|
+
}
|
|
338
|
+
if (j.blocking_failures > 0) {
|
|
339
|
+
out(`${j.blocking_failures} blocking issue(s). Fix before pushing to avoid a review round-trip.`)
|
|
340
|
+
process.exitCode = 2
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
out('All checks that run before upload pass.')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function cmdPush() {
|
|
347
|
+
const dir = args[1]
|
|
348
|
+
const dsRef = opt('dataset')
|
|
349
|
+
if (!dir || !dsRef) die('Usage: myelin push <dir> --dataset <slug|id> [--submit] [--replace]')
|
|
350
|
+
const root = resolve(dir)
|
|
351
|
+
const files = walkDir(root)
|
|
352
|
+
if (files.length === 0) die(`No files under ${root}`)
|
|
353
|
+
const dataset = await resolveDataset(dsRef)
|
|
354
|
+
|
|
355
|
+
// 1. create or resume the draft
|
|
356
|
+
const created = expectOk(await api('POST', `/datasets/${dataset.id}/batches`), 'create batch')
|
|
357
|
+
const batchId = created.batch_id
|
|
358
|
+
out(`Draft ${created.resumed ? 'resumed' : 'created'} (${batchId.slice(0, 8)}…).`)
|
|
359
|
+
|
|
360
|
+
// 2. declare in chunks of 500 — idempotent, so re-running skips what's done
|
|
361
|
+
const declared = []
|
|
362
|
+
let upload = null
|
|
363
|
+
for (let i = 0; i < files.length; i += 500) {
|
|
364
|
+
const slice = files.slice(i, i + 500)
|
|
365
|
+
const r = await api('POST', `/batches/${batchId}/files`, {
|
|
366
|
+
files: slice.map((f) => ({ path: f.path, size_bytes: f.size })),
|
|
367
|
+
replace: flag('replace'),
|
|
368
|
+
})
|
|
369
|
+
if (r.status !== 200 && r.status !== 409) expectOk(r, 'declare')
|
|
370
|
+
const conflicts = r.json.files.filter((f) => f.error)
|
|
371
|
+
if (conflicts.length > 0) {
|
|
372
|
+
for (const c of conflicts) out(`✗ ${c.path}: ${c.error}`)
|
|
373
|
+
die(`${conflicts.length} path conflict(s) — pass --replace to overwrite`, 2)
|
|
374
|
+
}
|
|
375
|
+
declared.push(...r.json.files)
|
|
376
|
+
upload = r.json.upload
|
|
377
|
+
}
|
|
378
|
+
const partSize = upload?.part_size ?? 64 * 1024 * 1024
|
|
379
|
+
|
|
380
|
+
// 3. upload the delta (anything not already 'uploaded') via S3 multipart,
|
|
381
|
+
// 2 files in parallel (each file uploads its parts 4-wide internally)
|
|
382
|
+
const byPath = new Map(files.map((f) => [f.path, f]))
|
|
383
|
+
const pending = declared.filter((d) => d.upload_state !== 'uploaded')
|
|
384
|
+
out(`Uploading ${pending.length}/${declared.length} files (${fmtBytes(pending.reduce((s, d) => s + (byPath.get(d.path)?.size ?? 0), 0))}) · resumable`)
|
|
385
|
+
let done = 0
|
|
386
|
+
const queue = [...pending]
|
|
387
|
+
const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
|
|
388
|
+
for (;;) {
|
|
389
|
+
const d = queue.shift()
|
|
390
|
+
if (!d) return
|
|
391
|
+
const local = byPath.get(d.path)
|
|
392
|
+
const { upload_id, parts } = await uploadFileMultipart(local.abs, local.size, d.file_id, partSize)
|
|
393
|
+
const c = await api('POST', `/files/${d.file_id}/confirm`, { upload_id, parts })
|
|
394
|
+
if (c.status !== 200) die(`confirm ${d.path}: [${c.json?.code}] ${c.json?.detail ?? c.status}`)
|
|
395
|
+
done++
|
|
396
|
+
out(` ✓ ${d.path} (${done}/${pending.length})`)
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
await Promise.all(workers)
|
|
400
|
+
out('All files confirmed.')
|
|
401
|
+
|
|
402
|
+
// 4. optional submit
|
|
403
|
+
if (flag('submit')) {
|
|
404
|
+
const r = await api('POST', `/batches/${batchId}/submit`)
|
|
405
|
+
if (r.status !== 200) {
|
|
406
|
+
const code = r.json?.code ?? 'error'
|
|
407
|
+
die(`submit: [${code}] ${r.json?.detail ?? r.status}`, code === 'submit_blocked' ? 2 : 1)
|
|
408
|
+
}
|
|
409
|
+
const a = r.json.auto_checks
|
|
410
|
+
emit(r.json)
|
|
411
|
+
out(`Submitting… auto-checks: ${a.passed} passed, ${a.flagged} flagged, ${a.failed} failed.`)
|
|
412
|
+
out(`✓ Batch submitted for review. Track: myelin status ${batchId} --watch`)
|
|
413
|
+
} else {
|
|
414
|
+
emit({ batch_id: batchId, files: declared.length, submitted: false })
|
|
415
|
+
out(`Draft ready (not submitted). Submit with: myelin push ${dir} --dataset ${dsRef} --submit`)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function cmdStatus() {
|
|
420
|
+
const ref = args[1]
|
|
421
|
+
if (!ref) die('Usage: myelin status <batch-id|display-name> [--watch]')
|
|
422
|
+
|
|
423
|
+
const findBatch = async () => {
|
|
424
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
|
|
425
|
+
const r = await api('GET', `/batches/${ref}`)
|
|
426
|
+
if (r.status === 200) return r.json.batch
|
|
427
|
+
die(`Batch ${ref} not found in this key's scope`)
|
|
428
|
+
}
|
|
429
|
+
const list = expectOk(await api('GET', '/batches?limit=100'), 'batches')
|
|
430
|
+
const hit = list.batches.find((b) => b.display_name === ref)
|
|
431
|
+
if (!hit) die(`No batch named "${ref}" in this key's scope`)
|
|
432
|
+
return expectOk(await api('GET', `/batches/${hit.id}`), 'batch').batch
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const print = async (batch) => {
|
|
436
|
+
emit({ batch })
|
|
437
|
+
out(`${batch.display_name}: ${batch.status} — ${batch.court === 'partner' ? 'your move.' : batch.court === 'reviewer' ? "reviewer's move." : batch.court === 'system' ? 'transferring…' : 'done.'}`)
|
|
438
|
+
if (batch.status === 'changes_requested') {
|
|
439
|
+
const f = expectOk(await api('GET', `/batches/${batch.id}/findings`), 'findings')
|
|
440
|
+
if (f.request_changes_comment) out(` reviewer: "${f.request_changes_comment}"`)
|
|
441
|
+
for (const fr of f.file_reviews.filter((x) => x.verdict === 'failed')) {
|
|
442
|
+
out(` ✗ ${fr.path} (${fr.reviewer ?? 'reviewer'}: ${fr.comment ?? 'failed'})`)
|
|
443
|
+
}
|
|
444
|
+
for (const qf of f.quality_findings.filter((x) => x.verdict === 'failed')) {
|
|
445
|
+
out(` ✗ ${qf.name ?? qf.rule_key}${qf.hint ? `: ${qf.hint}` : ''}`)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return batch
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let batch = await print(await findBatch())
|
|
452
|
+
if (flag('watch')) {
|
|
453
|
+
const terminal = ['transferred', 'rejected', 'changes_requested', 'transfer_failed']
|
|
454
|
+
while (!terminal.includes(batch.status)) {
|
|
455
|
+
await new Promise((r) => setTimeout(r, 20_000))
|
|
456
|
+
const next = await findBatch()
|
|
457
|
+
if (next.status !== batch.status) batch = await print(next)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function cmdSandbox() {
|
|
463
|
+
const file = args[1]
|
|
464
|
+
if (!file) die('Usage: myelin sandbox <file>')
|
|
465
|
+
const abs = resolve(file)
|
|
466
|
+
const size = statSync(abs).size
|
|
467
|
+
if (size > 4 * 1024 * 1024) die('Sandbox files should stay under ~4 MB — this verifies connectivity, not throughput.')
|
|
468
|
+
|
|
469
|
+
const me = expectOk(await api('GET', '/me'), 'auth')
|
|
470
|
+
const form = new FormData()
|
|
471
|
+
form.set('file', new File([readFileSync(abs)], basename(abs)))
|
|
472
|
+
const r = await api('POST', `/bridges/${me.bridge.id}/sandbox`, form, true)
|
|
473
|
+
const j = expectOk(r, 'sandbox')
|
|
474
|
+
emit(j)
|
|
475
|
+
out(`✓ Sandbox upload succeeded — the partner-side test transfer checklist item is satisfied for bridge "${me.bridge.name}".`)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function cmdSampleDepth() {
|
|
479
|
+
const dsRef = opt('dataset')
|
|
480
|
+
const raw = args[1]
|
|
481
|
+
if (!dsRef || raw === undefined) {
|
|
482
|
+
die('Usage: myelin sample-depth <0-5> --dataset <slug|id>')
|
|
483
|
+
}
|
|
484
|
+
const depth = Number(raw)
|
|
485
|
+
if (!Number.isInteger(depth) || depth < 0 || depth > 5) {
|
|
486
|
+
die('sample-depth must be an integer between 0 and 5')
|
|
487
|
+
}
|
|
488
|
+
const dataset = await resolveDataset(dsRef)
|
|
489
|
+
const j = expectOk(
|
|
490
|
+
await api('POST', `/datasets/${dataset.id}/sample-depth`, { sample_depth: depth }),
|
|
491
|
+
'sample-depth',
|
|
492
|
+
)
|
|
493
|
+
emit(j)
|
|
494
|
+
if (!JSON_MODE) {
|
|
495
|
+
out(
|
|
496
|
+
j.changed
|
|
497
|
+
? `Sample depth for ${dataset.name} set to ${j.sample_depth}.`
|
|
498
|
+
: `Sample depth for ${dataset.name} already ${j.sample_depth} — nothing to change.`,
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function cmdHelp() {
|
|
504
|
+
console.log(`myelin — Partner Ingestion CLI
|
|
505
|
+
|
|
506
|
+
Setup:
|
|
507
|
+
export MYELIN_API_KEY=myl_live_… (create in Bridge → API)
|
|
508
|
+
export MYELIN_API_URL=… (optional; default https://myelinbridge.com/api/v1)
|
|
509
|
+
export MYELIN_API_HEADER="Name: value" (optional; extra headers, one per line —
|
|
510
|
+
for a gateway that authenticates ahead of Myelin)
|
|
511
|
+
|
|
512
|
+
Commands:
|
|
513
|
+
ping verify the key; show bridge + projects
|
|
514
|
+
projects list scoped projects
|
|
515
|
+
datasets list datasets with "your move" hints
|
|
516
|
+
sample-depth <0-5> --dataset <slug|id> declare the folder depth a sample sits at (locks after 1st submit)
|
|
517
|
+
contract --dataset <slug|id> what this dataset expects of your delivery
|
|
518
|
+
check <dir> --dataset <slug|id> preflight local files against quality rules (no upload)
|
|
519
|
+
push <dir> --dataset <slug|id> create/resume a delivery and upload (resumable; re-run to resume)
|
|
520
|
+
[--submit] [--replace]
|
|
521
|
+
status <batch|name> [--watch] review status + fix-loop findings
|
|
522
|
+
sandbox <file> partner-side test transfer (bridge activation)
|
|
523
|
+
|
|
524
|
+
Client-side commands (need a CLIENT key — Bridge → API → "Your read keys"):
|
|
525
|
+
deliveries [--project <id>] what landed in your destination bucket
|
|
526
|
+
[--dataset <id>] [--since <iso>] [--limit <n>]
|
|
527
|
+
resolve <path|prefix|id> what a path in your bucket actually is —
|
|
528
|
+
project, dataset, batch, file
|
|
529
|
+
|
|
530
|
+
Every command accepts --json. Exit codes: 0 ok · 1 error · 2 blocked.`)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ——— client-side commands (client keys only) ———
|
|
534
|
+
//
|
|
535
|
+
// These need a CLIENT key, minted by the client bridge owner in Bridge → API →
|
|
536
|
+
// "Your read keys". A partner key gets 403 wrong_key_side, and the message says
|
|
537
|
+
// so — the two sides deliberately do not silently degrade into each other.
|
|
538
|
+
|
|
539
|
+
async function cmdDeliveries() {
|
|
540
|
+
const qs = new URLSearchParams()
|
|
541
|
+
const map = { project: 'project_id', dataset: 'dataset_id', since: 'since', limit: 'limit' }
|
|
542
|
+
for (const [flagName, param] of Object.entries(map)) {
|
|
543
|
+
const v = opt(flagName)
|
|
544
|
+
if (v) qs.set(param, v)
|
|
545
|
+
}
|
|
546
|
+
const query = qs.toString()
|
|
547
|
+
const j = expectOk(await api('GET', `/deliveries${query ? `?${query}` : ''}`), 'deliveries')
|
|
548
|
+
emit(j)
|
|
549
|
+
if (j.deliveries.length === 0) {
|
|
550
|
+
out('No deliveries yet in this key’s scope.')
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
const rows = j.deliveries.map((d) => [
|
|
554
|
+
d.project.code ?? d.project.id.slice(0, 8),
|
|
555
|
+
d.dataset.slug ?? d.dataset.id.slice(0, 8),
|
|
556
|
+
d.batch_name ?? '—',
|
|
557
|
+
String(d.file_count),
|
|
558
|
+
(d.transferred_at ?? '').slice(0, 10),
|
|
559
|
+
d.destination_root ?? '—',
|
|
560
|
+
])
|
|
561
|
+
for (const line of table(['PROJECT', 'DATASET', 'BATCH', 'FILES', 'DELIVERED', 'LOCATION'], rows)) out(line)
|
|
562
|
+
if (j.next_cursor) out(`\n… more — re-run with --since ${j.next_cursor}`)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function cmdResolve() {
|
|
566
|
+
const path = args[1]
|
|
567
|
+
if (!path || path.startsWith('--')) die('Usage: myelin resolve <path|prefix|id>')
|
|
568
|
+
const j = expectOk(await api('GET', `/deliveries/resolve?path=${encodeURIComponent(path)}`), 'resolve')
|
|
569
|
+
emit(j)
|
|
570
|
+
out(`${j.resolved} · ${j.query}`)
|
|
571
|
+
if (j.project) out(` project ${j.project.code} — ${j.project.title}`)
|
|
572
|
+
if (j.dataset) out(` dataset ${j.dataset.name}${j.dataset.label ? ` (${j.dataset.label})` : ''}`)
|
|
573
|
+
if (j.delivery) {
|
|
574
|
+
out(` batch ${j.delivery.batch_name ?? j.delivery.batch_id} · #${j.delivery.sequence_number ?? '?'}`)
|
|
575
|
+
out(` validated ${j.delivery.validated_at ?? '—'} · delivered ${j.delivery.transferred_at ?? '—'}`)
|
|
576
|
+
if (j.delivery.manifest_path) out(` manifest ${j.delivery.manifest_path}`)
|
|
577
|
+
}
|
|
578
|
+
if (j.file) {
|
|
579
|
+
out(
|
|
580
|
+
` file ${j.file.filename} · ${j.file.size_bytes} bytes` +
|
|
581
|
+
(j.file.declared_checksum
|
|
582
|
+
? ` · ${j.file.declared_checksum_algorithm}:${j.file.declared_checksum}`
|
|
583
|
+
: ''),
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ——— main ———
|
|
589
|
+
if (!command || command === 'help' || command === '--help') {
|
|
590
|
+
cmdHelp()
|
|
591
|
+
process.exit(0)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const commands = {
|
|
595
|
+
ping: cmdPing,
|
|
596
|
+
projects: cmdProjects,
|
|
597
|
+
datasets: cmdDatasets,
|
|
598
|
+
'sample-depth': cmdSampleDepth,
|
|
599
|
+
contract: cmdContract,
|
|
600
|
+
check: cmdCheck,
|
|
601
|
+
push: cmdPush,
|
|
602
|
+
status: cmdStatus,
|
|
603
|
+
sandbox: cmdSandbox,
|
|
604
|
+
deliveries: cmdDeliveries,
|
|
605
|
+
resolve: cmdResolve,
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
if (!KEY) die('Set MYELIN_API_KEY (create a key in Bridge → API).')
|
|
609
|
+
const fn = commands[command]
|
|
610
|
+
if (!fn) die(`Unknown command "${command}" — run: myelin help`)
|
|
611
|
+
await fn()
|
|
612
|
+
} catch (err) {
|
|
613
|
+
// die() already printed and set the code; anything else is a real bug and
|
|
614
|
+
// deserves its stack.
|
|
615
|
+
if (!(err instanceof CliExit)) throw err
|
|
616
|
+
}
|
package/package.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@myelinbridge/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Myelin Partner Ingestion CLI — push R&D data deliveries from a pipeline: preflight against the client's quality rules, resumable upload, submit, track review outcomes.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"myelin": "
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"bin/",
|
|
11
|
-
"README.md"
|
|
12
|
-
],
|
|
13
|
-
"engines": {
|
|
14
|
-
"node": ">=20"
|
|
15
|
-
},
|
|
16
|
-
"keywords": [
|
|
17
|
-
"myelin",
|
|
18
|
-
"pharma",
|
|
19
|
-
"ingestion",
|
|
20
|
-
"s3"
|
|
21
|
-
],
|
|
22
|
-
"license": "UNLICENSED"
|
|
23
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@myelinbridge/cli",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Myelin Partner Ingestion CLI — push R&D data deliveries from a pipeline: preflight against the client's quality rules, resumable upload, submit, track review outcomes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"myelin": "bin/myelin.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"myelin",
|
|
18
|
+
"pharma",
|
|
19
|
+
"ingestion",
|
|
20
|
+
"s3"
|
|
21
|
+
],
|
|
22
|
+
"license": "UNLICENSED"
|
|
23
|
+
}
|