@myelinbridge/cli 0.7.0 → 0.8.1

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 +17 -4
  2. package/bin/myelin.js +157 -50
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -22,15 +22,22 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
22
22
  sample sits — `0` = the delivery root is one sample, `1` (default) = each top-level
23
23
  folder is a sample, `2` = one level deeper. Sample-scoped quality checks group by it,
24
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.
25
+ **idempotent** (safe to assert on every pipeline run, before and after the lock) and
26
+ **locks once your first batch leaves draft**, so that verdicts stay comparable across
27
+ deliveries after that, re-asserting the current value still succeeds and only a
28
+ *change* exits `2`. This is the only dataset field you can write.
28
29
 
29
30
  - **Resume = re-run.** `push` is idempotent: already-uploaded files are skipped
30
31
  (path + size), and within a large file, parts that already landed are skipped
31
32
  too (S3 multipart). Uploads go direct to storage over short-lived presigned
32
33
  URLs — no credential is stored on your machine, and revoking the API key cuts
33
34
  off signing immediately.
35
+ - **Transient failures retry themselves.** Since 0.8.0 a rate limit (`429`)
36
+ waits out `Retry-After` and retries on every command, and a part upload that
37
+ hits a storage hiccup (5xx, an edge timeout, an expired URL) re-signs, backs
38
+ off, and retries up to 4 attempts before surfacing — an unattended pipeline
39
+ run survives a blip. A retried `--submit` whose first attempt actually landed
40
+ gets a success echo (`already_submitted: true`), not a false failure.
34
41
  - **Every file is fingerprinted.** Since 0.7.0, `push` computes an MD5 of each
35
42
  file while it uploads and records it with the delivery. If the client's
36
43
  quality contract includes a checksum-manifest check, your delivery verifies
