@myelinbridge/cli 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -2
- package/bin/myelin.js +154 -23
- package/package.json +3 -6
package/README.md
CHANGED
|
@@ -11,12 +11,33 @@ export MYELIN_API_KEY=myl_live_… # created by your bridge owner in Bridge
|
|
|
11
11
|
|
|
12
12
|
npx @myelinbridge/cli ping # verifies auth, prints your projects
|
|
13
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
|
|
14
16
|
npx @myelinbridge/cli check ./run_042 --dataset onco1-wes # validate BEFORE uploading a byte
|
|
15
17
|
npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
16
18
|
```
|
|
17
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
|
+
|
|
18
29
|
- **Resume = re-run.** `push` is idempotent: already-uploaded files are skipped
|
|
19
|
-
(path + size),
|
|
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
|
+
|
|
20
41
|
- **`check` costs nothing.** It evaluates your local manifest against the dataset's
|
|
21
42
|
quality checks server-side — same engine, same verdicts as submit — without
|
|
22
43
|
uploading. Exit code 2 means a blocking rule fails.
|
|
@@ -28,7 +49,7 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
|
28
49
|
## Machine mode
|
|
29
50
|
|
|
30
51
|
Every command takes `--json`. Exit codes: `0` ok · `1` error · `2` blocked
|
|
31
|
-
(blocking preflight failure, locked delivery, blocked submit).
|
|
52
|
+
(blocking preflight failure, locked delivery, blocked submit, locked sample depth).
|
|
32
53
|
|
|
33
54
|
## Webhooks instead of polling
|
|
34
55
|
|
package/bin/myelin.js
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
|
|
12
12
|
import { resolve, join, relative, sep, basename } from 'node:path'
|
|
13
13
|
import process from 'node:process'
|
|
14
|
-
import * as tus from 'tus-js-client'
|
|
15
14
|
|
|
16
15
|
const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
|
|
17
16
|
const KEY = process.env.MYELIN_API_KEY
|
|
@@ -55,7 +54,7 @@ function expectOk(r, context) {
|
|
|
55
54
|
if (r.status >= 200 && r.status < 300) return r.json
|
|
56
55
|
const detail = r.json?.detail ?? `HTTP ${r.status}`
|
|
57
56
|
const code = r.json?.code ?? 'error'
|
|
58
|
-
const blocked = ['delivery_locked', 'submit_blocked', 'dataset_not_active', 'api_disabled', 'bridge_paused'].includes(code)
|
|
57
|
+
const blocked = ['delivery_locked', 'submit_blocked', 'dataset_not_active', 'api_disabled', 'bridge_paused', 'sample_depth_locked'].includes(code)
|
|
59
58
|
die(`${context}: [${code}] ${detail}`, blocked ? 2 : 1)
|
|
60
59
|
}
|
|
61
60
|
|
|
@@ -97,24 +96,71 @@ function fmtBytes(n) {
|
|
|
97
96
|
return n + ' B'
|
|
98
97
|
}
|
|
99
98
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
onError: rejectP,
|
|
115
|
-
onSuccess: () => resolveP(),
|
|
116
|
-
}).start()
|
|
99
|
+
// Upload a file via S3 presigned multipart (SEC-TOKEN-01: no bearer token ever
|
|
100
|
+
// reaches the client — declare tells us the part size, and each part gets a
|
|
101
|
+
// short-lived presigned PUT URL scoped to exactly that one part of one object).
|
|
102
|
+
// Resumable: the sign step reports parts that already landed, so a re-run skips
|
|
103
|
+
// them. Returns { upload_id, parts: [{part_number, etag}] } for confirm.
|
|
104
|
+
const SIGN_WINDOW = 1000 // ≤ the server's max_parts_per_sign
|
|
105
|
+
|
|
106
|
+
async function readPart(absPath, start, end) {
|
|
107
|
+
const chunks = []
|
|
108
|
+
await new Promise((res, rej) => {
|
|
109
|
+
const s = createReadStream(absPath, { start, end }) // end inclusive
|
|
110
|
+
s.on('data', (c) => chunks.push(c))
|
|
111
|
+
s.on('end', res)
|
|
112
|
+
s.on('error', rej)
|
|
117
113
|
})
|
|
114
|
+
return Buffer.concat(chunks)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone) {
|
|
118
|
+
const numParts = Math.max(1, Math.ceil(size / partSize))
|
|
119
|
+
const allParts = Array.from({ length: numParts }, (_, i) => i + 1)
|
|
120
|
+
const etags = new Map() // part_number -> etag
|
|
121
|
+
let uploadId = null
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < allParts.length; i += SIGN_WINDOW) {
|
|
124
|
+
const window = allParts.slice(i, i + SIGN_WINDOW)
|
|
125
|
+
const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: window }), 'sign upload parts')
|
|
126
|
+
uploadId = j.upload_id
|
|
127
|
+
for (const p of j.uploaded_parts) etags.set(p.part_number, p.etag) // already landed → skip
|
|
128
|
+
const urlByPart = new Map(j.urls.map((u) => [u.part_number, u.url]))
|
|
129
|
+
|
|
130
|
+
// upload the still-missing parts in this window, up to 4 concurrent.
|
|
131
|
+
// Presigned URLs expire (~30 min); on a rejection we re-sign that one part
|
|
132
|
+
// and retry once — cheap, since upload-parts is idempotent.
|
|
133
|
+
const missing = window.filter((n) => !etags.has(n))
|
|
134
|
+
const queue = [...missing]
|
|
135
|
+
const resign = async (partNo) => {
|
|
136
|
+
const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: [partNo] }), 're-sign upload part')
|
|
137
|
+
const fresh = j.urls.find((u) => u.part_number === partNo)?.url
|
|
138
|
+
if (!fresh) die(`upload part ${partNo}: could not re-sign (already finalized?)`)
|
|
139
|
+
urlByPart.set(partNo, fresh)
|
|
140
|
+
return fresh
|
|
141
|
+
}
|
|
142
|
+
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
143
|
+
for (;;) {
|
|
144
|
+
const partNo = queue.shift()
|
|
145
|
+
if (partNo === undefined) return
|
|
146
|
+
const start = (partNo - 1) * partSize
|
|
147
|
+
const end = Math.min(start + partSize, size) - 1
|
|
148
|
+
const bytes = await readPart(absPath, start, end)
|
|
149
|
+
let put = await fetch(urlByPart.get(partNo), { method: 'PUT', body: bytes }).catch(() => null)
|
|
150
|
+
if (!put || put.status === 403 || put.status === 401) {
|
|
151
|
+
put = await fetch(await resign(partNo), { method: 'PUT', body: bytes }).catch((e) => die(`upload part ${partNo}: ${e.message}`))
|
|
152
|
+
}
|
|
153
|
+
if (put.status !== 200) die(`upload part ${partNo}: HTTP ${put.status}`)
|
|
154
|
+
const etag = put.headers.get('etag')
|
|
155
|
+
if (!etag) die(`upload part ${partNo}: storage returned no ETag`)
|
|
156
|
+
etags.set(partNo, etag)
|
|
157
|
+
if (onPartDone) onPartDone()
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
await Promise.all(workers)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { upload_id: uploadId, parts: allParts.map((n) => ({ part_number: n, etag: etags.get(n) })) }
|
|
118
164
|
}
|
|
119
165
|
|
|
120
166
|
// ——— commands ———
|
|
@@ -161,6 +207,60 @@ async function cmdDatasets() {
|
|
|
161
207
|
}
|
|
162
208
|
}
|
|
163
209
|
|
|
210
|
+
// The delivery contract, before you deliver anything. Answers "what is expected
|
|
211
|
+
// of me?" — which used to be discoverable only by submitting and being rejected.
|
|
212
|
+
async function cmdContract() {
|
|
213
|
+
const dsRef = opt('dataset')
|
|
214
|
+
if (!dsRef) die('Usage: myelin contract --dataset <slug|id>')
|
|
215
|
+
const dataset = await resolveDataset(dsRef)
|
|
216
|
+
const j = expectOk(
|
|
217
|
+
await api('GET', `/datasets/${dataset.id}/quality-checks`),
|
|
218
|
+
'quality-checks',
|
|
219
|
+
)
|
|
220
|
+
emit(j)
|
|
221
|
+
if (JSON_MODE) return
|
|
222
|
+
|
|
223
|
+
if (!j.version) {
|
|
224
|
+
out(`${dataset.name}: no quality contract published yet — nothing is enforced.`)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const s = j.summary
|
|
229
|
+
out(`${dataset.name} — delivery contract v${j.version}`)
|
|
230
|
+
out(
|
|
231
|
+
`${s.total} checks · ${s.blocking} block validation · ` +
|
|
232
|
+
`${s.checkable_before_upload} checkable before upload` +
|
|
233
|
+
(s.needs_reviewer ? ` · ${s.needs_reviewer} reviewed by a person` : ''),
|
|
234
|
+
)
|
|
235
|
+
out('')
|
|
236
|
+
|
|
237
|
+
// Grouped by dimension so the contract reads as questions, not a flat list.
|
|
238
|
+
const byDim = new Map()
|
|
239
|
+
for (const c of j.checks) {
|
|
240
|
+
const key = c.dimension ?? 'review'
|
|
241
|
+
if (!byDim.has(key)) byDim.set(key, [])
|
|
242
|
+
byDim.get(key).push(c)
|
|
243
|
+
}
|
|
244
|
+
for (const d of s.dimensions) {
|
|
245
|
+
const items = byDim.get(d.key) ?? []
|
|
246
|
+
if (items.length === 0) continue
|
|
247
|
+
out(`${d.label.toUpperCase()} — ${d.question}`)
|
|
248
|
+
for (const c of items) {
|
|
249
|
+
const gate = c.severity === 'blocking' ? 'must' : 'should'
|
|
250
|
+
const when = c.runs_at === 'preflight' ? '' : ' (checked at submission)'
|
|
251
|
+
out(` ${gate === 'must' ? '!' : '·'} ${c.assertion ?? c.name}${when}`)
|
|
252
|
+
}
|
|
253
|
+
out('')
|
|
254
|
+
}
|
|
255
|
+
const manual = byDim.get('review') ?? []
|
|
256
|
+
if (manual.length > 0) {
|
|
257
|
+
out('REVIEWER JUDGEMENT — decided by a person, not the engine')
|
|
258
|
+
for (const c of manual) out(` · ${c.name}`)
|
|
259
|
+
out('')
|
|
260
|
+
}
|
|
261
|
+
out(`Run "myelin check <dir> --dataset ${dsRef}" to test ${s.checkable_before_upload} of these locally.`)
|
|
262
|
+
}
|
|
263
|
+
|
|
164
264
|
async function cmdCheck() {
|
|
165
265
|
const dir = args[1]
|
|
166
266
|
const dsRef = opt('dataset')
|
|
@@ -226,20 +326,22 @@ async function cmdPush() {
|
|
|
226
326
|
declared.push(...r.json.files)
|
|
227
327
|
upload = r.json.upload
|
|
228
328
|
}
|
|
329
|
+
const partSize = upload?.part_size ?? 64 * 1024 * 1024
|
|
229
330
|
|
|
230
|
-
// 3. upload the delta (anything not already 'uploaded')
|
|
331
|
+
// 3. upload the delta (anything not already 'uploaded') via S3 multipart,
|
|
332
|
+
// 2 files in parallel (each file uploads its parts 4-wide internally)
|
|
231
333
|
const byPath = new Map(files.map((f) => [f.path, f]))
|
|
232
334
|
const pending = declared.filter((d) => d.upload_state !== 'uploaded')
|
|
233
335
|
out(`Uploading ${pending.length}/${declared.length} files (${fmtBytes(pending.reduce((s, d) => s + (byPath.get(d.path)?.size ?? 0), 0))}) · resumable`)
|
|
234
336
|
let done = 0
|
|
235
337
|
const queue = [...pending]
|
|
236
|
-
const workers = Array.from({ length: Math.min(
|
|
338
|
+
const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
|
|
237
339
|
for (;;) {
|
|
238
340
|
const d = queue.shift()
|
|
239
341
|
if (!d) return
|
|
240
342
|
const local = byPath.get(d.path)
|
|
241
|
-
await
|
|
242
|
-
const c = await api('POST', `/files/${d.file_id}/confirm
|
|
343
|
+
const { upload_id, parts } = await uploadFileMultipart(local.abs, local.size, d.file_id, partSize)
|
|
344
|
+
const c = await api('POST', `/files/${d.file_id}/confirm`, { upload_id, parts })
|
|
243
345
|
if (c.status !== 200) die(`confirm ${d.path}: [${c.json?.code}] ${c.json?.detail ?? c.status}`)
|
|
244
346
|
done++
|
|
245
347
|
out(` ✓ ${d.path} (${done}/${pending.length})`)
|
|
@@ -324,6 +426,31 @@ async function cmdSandbox() {
|
|
|
324
426
|
out(`✓ Sandbox upload succeeded — the partner-side test transfer checklist item is satisfied for bridge "${me.bridge.name}".`)
|
|
325
427
|
}
|
|
326
428
|
|
|
429
|
+
async function cmdSampleDepth() {
|
|
430
|
+
const dsRef = opt('dataset')
|
|
431
|
+
const raw = args[1]
|
|
432
|
+
if (!dsRef || raw === undefined) {
|
|
433
|
+
die('Usage: myelin sample-depth <0-5> --dataset <slug|id>')
|
|
434
|
+
}
|
|
435
|
+
const depth = Number(raw)
|
|
436
|
+
if (!Number.isInteger(depth) || depth < 0 || depth > 5) {
|
|
437
|
+
die('sample-depth must be an integer between 0 and 5')
|
|
438
|
+
}
|
|
439
|
+
const dataset = await resolveDataset(dsRef)
|
|
440
|
+
const j = expectOk(
|
|
441
|
+
await api('POST', `/datasets/${dataset.id}/sample-depth`, { sample_depth: depth }),
|
|
442
|
+
'sample-depth',
|
|
443
|
+
)
|
|
444
|
+
emit(j)
|
|
445
|
+
if (!JSON_MODE) {
|
|
446
|
+
out(
|
|
447
|
+
j.changed
|
|
448
|
+
? `Sample depth for ${dataset.name} set to ${j.sample_depth}.`
|
|
449
|
+
: `Sample depth for ${dataset.name} already ${j.sample_depth} — nothing to change.`,
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
327
454
|
function cmdHelp() {
|
|
328
455
|
console.log(`myelin — Partner Ingestion CLI
|
|
329
456
|
|
|
@@ -335,6 +462,8 @@ Commands:
|
|
|
335
462
|
ping verify the key; show bridge + projects
|
|
336
463
|
projects list scoped projects
|
|
337
464
|
datasets list datasets with "your move" hints
|
|
465
|
+
sample-depth <0-5> --dataset <slug|id> declare the folder depth a sample sits at (locks after 1st submit)
|
|
466
|
+
contract --dataset <slug|id> what this dataset expects of your delivery
|
|
338
467
|
check <dir> --dataset <slug|id> preflight local files against quality rules (no upload)
|
|
339
468
|
push <dir> --dataset <slug|id> create/resume a delivery and upload (resumable; re-run to resume)
|
|
340
469
|
[--submit] [--replace]
|
|
@@ -355,6 +484,8 @@ const commands = {
|
|
|
355
484
|
ping: cmdPing,
|
|
356
485
|
projects: cmdProjects,
|
|
357
486
|
datasets: cmdDatasets,
|
|
487
|
+
'sample-depth': cmdSampleDepth,
|
|
488
|
+
contract: cmdContract,
|
|
358
489
|
check: cmdCheck,
|
|
359
490
|
push: cmdPush,
|
|
360
491
|
status: cmdStatus,
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myelinbridge/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"myelin": "
|
|
7
|
+
"myelin": "bin/myelin.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
@@ -13,14 +13,11 @@
|
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=20"
|
|
15
15
|
},
|
|
16
|
-
"dependencies": {
|
|
17
|
-
"tus-js-client": "^4.1.0"
|
|
18
|
-
},
|
|
19
16
|
"keywords": [
|
|
20
17
|
"myelin",
|
|
21
18
|
"pharma",
|
|
22
19
|
"ingestion",
|
|
23
|
-
"
|
|
20
|
+
"s3"
|
|
24
21
|
],
|
|
25
22
|
"license": "UNLICENSED"
|
|
26
23
|
}
|