@myelinbridge/cli 0.6.0 → 0.7.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 CHANGED
@@ -31,6 +31,10 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
31
31
  too (S3 multipart). Uploads go direct to storage over short-lived presigned
32
32
  URLs — no credential is stored on your machine, and revoking the API key cuts
33
33
  off signing immediately.
34
+ - **Every file is fingerprinted.** Since 0.7.0, `push` computes an MD5 of each
35
+ file while it uploads and records it with the delivery. If the client's
36
+ quality contract includes a checksum-manifest check, your delivery verifies
37
+ against your own `md5_manifest.csv` instead of reading as "unverifiable".
34
38
  - **Read the contract before you build the delivery.** `contract` prints what the
35
39
  client expects — every check as one plain sentence, grouped by what it answers
36
40
  (completeness, structure, validity, consistency, integrity, privacy), and marked
@@ -53,9 +57,10 @@ Two kinds of key exist, and they are not interchangeable. Everything above needs
53
57
  question your own systems ask once the data has landed: *the bucket is full of UUIDs —
54
58
  what is this?*
55
59
 
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"**.
60
+ Where they live follows from what they are. A **partner key belongs to one bridge**, because a
61
+ partner does. A **client key belongs to your organisation**: one key, every partner, one answer.
62
+ Your organisation admin creates it in **Organisation → API keys**. To narrow one to a single
63
+ partner's data, scope it to that partner's projects.
59
64
 
60
65
  ```bash
61
66
  export MYELIN_API_KEY=myl_live_…
package/bin/myelin.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
12
12
  import { resolve, join, relative, sep, basename } from 'node:path'
13
+ import { createHash } from 'node:crypto'
13
14
  import process from 'node:process'
14
15
 
15
16
  const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
@@ -158,6 +159,27 @@ async function readPart(absPath, start, end) {
158
159
  return Buffer.concat(chunks)
159
160
  }
160
161
 
162
+ // Whole-file MD5, lowercase hex — the exact format md5sum-style manifests use
163
+ // and the only one the platform's manifest_checksums_match check can verify.
164
+ // A separate sequential read (parts upload 4-wide, out of order — a streaming
165
+ // hash cannot ride along), overlapped with the upload by the caller. Returns
166
+ // null on read failure: a hash miss must degrade the delivery to
167
+ // "unverifiable", never kill the push.
168
+ async function md5File(absPath) {
169
+ try {
170
+ const hash = createHash('md5')
171
+ await new Promise((res, rej) => {
172
+ const s = createReadStream(absPath)
173
+ s.on('data', (c) => hash.update(c))
174
+ s.on('end', res)
175
+ s.on('error', rej)
176
+ })
177
+ return hash.digest('hex')
178
+ } catch {
179
+ return null
180
+ }
181
+ }
182
+
161
183
  async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone) {
162
184
  const numParts = Math.max(1, Math.ceil(size / partSize))
163
185
  const allParts = Array.from({ length: numParts }, (_, i) => i + 1)
@@ -389,8 +411,19 @@ async function cmdPush() {
389
411
  const d = queue.shift()
390
412
  if (!d) return
391
413
  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 })
414
+ // Hash and upload overlap two independent reads of the same file. The
415
+ // checksum rides on confirm so files.checksum is populated for CLI
416
+ // pushes (0.7.0): without it every CLI delivery reads as "unverifiable"
417
+ // to the manifest_checksums_match quality check.
418
+ const [{ upload_id, parts }, checksum] = await Promise.all([
419
+ uploadFileMultipart(local.abs, local.size, d.file_id, partSize),
420
+ md5File(local.abs),
421
+ ])
422
+ const c = await api('POST', `/files/${d.file_id}/confirm`, {
423
+ upload_id,
424
+ parts,
425
+ ...(checksum ? { checksum, checksum_algorithm: 'md5' } : {}),
426
+ })
394
427
  if (c.status !== 200) die(`confirm ${d.path}: [${c.json?.code}] ${c.json?.detail ?? c.status}`)
395
428
  done++
396
429
  out(` ✓ ${d.path} (${done}/${pending.length})`)
@@ -521,7 +554,7 @@ Commands:
521
554
  status <batch|name> [--watch] review status + fix-loop findings
522
555
  sandbox <file> partner-side test transfer (bridge activation)
523
556
 
524
- Client-side commands (need a CLIENT key — Bridge → API → "Your read keys"):
557
+ Client-side commands (need a CLIENT key — Organisation → API keys):
525
558
  deliveries [--project <id>] what landed in your destination bucket
526
559
  [--dataset <id>] [--since <iso>] [--limit <n>]
527
560
  resolve <path|prefix|id> what a path in your bucket actually is —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myelinbridge/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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": {