@@ -65,7 +72,7 @@ partner's data, scope it to that partner's projects.
65
72
  ```bash
66
73
  export MYELIN_API_KEY=myl_live_…
67
74
 
68
- # What has landed, newest first
75
+ # What has landed, newest first (page with --cursor from the printed next_cursor)
69
76
  myelin deliveries --dataset <dataset-id>
70
77
 
71
78
  # What is this object, exactly?
@@ -94,6 +101,12 @@ A partner key calling these gets `403 wrong_key_side`, and vice versa.
94
101
  Every command takes `--json`. Exit codes: `0` ok · `1` error · `2` blocked
95
102
  (blocking preflight failure, locked delivery, blocked submit, locked sample depth).
96
103
 
104
+ API errors carry structure, not just prose: in `--json` mode an error is
105
+ `{ "error", "code", "status", "request_id" }` — branch on `code`, and quote the
106
+ `request_id` when reporting a problem (it is printed in human mode too; it lets
107
+ Myelin find the exact server-side log line). `myelin version` prints the CLI
108
+ version.
109
+
97
110
  ## Environment
98
111
 
99
112
  | Variable | |
package/bin/myelin.js CHANGED
@@ -15,6 +15,7 @@ import process from 'node:process'
15
15
 
16
16
  const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
17
17
  const KEY = process.env.MYELIN_API_KEY
18
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
18
19
 
19
20
  // Extra headers sent with every API call — newline-separated "Name: value"
20
21
  // pairs in MYELIN_API_HEADER. For deployments fronted by something that
@@ -49,13 +50,19 @@ class CliExit extends Error {}
49
50
  // assertion on Windows, which printed a C stack trace after a perfectly good
50
51
  // error message and replaced the documented exit code with 127. Node exits on
51
52
  // its own once the request settles — measured at ~130 ms, no keep-alive stall.
52
- const die = (message, code = 1) => {
53
- if (JSON_MODE) console.log(JSON.stringify({ error: message }))
54
- else console.error(`✗ ${message}`)
53
+ // `extra` fields ride into the --json error object as structured data.
54
+ const die = (message, code = 1, extra = null) => {
55
+ if (JSON_MODE) console.log(JSON.stringify({ error: message, ...(extra ?? {}) }))
56
+ else {
57
+ console.error(`✗ ${message}`)
58
+ if (extra?.request_id) console.error(` request id ${extra.request_id} — include it if you report this`)
59
+ }
55
60
  process.exitCode = code
56
61
  throw new CliExit(message)
57
62
  }
58
63
 
64
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
65
+
59
66
  // Column widths come from the content. padEnd() alone silently ran a long
60
67
  // value into the next column — a 28-character dataset slug swallowed the
61
68
  // STATUS header's gutter. Pass headers = null for an unheadered list.
@@ -80,27 +87,61 @@ function opt(name) {
80
87
  return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
81
88
  }
82
89
 
90
+ // 429s are handled HERE so every command inherits the platform's documented
91
+ // contract (honour Retry-After): bounded — 4 waits, then the error surfaces
92
+ // like any other. The wait is capped at 60 s so a pathological header cannot
93
+ // stall a pipeline for an hour.
83
94
  async function api(method, path, body, raw) {
84
- const res = await fetch(API_URL + path, {
85
- method,
86
- headers: {
87
- ...EXTRA_HEADERS,
88
- Authorization: `Bearer ${KEY}`,
89
- ...(body && !raw ? { 'Content-Type': 'application/json' } : {}),
90
- },
91
- body: raw ? body : body ? JSON.stringify(body) : undefined,
92
- }).catch((err) => die(`Cannot reach ${API_URL}: ${err.message}`))
93
- let json = null
94
- try { json = await res.json() } catch { /* non-JSON body */ }
95
- return { status: res.status, json }
95
+ for (let attempt = 1; ; attempt++) {
96
+ const res = await fetch(API_URL + path, {
97
+ method,
98
+ headers: {
99
+ ...EXTRA_HEADERS,
100
+ Authorization: `Bearer ${KEY}`,
101
+ ...(body && !raw ? { 'Content-Type': 'application/json' } : {}),
102
+ },
103
+ body: raw ? body : body ? JSON.stringify(body) : undefined,
104
+ }).catch((err) => die(`Cannot reach ${API_URL}: ${err.message}`))
105
+ let json = null
106
+ try { json = await res.json() } catch { /* non-JSON body */ }
107
+ const requestId = res.headers.get('myelin-request-id') ?? json?.request_id ?? null
108
+ if (res.status === 429 && attempt <= 4) {
109
+ const wait = Math.min(Number(res.headers.get('retry-after') ?? json?.retry_after) || 5, 60)
110
+ out(` rate limited — retrying in ${wait}s (${attempt}/4)`)
111
+ await sleep(wait * 1000 + Math.random() * 500)
112
+ continue
113
+ }
114
+ return { status: res.status, json, requestId }
115
+ }
96
116
  }
97
117
 
98
- function expectOk(r, context) {
99
- if (r.status >= 200 && r.status < 300) return r.json
118
+ const BLOCKED_CODES = ['delivery_locked', 'submit_blocked', 'dataset_not_active', 'api_disabled', 'bridge_paused', 'sample_depth_locked']
119
+
120
+ // Every API failure surfaces the stable `code` AND the request id the server
121
+ // echoed — the id is what lets Myelin find the exact server-side log line for
122
+ // a support report. In --json mode both are structured fields, never prose a
123
+ // script has to regex out of the message.
124
+ function apiDie(context, r) {
100
125
  const detail = r.json?.detail ?? `HTTP ${r.status}`
101
126
  const code = r.json?.code ?? 'error'
102
- const blocked = ['delivery_locked', 'submit_blocked', 'dataset_not_active', 'api_disabled', 'bridge_paused', 'sample_depth_locked'].includes(code)
103
- die(`${context}: [${code}] ${detail}`, blocked ? 2 : 1)
127
+ die(`${context}: [${code}] ${detail}`, BLOCKED_CODES.includes(code) ? 2 : 1, {
128
+ code,
129
+ status: r.status,
130
+ ...(r.requestId ? { request_id: r.requestId } : {}),
131
+ })
132
+ }
133
+
134
+ function expectOk(r, context) {
135
+ if (r.status >= 200 && r.status < 300) {
136
+ // A 2xx that isn't JSON is almost always a front door answering instead
137
+ // of Myelin (a gateway login page, an SSO-protected preview whose pass
138
+ // lapsed). Name the likely cause instead of crashing on j.<field>.
139
+ if (r.json == null) {
140
+ die(`${context}: the server answered ${r.status} but not with JSON — if this deployment sits behind a gateway or front door, your pass (MYELIN_API_HEADER) may have lapsed. Re-mint it and retry.`)
141
+ }
142
+ return r.json
143
+ }
144
+ apiDie(context, r)
104
145
  }
105
146
 
106
147
  // ——— dataset resolution (id or slug) ———
@@ -123,9 +164,15 @@ async function resolveDataset(ref) {
123
164
  function walkDir(root) {
124
165
  const files = []
125
166
  const walk = (dir) => {
126
- for (const entry of readdirSync(dir)) {
167
+ let entries
168
+ try { entries = readdirSync(dir) } catch (err) { die(`Cannot read directory ${dir}: ${err.message}`) }
169
+ for (const entry of entries) {
127
170
  const full = join(dir, entry)
128
- const st = statSync(full)
171
+ // A broken symlink or unreadable file gets a named error, not a stack —
172
+ // everything under the directory ships, so an unreadable entry is the
173
+ // partner's to fix or remove, and the message should say which one.
174
+ let st
175
+ try { st = statSync(full) } catch (err) { die(`Cannot read ${full}: ${err.message} — fix or remove it (every file under the directory is included)`) }
129
176
  if (st.isDirectory()) walk(full)
130
177
  else files.push({ abs: full, path: '/' + relative(root, full).split(sep).join('/'), size: st.size })
131
178
  }
@@ -194,12 +241,18 @@ async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone)
194
241
  const urlByPart = new Map(j.urls.map((u) => [u.part_number, u.url]))
195
242
 
196
243
  // upload the still-missing parts in this window, up to 4 concurrent.
197
- // Presigned URLs expire (~30 min); on a rejection we re-sign that one part
198
- // and retry once — cheap, since upload-parts is idempotent.
199
244
  const missing = window.filter((n) => !etags.has(n))
200
245
  const queue = [...missing]
246
+ // Re-sign one part. Returns a fresh URL — or null when the server reports
247
+ // the part ALREADY LANDED: a 524/timeout can kill the response after S3
248
+ // committed the bytes, and the server then returns no URL for that part,
249
+ // only its etag in uploaded_parts. That case is a success to record, not
250
+ // a dead end to die on.
201
251
  const resign = async (partNo) => {
202
252
  const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: [partNo] }), 're-sign upload part')
253
+ uploadId = j.upload_id
254
+ const landed = j.uploaded_parts.find((p) => p.part_number === partNo)
255
+ if (landed) { etags.set(partNo, landed.etag); return null }
203
256
  const fresh = j.urls.find((u) => u.part_number === partNo)?.url
204
257
  if (!fresh) die(`upload part ${partNo}: could not re-sign (already finalized?)`)
205
258
  urlByPart.set(partNo, fresh)
@@ -212,14 +265,29 @@ async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone)
212
265
  const start = (partNo - 1) * partSize
213
266
  const end = Math.min(start + partSize, size) - 1
214
267
  const bytes = await readPart(absPath, start, end)
215
- let put = await fetch(urlByPart.get(partNo), { method: 'PUT', body: bytes }).catch(() => null)
216
- if (!put || put.status === 403 || put.status === 401) {
217
- put = await fetch(await resign(partNo), { method: 'PUT', body: bytes }).catch((e) => die(`upload part ${partNo}: ${e.message}`))
268
+ // Expired URLs (401/403), transient storage errors (5xx), edge kills
269
+ // (524) and throttling all deserve the same treatment: re-sign — always
270
+ // safe, upload-parts is idempotent back off, try again. Bounded at 4
271
+ // attempts, then the error surfaces. An unattended pipeline should
272
+ // survive a hiccup, not page a human for it; `push` stays resumable
273
+ // either way.
274
+ let put = null
275
+ for (let attempt = 1; ; attempt++) {
276
+ const url = attempt === 1 ? urlByPart.get(partNo) : await resign(partNo)
277
+ if (url === null) break // part landed server-side — resign() recorded its etag
278
+ put = await fetch(url, { method: 'PUT', body: bytes }).catch(() => null)
279
+ if (put && put.status === 200) break
280
+ const reason = put ? `HTTP ${put.status}` : 'network error'
281
+ if (attempt >= 4) die(`upload part ${partNo}: ${reason} after ${attempt} attempts — re-run to resume`)
282
+ const wait = [1, 4, 10][attempt - 1] + Math.random()
283
+ out(` part ${partNo}: ${reason} — retrying in ${Math.round(wait)}s (${attempt}/3)`)
284
+ await sleep(wait * 1000)
285
+ }
286
+ if (put && put.status === 200) {
287
+ const etag = put.headers.get('etag')
288
+ if (!etag) die(`upload part ${partNo}: storage returned no ETag`)
289
+ etags.set(partNo, etag)
218
290
  }
219
- if (put.status !== 200) die(`upload part ${partNo}: HTTP ${put.status}`)
220
- const etag = put.headers.get('etag')
221
- if (!etag) die(`upload part ${partNo}: storage returned no ETag`)
222
- etags.set(partNo, etag)
223
291
  if (onPartDone) onPartDone()
224
292
  }
225
293
  })
@@ -374,8 +442,27 @@ async function cmdPush() {
374
442
  if (files.length === 0) die(`No files under ${root}`)
375
443
  const dataset = await resolveDataset(dsRef)
376
444
 
377
- // 1. create or resume the draft
378
- const created = expectOk(await api('POST', `/datasets/${dataset.id}/batches`), 'create batch')
445
+ // 1. create or resume the draft. A 409 delivery_locked here can BE the
446
+ // lost-response retry case: our previous run's --submit landed, the
447
+ // batch left draft, and the pipeline re-ran the whole command. Before
448
+ // claiming success, verify the in-flight batch actually IS this delivery
449
+ // (same paths, same sizes) — an unrelated delivery under review must
450
+ // keep the honest blocked exit.
451
+ const createRes = await api('POST', `/datasets/${dataset.id}/batches`)
452
+ if (createRes.status === 409 && createRes.json?.code === 'delivery_locked' && createRes.json?.batch_id && flag('submit')) {
453
+ const b = await api('GET', `/batches/${createRes.json.batch_id}`)
454
+ const remote = b.status === 200 ? (b.json.batch?.files ?? []) : []
455
+ const matches =
456
+ remote.length === files.length &&
457
+ files.every((f) => remote.some((r) => r.path === f.path && r.size_bytes === f.size))
458
+ if (matches) {
459
+ const j = expectOk(await api('POST', `/batches/${createRes.json.batch_id}/submit`), 'submit')
460
+ emit(j)
461
+ out(`✓ This delivery is already submitted (${j.status}) — nothing to redo. Track: myelin status ${createRes.json.batch_id} --watch`)
462
+ return
463
+ }
464
+ }
465
+ const created = expectOk(createRes, 'create batch')
379
466
  const batchId = created.batch_id
380
467
  out(`Draft ${created.resumed ? 'resumed' : 'created'} (${batchId.slice(0, 8)}…).`)
381
468
 
@@ -389,6 +476,7 @@ async function cmdPush() {
389
476
  replace: flag('replace'),
390
477
  })
391
478
  if (r.status !== 200 && r.status !== 409) expectOk(r, 'declare')
479
+ if (!r.json?.files) apiDie('declare', r) // 409 with a non-JSON body (a proxy page, an edge error) must not crash
392
480
  const conflicts = r.json.files.filter((f) => f.error)
393
481
  if (conflicts.length > 0) {
394
482
  for (const c of conflicts) out(`✗ ${c.path}: ${c.error}`)
@@ -424,7 +512,7 @@ async function cmdPush() {
424
512
  parts,
425
513
  ...(checksum ? { checksum, checksum_algorithm: 'md5' } : {}),
426
514
  })
427
- if (c.status !== 200) die(`confirm ${d.path}: [${c.json?.code}] ${c.json?.detail ?? c.status}`)
515
+ if (c.status !== 200) apiDie(`confirm ${d.path}`, c)
428
516
  done++
429
517
  out(` ✓ ${d.path} (${done}/${pending.length})`)
430
518
  }
@@ -434,15 +522,17 @@ async function cmdPush() {
434
522
 
435
523
  // 4. optional submit
436
524
  if (flag('submit')) {
437
- const r = await api('POST', `/batches/${batchId}/submit`)
438
- if (r.status !== 200) {
439
- const code = r.json?.code ?? 'error'
440
- die(`submit: [${code}] ${r.json?.detail ?? r.status}`, code === 'submit_blocked' ? 2 : 1)
525
+ const j = expectOk(await api('POST', `/batches/${batchId}/submit`), 'submit')
526
+ emit(j)
527
+ if (j.already_submitted) {
528
+ // A retried submit whose first attempt actually landed: the API echoes
529
+ // success instead of failing, and so do we — nothing to redo.
530
+ out(`✓ Batch already submitted (${j.status}) — nothing to redo. Track: myelin status ${batchId} --watch`)
531
+ } else {
532
+ const a = j.auto_checks
533
+ out(`Submitting… auto-checks: ${a.passed} passed, ${a.flagged} flagged, ${a.failed} failed.`)
534
+ out(`✓ Batch submitted for review. Track: myelin status ${batchId} --watch`)
441
535
  }
442
- const a = r.json.auto_checks
443
- emit(r.json)
444
- out(`Submitting… auto-checks: ${a.passed} passed, ${a.flagged} flagged, ${a.failed} failed.`)
445
- out(`✓ Batch submitted for review. Track: myelin status ${batchId} --watch`)
446
536
  } else {
447
537
  emit({ batch_id: batchId, files: declared.length, submitted: false })
448
538
  out(`Draft ready (not submitted). Submit with: myelin push ${dir} --dataset ${dsRef} --submit`)
@@ -456,7 +546,7 @@ async function cmdStatus() {
456
546
  const findBatch = async () => {
457
547
  if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
458
548
  const r = await api('GET', `/batches/${ref}`)
459
- if (r.status === 200) return r.json.batch
549
+ if (r.status === 200) return expectOk(r, 'batch').batch
460
550
  die(`Batch ${ref} not found in this key's scope`)
