@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.
Files changed (3) hide show
  1. package/README.md +23 -2
  2. package/bin/myelin.js +154 -23
  3. 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), interrupted uploads resume mid-file over TUS.
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
- function tusUpload(absPath, size, storagePath, upload) {
101
- return new Promise((resolveP, rejectP) => {
102
- new tus.Upload(createReadStream(absPath), {
103
- endpoint: upload.endpoint,
104
- chunkSize: upload.chunk_size_bytes,
105
- uploadSize: size,
106
- retryDelays: [0, 1000, 5000],
107
- headers: { authorization: `Bearer ${upload.token}`, apikey: upload.apikey, ...upload.headers },
108
- metadata: {
109
- bucketName: upload.bucket,
110
- objectName: storagePath,
111
- contentType: 'application/octet-stream',
112
- cacheControl: '3600',
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'), 4 in parallel
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(4, queue.length) }, async () => {
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 tusUpload(local.abs, local.size, d.storage_path, upload)
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.1.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": "./bin/myelin.js"
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
- "tus"
20
+ "s3"
24
21
  ],
25
22
  "license": "UNLICENSED"
26
23
  }