461
551
  }
462
552
  const list = expectOk(await api('GET', '/batches?limit=100'), 'batches')
@@ -496,7 +586,8 @@ async function cmdSandbox() {
496
586
  const file = args[1]
497
587
  if (!file) die('Usage: myelin sandbox <file>')
498
588
  const abs = resolve(file)
499
- const size = statSync(abs).size
589
+ let size
590
+ try { size = statSync(abs).size } catch (err) { die(`Cannot read ${abs}: ${err.message}`) }
500
591
  if (size > 4 * 1024 * 1024) die('Sandbox files should stay under ~4 MB — this verifies connectivity, not throughput.')
501
592
 
502
593
  const me = expectOk(await api('GET', '/me'), 'auth')
@@ -556,22 +647,26 @@ Commands:
556
647
 
557
648
  Client-side commands (need a CLIENT key — Organisation → API keys):
558
649
  deliveries [--project <id>] what landed in your destination bucket
559
- [--dataset <id>] [--since <iso>] [--limit <n>]
650
+ [--dataset <id>] [--since <iso>] [--limit <n>] [--cursor <ts>]
560
651
  resolve <path|prefix|id> what a path in your bucket actually is —
561
652
  project, dataset, batch, file
562
653
 
563
- Every command accepts --json. Exit codes: 0 ok · 1 error · 2 blocked.`)
654
+ version print the CLI version
655
+
656
+ Every command accepts --json. Exit codes: 0 ok · 1 error · 2 blocked.
657
+ Errors print the API request id — include it when reporting a problem.`)
564
658
  }
565
659
 
566
660
  // ——— client-side commands (client keys only) ———
567
661
  //
568
- // These need a CLIENT key, minted by the client bridge owner in Bridge → API
569
- // "Your read keys". A partner key gets 403 wrong_key_side, and the message says
570
- // so — the two sides deliberately do not silently degrade into each other.
662
+ // These need a CLIENT key, minted by a tenant admin in Organisation → API keys
663
+ // (tenant-scoped since 2026-08-08). A partner key gets 403 wrong_key_side, and
664
+ // the message says so — the two sides deliberately do not silently degrade
665
+ // into each other.
571
666
 
572
667
  async function cmdDeliveries() {
573
668
  const qs = new URLSearchParams()
574
- const map = { project: 'project_id', dataset: 'dataset_id', since: 'since', limit: 'limit' }
669
+ const map = { project: 'project_id', dataset: 'dataset_id', since: 'since', limit: 'limit', cursor: 'cursor' }
575
670
  for (const [flagName, param] of Object.entries(map)) {
576
671
  const v = opt(flagName)
577
672
  if (v) qs.set(param, v)
@@ -592,7 +687,15 @@ async function cmdDeliveries() {
592
687
  d.destination_root ?? '—',
593
688
  ])
594
689
  for (const line of table(['PROJECT', 'DATASET', 'BATCH', 'FILES', 'DELIVERED', 'LOCATION'], rows)) out(line)
595
- if (j.next_cursor) out(`\n… more — re-run with --since ${j.next_cursor}`)
690
+ if (j.next_cursor) {
691
+ // Carry the invocation's own filters into the hint — a hint that drops
692
+ // --dataset pages the unfiltered list and interleaves other datasets.
693
+ const carried = ['project', 'dataset', 'since', 'limit']
694
+ .filter((f) => opt(f))
695
+ .map((f) => `--${f} ${opt(f)}`)
696
+ .join(' ')
697
+ out(`\n… more — next page: myelin deliveries ${carried ? `${carried} ` : ''}--cursor ${j.next_cursor}`)
698
+ }
596
699
  }
597
700
 
598
701
  async function cmdResolve() {
@@ -623,6 +726,10 @@ if (!command || command === 'help' || command === '--help') {
623
726
  cmdHelp()
624
727
  process.exit(0)
625
728
  }
729
+ if (command === 'version' || command === '--version' || command === '-v') {
730
+ console.log(JSON_MODE ? JSON.stringify({ version: VERSION }) : VERSION)
731
+ process.exit(0)
732
+ }
626
733
 
627
734
  const commands = {
628
735
  ping: cmdPing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myelinbridge/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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": {